107 lines
4.5 KiB
TypeScript
107 lines
4.5 KiB
TypeScript
import { generateText } from "ai";
|
|
import { z } from "zod";
|
|
import { getConversationModel } from "../actors/conversation/agent.js";
|
|
import { config } from "../config.js";
|
|
import { log } from "../log.js";
|
|
import { isAllowedNotificationHref, MODULE_IDS, type HomeModuleId, type HomeNotification, type HomeUrgency } from "./types.js";
|
|
|
|
const notificationSchema = z.object({
|
|
moduleId: z.enum(MODULE_IDS as [HomeModuleId, ...HomeModuleId[]]),
|
|
title: z.string().min(4).max(72),
|
|
subtitle: z.string().min(4).max(110),
|
|
tag: z.string().min(2).max(14),
|
|
urgency: z.enum(["now", "today", "soon", "calm"]),
|
|
href: z.string().min(1),
|
|
source: z.enum(["resume", "interview", "roleplay", "qscore", "mission", "social", "pathways", "rewards", "system"]),
|
|
reason: z.string().max(160).optional(),
|
|
});
|
|
|
|
const feedSchema = z.object({
|
|
notifications: z.array(notificationSchema).min(6).max(24),
|
|
});
|
|
|
|
const HOME_FEED_AGENT_TIMEOUT_MS = Number(process.env.HOME_FEED_AGENT_TIMEOUT_MS ?? 20000);
|
|
|
|
export type AgentHomeNotification = z.infer<typeof notificationSchema>;
|
|
|
|
const SYSTEM = `You are GrowQR's Home Feed Agent.
|
|
Your job is to rank and rewrite dashboard notifications from real platform context.
|
|
Keep them coherent, specific, and action-oriented. Do not invent unavailable products, scores, sessions, deadlines, companies, artifacts, or rewards.
|
|
Every notification must point to one of these real dashboard routes:
|
|
- /agents/resume for resume building, resume analysis, ATS, resume suggestions
|
|
- /agents/interview for mock interview setup, interview session, interview review
|
|
- /agents/roleplay for recruiter/manager/salary/stakeholder roleplay
|
|
- /agents/qscore for Q Score/readiness explanations
|
|
- /missions for mission progress, approvals, artifacts, next stages
|
|
- /social for LinkedIn/social branding
|
|
- /pathways for locked/coming-soon pathways
|
|
- /rewards for locked/coming-soon rewards
|
|
- /suggestions for broad onboarding/profile suggestions
|
|
Use minimal iPhone-notification copy: title <= 72 chars, subtitle <= 110 chars, short tag <= 14 chars.
|
|
Use urgency truthfully: now = needs immediate user action, today = useful today, soon = next few days, calm = informational.`;
|
|
|
|
function sanitizeHref(href: string, moduleId: HomeModuleId) {
|
|
if (isAllowedNotificationHref(href)) return href;
|
|
if (href.startsWith("/missions")) return "/missions/active";
|
|
if (href.startsWith("/social")) return "/social";
|
|
if (href.startsWith("/pathways")) return "/pathways";
|
|
if (href.startsWith("/rewards")) return "/rewards";
|
|
if (href.startsWith("/productivity")) return "/productivity";
|
|
return moduleId === "productivity" ? "/productivity" : `/${moduleId}`;
|
|
}
|
|
|
|
function stableId(prefix: string, index: number) {
|
|
return `${prefix}-${index + 1}`;
|
|
}
|
|
|
|
function parseJsonObject(text: string) {
|
|
const cleaned = text.trim().replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim();
|
|
try {
|
|
return JSON.parse(cleaned);
|
|
} catch {
|
|
const start = cleaned.indexOf("{");
|
|
const end = cleaned.lastIndexOf("}");
|
|
if (start === -1 || end === -1 || end <= start) throw new Error("home_feed_agent_invalid_json");
|
|
return JSON.parse(cleaned.slice(start, end + 1));
|
|
}
|
|
}
|
|
|
|
export async function refineHomeNotificationsWithAgent(input: {
|
|
userId: string;
|
|
context: Record<string, unknown>;
|
|
seeds: Array<Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId }>;
|
|
}): Promise<Array<AgentHomeNotification & { id: string; createdAt: string }>> {
|
|
if (!config.llmApiKey) return [];
|
|
|
|
try {
|
|
const result = await generateText({
|
|
model: getConversationModel(),
|
|
system: [
|
|
SYSTEM,
|
|
"Return JSON only. Shape: {\"notifications\": [...]}. Do not use markdown.",
|
|
"Use ASCII punctuation only.",
|
|
].join("\n"),
|
|
timeout: HOME_FEED_AGENT_TIMEOUT_MS,
|
|
prompt: JSON.stringify({
|
|
task: "Create coherent GrowQR home dashboard notifications from the provided service context and deterministic candidates.",
|
|
userId: input.userId,
|
|
serviceContext: input.context,
|
|
deterministicCandidates: input.seeds,
|
|
}),
|
|
});
|
|
|
|
const parsed = feedSchema.parse(parseJsonObject(result.text));
|
|
const now = new Date().toISOString();
|
|
return parsed.notifications.map((n, index) => ({
|
|
...n,
|
|
href: sanitizeHref(n.href, n.moduleId),
|
|
urgency: n.urgency as HomeUrgency,
|
|
id: stableId("agent-home", index),
|
|
createdAt: now,
|
|
}));
|
|
} catch (err) {
|
|
log.warn({ err, userId: input.userId }, "home feed agent failed; using deterministic notifications");
|
|
return [];
|
|
}
|
|
}
|