feat: personalize home feed suggestions

This commit is contained in:
-Puter
2026-06-05 17:30:00 +05:30
parent e478db9334
commit d10ef2a882
4 changed files with 192 additions and 29 deletions

View File

@@ -3,7 +3,7 @@ import { z } from "zod";
import { getConversationModel } from "../actors/conversation/agent.js";
import { config } from "../config.js";
import { log } from "../log.js";
import { ALLOWED_NOTIFICATION_HREFS, MODULE_IDS, type HomeModuleId, type HomeNotification, type HomeUrgency } from "./types.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[]]),
@@ -39,12 +39,8 @@ Use minimal iPhone-notification copy: title <= 72 chars, subtitle <= 110 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 (ALLOWED_NOTIFICATION_HREFS.has(href)) return href;
if (href.startsWith("/agents/resume")) return "/agents/resume";
if (href.startsWith("/agents/interview")) return "/agents/interview";
if (href.startsWith("/agents/roleplay")) return "/agents/roleplay";
if (href.startsWith("/agents/qscore")) return "/agents/qscore";
if (href.startsWith("/missions")) return "/missions";
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";

View File

@@ -8,6 +8,7 @@ import {
growQscoreProjectionState,
missionArtifacts,
missionServiceSessions,
missionSuggestions,
qscoreSnapshots,
users,
type GrowHomeNotificationRow,
@@ -16,7 +17,7 @@ import {
import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js";
import { refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
import {
ALLOWED_NOTIFICATION_HREFS,
isAllowedNotificationHref,
MODULE_IDS,
MODULE_META,
type HomeFeedResponse,
@@ -35,7 +36,7 @@ const SERVICE_HREFS = {
interview: "/agents/interview",
roleplay: "/agents/roleplay",
qscore: "/agents/qscore",
mission: "/missions",
mission: "/missions/active",
social: "/social",
pathways: "/pathways",
rewards: "/rewards",
@@ -50,10 +51,13 @@ type HomeContext = {
qscore: { score: number; signalCount: number; summary: string | null; dimensions: Record<string, unknown> | null } | undefined;
qscoreSignals: Array<{ signalId: string; score: number; source: string | null; updatedAt: Date }>;
activeMissions: Array<{ instanceId: string; missionId: string; title: string; status: string; progressPercent: number; currentStageId: string | null; updatedAt: Date }>;
missionSuggestions: Array<{ id: string; missionInstanceId: string; missionId: string; stageId: string | null; role: string; type: string; title: string; body: string; reason: string | null; priority: number; urgency: string; ctaLabel: string; ctaHref: string; updatedAt: Date }>;
sessions: Array<{ serviceId: string; externalId: string; status: string; updatedAt: Date; metadata: Record<string, unknown> | null }>;
artifacts: Array<{ serviceId: string | null; type: string; title: string; status: string; summary: string | null; createdAt: Date }>;
events: Array<{ source: string; type: string; occurredAt: Date; payload: Record<string, unknown> }>;
serviceStates: Record<string, unknown>;
userProfile?: Record<string, unknown>;
preferences: Record<string, unknown>;
};
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -64,13 +68,68 @@ function numberFrom(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function arrayOfStrings(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
}
function recordOf(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
function profileFromPreferences(preferences: Record<string, unknown>) {
const onboarding = recordOf(preferences.onboarding);
const interview = recordOf(preferences.interview_preferences);
const resume = recordOf(preferences.resume_preferences);
const mission = recordOf(preferences.mission_preferences);
const targetRoles = arrayOfStrings(preferences.target_roles);
const targetCompanies = arrayOfStrings(preferences.target_companies);
const focusAreas = arrayOfStrings(interview.focus_areas);
return {
targetRole: targetRoles[0] ?? (typeof resume.target_title === "string" ? resume.target_title : "Senior Data Scientist"),
targetCompany: targetCompanies[0] ?? "target company",
industry: typeof preferences.industry === "string" ? preferences.industry : "AI / SaaS",
focusAreas,
weakSpots: arrayOfStrings(interview.weak_spots),
jobDescription: typeof interview.job_description === "string" ? interview.job_description : undefined,
activeGoal: typeof mission.active_goal === "string" ? mission.active_goal : typeof onboarding.goal === "string" ? onboarding.goal : undefined,
onboardingComplete: Boolean(onboarding.completed_at),
};
}
function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) {
const profile = profileFromPreferences(ctx.preferences);
const params = new URLSearchParams({ source: "home" });
if (mission?.instanceId) params.set("missionInstanceId", mission.instanceId);
if (mission?.missionId) params.set("missionId", mission.missionId);
if (mission?.stageId) params.set("stageId", mission.stageId);
params.set("targetRole", profile.targetRole);
if (profile.targetCompany !== "target company") params.set("targetCompany", profile.targetCompany);
if (profile.industry) params.set("industry", profile.industry);
if (profile.focusAreas.length) params.set("focusAreas", profile.focusAreas.slice(0, 4).join(","));
if (profile.weakSpots.length) params.set("weakSpots", profile.weakSpots.slice(0, 3).join(","));
if (profile.jobDescription) params.set("jobDescription", profile.jobDescription.slice(0, 900));
if (service === "interview") return `/agents/interview/setup?${params.toString()}`;
if (service === "roleplay") return `/agents/roleplay/setup?${params.toString()}`;
if (service === "resume") return `/agents/resume?${params.toString()}`;
return `/agents/qscore?${params.toString()}`;
}
function sourceFromSuggestionRole(role: string): HomeSource {
const value = role.toLowerCase();
if (value.includes("resume")) return "resume";
if (value.includes("roleplay")) return "roleplay";
if (value.includes("interview")) return "interview";
if (value.includes("q")) return "qscore";
return "mission";
}
function sanitizeUrgency(value: string): HomeUrgency {
if (value === "now" || value === "today" || value === "soon" || value === "calm") return value;
return "calm";
}
function sanitizeHref(href: string | undefined, fallback: string) {
if (href && ALLOWED_NOTIFICATION_HREFS.has(href)) return href;
if (href && isAllowedNotificationHref(href)) return href;
return fallback;
}
@@ -97,7 +156,9 @@ function hasAnyRealActivity(ctx: HomeContext) {
ctx.activeMissions.length ||
ctx.sessions.length ||
ctx.artifacts.length ||
ctx.events.length,
ctx.events.length ||
ctx.missionSuggestions.length ||
profileFromPreferences(ctx.preferences).onboardingComplete,
);
}
@@ -119,6 +180,7 @@ function buildDayOneSeeds(): SeedNotification[] {
function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
const seeds = buildDayOneSeeds().filter((seed) => seed.moduleId === "pathways" || seed.moduleId === "rewards");
const profile = profileFromPreferences(ctx.preferences);
const qscore = ctx.qscore?.score ?? Math.round(ctx.qscoreSignals.reduce((sum, s) => sum + s.score, 0) / Math.max(ctx.qscoreSignals.length, 1));
const ats = latestScore(ctx.qscoreSignals, "resume.ats_compatibility");
const interviewOverall = latestScore(ctx.qscoreSignals, "interview.overall_score");
@@ -130,14 +192,55 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
const roleplayReview = serviceEvent(ctx, "roleplay.", "review");
const resumeAnalysis = serviceEvent(ctx, "resume.", "analysis");
for (const suggestion of ctx.missionSuggestions.slice(0, 5)) {
const mission = ctx.activeMissions.find((item) => item.instanceId === suggestion.missionInstanceId);
const source = sourceFromSuggestionRole(suggestion.role);
const href = sanitizeHref(suggestion.ctaHref, mission ? `/missions/active?missionInstanceId=${encodeURIComponent(mission.instanceId)}` : SERVICE_HREFS.mission);
pushSeed(seeds, {
moduleId: "suggestions",
title: suggestion.title,
subtitle: suggestion.body,
tag: suggestion.ctaLabel.replace(/\s+/g, " ").slice(0, 14),
urgency: sanitizeUrgency(suggestion.urgency),
href,
source,
reason: suggestion.reason ?? undefined,
priority: Math.max(100, suggestion.priority + 10),
});
pushSeed(seeds, {
moduleId: suggestion.role.toLowerCase().includes("resume") || suggestion.role.toLowerCase().includes("interview") || suggestion.role.toLowerCase().includes("roleplay") ? "productivity" : "missions",
title: `${suggestion.role}: ${suggestion.title}`,
subtitle: mission ? `${mission.title} · ${suggestion.body}` : suggestion.body,
tag: suggestion.urgency === "now" ? "Now" : suggestion.urgency === "today" ? "Today" : "Next",
urgency: sanitizeUrgency(suggestion.urgency),
href,
source,
reason: suggestion.reason ?? undefined,
priority: suggestion.priority,
});
}
if (profile.onboardingComplete) {
pushSeed(seeds, {
moduleId: "suggestions",
title: `${profile.targetRole} plan is calibrated`,
subtitle: profile.activeGoal ?? `Today's recommendations are tuned for ${profile.targetRole}${profile.targetCompany !== "target company" ? ` at ${profile.targetCompany}` : ""}.`,
tag: "Profile",
urgency: "today",
href: "/suggestions",
source: "system",
priority: 91,
});
}
if (ctx.qscore || ctx.qscoreSignals.length) {
pushSeed(seeds, {
moduleId: "suggestions",
title: qscore >= 80 ? "Protect your Q Score momentum" : "Raise your Q Score next",
subtitle: qscore >= 80 ? `Readiness is trending at ${qscore}. Keep one proof action moving.` : `Current estimate is ${qscore || 64}. Resume + mock practice are the fastest signals.`,
subtitle: qscore >= 80 ? `Readiness is trending at ${qscore}. Keep one proof action moving for ${profile.targetRole}.` : `Current estimate is ${qscore || 64}. Resume + mock practice are fastest for ${profile.targetRole}.`,
tag: "Q Score",
urgency: qscore >= 80 ? "today" : "now",
href: SERVICE_HREFS.qscore,
href: serviceHref("qscore", ctx),
source: "qscore",
priority: 95,
});
@@ -147,10 +250,10 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
pushSeed(seeds, {
moduleId: "suggestions",
title: ats >= 80 ? "ATS is demo-ready" : "Resume ATS needs one pass",
subtitle: ats >= 80 ? `ATS ${Math.round(ats)} — review role-specific keywords before applying.` : `ATS ${Math.round(ats)} — add JD keywords and measurable bullets.`,
subtitle: ats >= 80 ? `ATS ${Math.round(ats)} — review ${profile.targetRole} keywords before applying.` : `ATS ${Math.round(ats)} — add JD keywords and measurable data-science bullets.`,
tag: ats >= 80 ? "Ready" : "Fix",
urgency: ats >= 80 ? "today" : "now",
href: SERVICE_HREFS.resume,
href: serviceHref("resume", ctx),
source: "resume",
priority: 92,
});
@@ -163,7 +266,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
subtitle: mission.currentStageId ? `Current stage: ${mission.currentStageId.replaceAll("-", " ")}` : "Next action is ready on the mission dashboard.",
tag: mission.status === "paused" ? "Paused" : "Active",
urgency: mission.status === "paused" ? "soon" : "today",
href: SERVICE_HREFS.mission,
href: `/missions/active?missionInstanceId=${encodeURIComponent(mission.instanceId)}`,
source: "mission",
priority: 90 - mission.progressPercent,
});
@@ -186,7 +289,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
pushSeed(seeds, {
moduleId: "social",
title: "Turn proof into LinkedIn updates",
subtitle: ctx.artifacts.length ? `${ctx.artifacts.length} artifact${ctx.artifacts.length === 1 ? "" : "s"} can feed headline, featured, or post ideas.` : "Connect LinkedIn and use mission proof to improve your profile.",
subtitle: ctx.artifacts.length ? `${ctx.artifacts.length} artifact${ctx.artifacts.length === 1 ? "" : "s"} can feed headline, featured, or post ideas.` : `Connect LinkedIn and use ${profile.targetRole} proof to improve your profile.`,
tag: ctx.artifacts.length ? "Proof" : "Setup",
urgency: ctx.artifacts.length ? "today" : "soon",
href: SERVICE_HREFS.social,
@@ -198,51 +301,52 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
pushSeed(seeds, {
moduleId: "productivity",
title: ats !== undefined ? `Resume ATS ${Math.round(ats)}` : "Resume analysis is ready",
subtitle: ats !== undefined && ats >= 80 ? "Use this version for role-fit scan or final polish." : "Open Resume Builder for recommendations and bullet fixes.",
subtitle: ats !== undefined && ats >= 80 ? `Use this version for ${profile.targetRole} role-fit scan or final polish.` : "Open Resume Builder for recommendations and bullet fixes.",
tag: "Resume",
urgency: ats !== undefined && ats < 75 ? "now" : "today",
href: SERVICE_HREFS.resume,
href: serviceHref("resume", ctx),
source: "resume",
priority: 90,
});
}
const firstMission = ctx.activeMissions[0];
if (interviewReview || interviewOverall !== undefined || interviewSession) {
pushSeed(seeds, {
moduleId: "productivity",
title: interviewOverall !== undefined ? `Mock interview score ${Math.round(interviewOverall)}` : "Mock interview review is tracking",
subtitle: interviewReview?.type.includes("processing") ? "Review is still processing; check back from the interview page." : "Open interview practice for review, next drill, or a new session.",
subtitle: interviewReview?.type.includes("processing") ? "Review is still processing; check back from the interview page." : `Open ${profile.targetRole} interview practice for review, next drill, or a new session.`,
tag: interviewReview?.type.includes("processing") ? "Wait" : "Mock",
urgency: interviewReview?.type.includes("processing") ? "soon" : "today",
href: SERVICE_HREFS.interview,
href: serviceHref("interview", ctx, { instanceId: firstMission?.instanceId, missionId: firstMission?.missionId, stageId: firstMission?.currentStageId }),
source: "interview",
priority: 86,
});
} else {
pushSeed(seeds, { moduleId: "productivity", title: "Schedule a mock interview", subtitle: "Generate a behavioral or role-related session from your target role.", tag: "Mock", urgency: "soon", href: SERVICE_HREFS.interview, source: "interview", priority: 72 });
pushSeed(seeds, { moduleId: "productivity", title: `Schedule a ${profile.targetRole} mock`, subtitle: "Generate a behavioral or role-related session from your target role.", tag: "Mock", urgency: "soon", href: serviceHref("interview", ctx, { instanceId: firstMission?.instanceId, missionId: firstMission?.missionId, stageId: firstMission?.currentStageId }), source: "interview", priority: 72 });
}
if (roleplayReview || roleplayComms !== undefined || roleplaySession) {
pushSeed(seeds, {
moduleId: "productivity",
title: roleplayComms !== undefined ? `Roleplay communication ${Math.round(roleplayComms)}` : "Roleplay scenario is ready",
subtitle: "Practice recruiter, manager, salary, or stakeholder conversations.",
subtitle: `Practice recruiter, manager, salary, or stakeholder conversations for ${profile.targetRole}.`,
tag: "Roleplay",
urgency: "soon",
href: SERVICE_HREFS.roleplay,
href: serviceHref("roleplay", ctx, { instanceId: firstMission?.instanceId, missionId: firstMission?.missionId, stageId: firstMission?.currentStageId }),
source: "roleplay",
priority: 78,
});
}
if (!ctx.activeMissions.length) {
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: "Bundle resume fit, mock practice, and Q Score deltas into one journey.", tag: "Begin", urgency: "today", href: SERVICE_HREFS.mission, source: "mission", priority: 80 });
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: `Bundle resume fit, mock practice, and Q Score deltas for ${profile.targetRole}.`, tag: "Begin", urgency: "today", href: "/missions/available", source: "mission", priority: 80 });
}
return seeds;
}
async function collectContext(userId: string): Promise<HomeContext> {
async function collectContext(userId: string, input: { userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeContext> {
const [user] = await db.select({ id: users.id, email: users.email, displayName: users.displayName }).from(users).where(eq(users.id, userId)).limit(1);
const [qscore] = await db.select().from(growQscoreProjectionState).where(eq(growQscoreProjectionState.userId, userId)).limit(1);
const qscoreSignals = await db
@@ -257,6 +361,27 @@ async function collectContext(userId: string): Promise<HomeContext> {
.where(eq(growActiveMissions.userId, userId))
.orderBy(desc(growActiveMissions.updatedAt))
.limit(6);
const suggestions = await db
.select({
id: missionSuggestions.id,
missionInstanceId: missionSuggestions.missionInstanceId,
missionId: missionSuggestions.missionId,
stageId: missionSuggestions.stageId,
role: missionSuggestions.role,
type: missionSuggestions.type,
title: missionSuggestions.title,
body: missionSuggestions.body,
reason: missionSuggestions.reason,
priority: missionSuggestions.priority,
urgency: missionSuggestions.urgency,
ctaLabel: missionSuggestions.ctaLabel,
ctaHref: missionSuggestions.ctaHref,
updatedAt: missionSuggestions.updatedAt,
})
.from(missionSuggestions)
.where(and(eq(missionSuggestions.userId, userId), eq(missionSuggestions.status, "active")))
.orderBy(desc(missionSuggestions.priority), desc(missionSuggestions.updatedAt))
.limit(12);
const sessions = await db
.select({ serviceId: missionServiceSessions.serviceId, externalId: missionServiceSessions.externalId, status: missionServiceSessions.status, updatedAt: missionServiceSessions.updatedAt, metadata: missionServiceSessions.metadata })
.from(missionServiceSessions)
@@ -296,10 +421,13 @@ async function collectContext(userId: string): Promise<HomeContext> {
: undefined,
qscoreSignals,
activeMissions,
missionSuggestions: suggestions,
sessions: sessions.map((s) => ({ ...s, metadata: isRecord(s.metadata) ? s.metadata : null })),
artifacts,
events: events.map((e) => ({ ...e, payload: isRecord(e.payload) ? e.payload : {} })),
serviceStates: { resume: resumeState, interview: interviewState, roleplay: roleplayState },
userProfile: input.userProfile,
preferences: input.preferences ?? {},
};
}
@@ -430,8 +558,8 @@ async function buildIdentity(ctx: HomeContext) {
};
}
export async function getHomeFeed(userId: string, opts: { refresh?: boolean } = {}): Promise<HomeFeedResponse> {
const ctx = await collectContext(userId);
export async function getHomeFeed(userId: string, opts: { refresh?: boolean; userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeFeedResponse> {
const ctx = await collectContext(userId, { userProfile: opts.userProfile, preferences: opts.preferences });
const persisted = await readPersistedNotifications(userId);
const newest = persisted[0]?.createdAt?.getTime() ?? 0;
const hasDemo = persisted.some((row) => row.generatedBy === "demo");
@@ -459,6 +587,9 @@ export async function getHomeFeed(userId: string, opts: { refresh?: boolean } =
artifacts: ctx.artifacts,
recentEvents: ctx.events,
serviceStates: ctx.serviceStates,
missionSuggestions: ctx.missionSuggestions,
userProfile: ctx.userProfile,
preferences: ctx.preferences,
routeRules: SERVICE_HREFS,
},
seeds: deterministic,

View File

@@ -53,12 +53,32 @@ export const MODULE_IDS: HomeModuleId[] = ["suggestions", "missions", "social",
export const ALLOWED_NOTIFICATION_HREFS = new Set([
"/suggestions",
"/missions",
"/missions/active",
"/missions/available",
"/social",
"/pathways",
"/productivity",
"/rewards",
"/agents/resume",
"/agents/interview",
"/agents/interview/setup",
"/agents/roleplay",
"/agents/roleplay/setup",
"/agents/qscore",
]);
export const ALLOWED_NOTIFICATION_HREF_PREFIXES = [
"/missions/active",
"/missions/available",
"/agents/resume",
"/agents/interview",
"/agents/interview/setup",
"/agents/roleplay",
"/agents/roleplay/setup",
"/agents/qscore",
] as const;
export function isAllowedNotificationHref(href: string) {
if (ALLOWED_NOTIFICATION_HREFS.has(href)) return true;
return ALLOWED_NOTIFICATION_HREF_PREFIXES.some((prefix) => href === prefix || href.startsWith(`${prefix}?`));
}

View File

@@ -8,13 +8,29 @@ function canSeedDemo(userId: string) {
return config.nodeEnv !== "production" || config.adminUserIds.includes(userId);
}
async function getUserServiceProfile(req: Request): Promise<{ userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> }> {
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
const headers = new Headers(req.headers);
headers.delete("host");
headers.delete("cookie");
const res = await fetch(target, { method: "GET", headers });
if (!res.ok) return {};
const userProfile = await res.json().catch(() => null) as Record<string, unknown> | null;
const preferences = userProfile?.preferences;
return {
userProfile: userProfile ?? undefined,
preferences: preferences && typeof preferences === "object" && !Array.isArray(preferences) ? preferences as Record<string, unknown> : {},
};
}
export function homeRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.get("/feed", async (c) => {
const refresh = c.req.query("refresh") === "1" || c.req.query("refresh") === "true";
return c.json(await getHomeFeed(c.get("userId"), { refresh }));
const profile = await getUserServiceProfile(c.req.raw);
return c.json(await getHomeFeed(c.get("userId"), { refresh, ...profile }));
});
app.post("/notifications/:id/dismiss", async (c) => {