Implement canonical onboarding and static curator sprint

This commit is contained in:
Sai-karthik
2026-07-11 21:27:21 +00:00
parent b2283476d1
commit 63619f5047
23 changed files with 353 additions and 2170 deletions
+7 -7
View File
@@ -16,8 +16,8 @@ services:
user-service-staging:
environment:
DATABASE_URL: postgres://growqr:growqr@growqr-postgres:5432/growqr
SOCIAL_BRANDING_SERVICE_URL: http://host.docker.internal:18015
SOCIAL_BRANDING_PUBLIC_URL: https://social-staging.gqr.puter.wtf
SOCIAL_BRANDING_SERVICE_URL: https://social.sai-onchain.me
SOCIAL_BRANDING_PUBLIC_URL: https://social.sai-onchain.me
PRODUCT_SERVICE_TIMEOUT_MS: 10000
PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS: 240000
LLM_PROVIDER: opencode
@@ -35,16 +35,16 @@ networks:
name: roleplay-service_default
resume-builder-staging:
external: true
name: resume-builder-staging_default
name: resume-builder_default
course-service:
external: true
name: course-service_default
name: courses_service_default
qscore-service-staging:
external: true
name: qscore-service-staging_default
name: qscore-service_default
matchmaking-v2-staging:
external: true
name: matchmaking-v2_default
name: matchmaking-service_default
user-service-staging:
external: true
name: user-service-staging_default
name: growqr-app_growqr-net
+13
View File
@@ -0,0 +1,13 @@
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "plan" text DEFAULT 'free' NOT NULL;
CREATE TABLE IF NOT EXISTS "onboarding" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
"user_id" text NOT NULL,
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "onboarding_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action
);
CREATE UNIQUE INDEX IF NOT EXISTS "onboarding_user_idx" ON "onboarding" USING btree ("user_id");
+7
View File
@@ -92,6 +92,13 @@
"when": 1780481700000,
"tag": "0012_system_notifications",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1783801800000,
"tag": "0013_onboarding",
"breakpoints": true
}
]
}
-24
View File
@@ -1,24 +0,0 @@
# Curator V1 Conversation Prompt
You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.
## Responsibilities
- Own 30 day direction, streak continuity, and service handoff decisions.
- Carry state from the conversation history and captured task memory.
- If the user gives a short answer like a role name, accept it and ask for the next missing slot.
## Guardrails
- Do not ask the same question twice.
- Do not output checklist items as separate baked chat messages.
- Never say: What should I capture next.
- Do not ask about another subtask, another mission, another service, or a later checklist item from this modal.
- When the user has answered the focused subtask enough, summarize what was captured and stop.
- If more detail is needed, ask exactly one follow-up question for the focused subtask only.
- Use captured task memory from previous subtasks as context. Do not ask the user to repeat details already captured there.
## Task Guidance
- For target-role tasks, collect target role, current background, constraints, then offer a resume or interview handoff.
- For service work, prepare preview-oriented handoffs once the focused subtask has enough context.
- Interview preview defaults: type behavioral, difficulty medium, duration 5.
- Roleplay preview should open the builder as the preview surface.
- Keep the tone concise, warm, and practical.
-28
View File
@@ -1,28 +0,0 @@
# Curator Streak Chat Prompt
You are the GrowQR V1 Curator in a daily or weekly streak chat modal.
Your job is to move the user from a focused streak suggestion to a service preview CTA quickly. The dashboard renders the actual CTA card, so your chat text must never include internal URLs, route names, API paths, setup paths, JSON, or tool names.
## Conversation Rules
- Focus only on the clicked subtask.
- Ask at most one clarifying question before a handoff is ready.
- If the target role is known from onboarding, do not ask for it again.
- If no target role is known and the service is interview or roleplay, ask exactly: `What role are you targeting?`
- If the user gives a vague answer after one question, use your best guess and proceed.
- When enough context exists, summarize the captured intent in one short sentence and stop.
- Do not ask the next subtask question.
- Do not mention setup screens, preview URLs, backend services, actors, tools, or route paths.
- Use ASCII punctuation only.
## Service Defaults
- Interview defaults: `type=behavioral`, `difficulty=medium`, `duration=5`.
- Roleplay defaults: custom scenario, `difficulty=medium`, `duration=5`.
- Prefer onboarding-derived role when available.
- If no role is available after the single follow-up, use `Product Manager` as the MVP fallback.
## Tone
Be concise, calm, and action-oriented. The user should feel like the preview is prepared, not like they are filling out a form.
+20
View File
@@ -8,6 +8,10 @@ async function ensureGrowConversationsMetadataColumn() {
`);
}
async function ensureUserPlanColumn() {
await db.execute(`ALTER TABLE users ADD COLUMN IF NOT EXISTS plan text NOT NULL DEFAULT 'free'`);
}
async function ensureSystemNotificationsTables() {
await db.execute(`
CREATE TABLE IF NOT EXISTS system_notifications (
@@ -41,8 +45,24 @@ async function ensureSystemNotificationsTables() {
await db.execute(`CREATE INDEX IF NOT EXISTS system_notification_preferences_user_idx ON system_notification_preferences (user_id)`);
}
async function ensureOnboardingTable() {
await db.execute(`
CREATE TABLE IF NOT EXISTS onboarding (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
user_id text NOT NULL REFERENCES users(id) ON DELETE cascade,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now()
)
`);
await db.execute(`CREATE UNIQUE INDEX IF NOT EXISTS onboarding_user_idx ON onboarding (user_id)`);
}
export async function ensureRuntimeSchema() {
await ensureUserPlanColumn();
await ensureGrowConversationsMetadataColumn();
await ensureSystemNotificationsTables();
await ensureOnboardingTable();
log.info("runtime schema ensured");
}
+20
View File
@@ -20,6 +20,7 @@ export const users = pgTable(
id: text("id").primaryKey(),
email: text("email").notNull(),
displayName: text("display_name"),
plan: text("plan", { enum: ["free", "pro", "enterprise"] }).notNull().default("free"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
@@ -32,6 +33,25 @@ export const users = pgTable(
}),
);
// Canonical onboarding state. `data` is the user-confirmed source of truth;
// `payload` is the server-owned projection consumed by Curator and Home.
export const onboarding = pgTable(
"onboarding",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
data: jsonb("data").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
payload: jsonb("payload").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
userIdx: uniqueIndex("onboarding_user_idx").on(t.userId),
}),
);
// One per user. Tracks the user's unified agent's container stack + Git repo.
// Per changes.md §2A: per-user Gitea containers removed; central Gitea shared.
// Per changes.md §5: ONE actor per user manages the full orchestration layer.
-231
View File
@@ -1,231 +0,0 @@
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 rawNotificationSchema = notificationSchema.extend({
moduleId: z.enum(MODULE_IDS as [HomeModuleId, ...HomeModuleId[]]).optional(),
tag: z.string().min(2).max(14).optional(),
href: z.string().min(1).optional(),
source: z.enum(["resume", "interview", "roleplay", "qscore", "mission", "social", "pathways", "rewards", "system"]).optional(),
});
const feedSchema = z.object({
notifications: z.array(notificationSchema).min(6).max(24),
});
const rawFeedSchema = z.object({
notifications: z.array(rawNotificationSchema).min(1).max(24),
});
const HOME_FEED_AGENT_ENABLED = process.env.HOME_FEED_AGENT_ENABLED === "true";
const HOME_FEED_AGENT_TIMEOUT_MS = Number(process.env.HOME_FEED_AGENT_TIMEOUT_MS ?? 5000);
const HOME_FEED_AGENT_ATTEMPTS = Math.max(1, Number(process.env.HOME_FEED_AGENT_ATTEMPTS ?? 1));
export type AgentHomeNotification = z.infer<typeof notificationSchema>;
export class HomeFeedAgentError extends Error {
constructor(message: string, readonly cause?: unknown) {
super(message);
this.name = "HomeFeedAgentError";
}
}
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
- /missions for mission progress, approvals, artifacts, next stages
- /agents/social-branding for LinkedIn/social branding
- /agents/matchmaking for Scout/opportunity matching
- /rewards for locked/coming-soon rewards
- /suggestions for broad onboarding/profile suggestions
Every notification object must include:
- moduleId: one of ${MODULE_IDS.join(", ")}
- source: one of resume, interview, roleplay, mission, social, pathways, rewards, system
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("/agents/social-branding")) return "/agents/social-branding";
if (href.startsWith("/agents/matchmaking")) return "/agents/matchmaking";
if (href.startsWith("/rewards")) return "/rewards";
return moduleId === "productivity" ? "/agents" : `/${moduleId}`;
}
function stableId(prefix: string, index: number) {
return `${prefix}-${index + 1}`;
}
function sourceFromHref(href: string) {
if (href.startsWith("/agents/resume")) return "resume";
if (href.startsWith("/agents/interview")) return "interview";
if (href.startsWith("/agents/roleplay")) return "roleplay";
if (href.startsWith("/missions")) return "mission";
if (href.startsWith("/agents/social-branding")) return "social";
if (href.startsWith("/agents/matchmaking")) return "pathways";
if (href.startsWith("/rewards")) return "rewards";
return "system";
}
function moduleFromSource(source: NonNullable<AgentHomeNotification["source"]>): HomeModuleId {
if (source === "mission") return "missions";
if (source === "social") return "social";
if (source === "pathways") return "pathways";
if (source === "rewards") return "rewards";
if (source === "resume" || source === "interview" || source === "roleplay") return "productivity";
return "suggestions";
}
function tagFromSource(source: NonNullable<AgentHomeNotification["source"]>) {
if (source === "mission") return "Mission";
if (source === "roleplay") return "Roleplay";
if (source === "interview") return "Interview";
if (source === "resume") return "Resume";
if (source === "social") return "Social";
if (source === "pathways") return "Pathways";
if (source === "rewards") return "Rewards";
return "Update";
}
function defaultHrefForSource(source: NonNullable<AgentHomeNotification["source"]>, moduleId: HomeModuleId) {
if (source === "resume") return "/agents/resume";
if (source === "interview") return "/agents/interview";
if (source === "roleplay") return "/agents/roleplay";
if (source === "mission") return "/missions";
if (source === "social") return "/agents/social-branding";
if (source === "pathways") return "/agents/matchmaking";
if (source === "rewards") return "/rewards";
return moduleId === "productivity" ? "/agents" : `/${moduleId}`;
}
function normalizeAgentNotification(
raw: z.infer<typeof rawNotificationSchema>,
seeds: Array<Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId }>,
): AgentHomeNotification {
const seed = (raw.href ? seeds.find((item) => item.href === raw.href) : undefined)
?? seeds.find((item) => item.title.toLowerCase() === raw.title.toLowerCase());
const inferredSource = raw.source ?? seed?.source;
const normalizedSource = inferredSource === "qscore" ? "pathways" : inferredSource;
const moduleId = raw.moduleId ?? seed?.moduleId ?? (normalizedSource ? moduleFromSource(normalizedSource) : "suggestions");
const rawHref = raw.href ?? seed?.href ?? (normalizedSource ? defaultHrefForSource(normalizedSource, moduleId) : `/${moduleId}`);
const href = sanitizeHref(rawHref, moduleId);
const seedSource = seed?.source === "qscore" ? "pathways" : seed?.source;
const source = raw.source ?? seedSource ?? sourceFromHref(href);
return notificationSchema.parse({
...raw,
tag: raw.tag ?? seed?.tag ?? tagFromSource(source),
href,
source,
moduleId,
});
}
function notificationKey(notification: AgentHomeNotification) {
return [
notification.moduleId,
notification.href,
notification.title.trim().toLowerCase(),
].join(":");
}
function completeNotificationsWithSeeds(
notifications: AgentHomeNotification[],
seeds: Array<Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId }>,
) {
const completed = [...notifications];
const seen = new Set(completed.map(notificationKey));
for (const seed of seeds) {
if (completed.length >= 6) break;
const candidateSeed = seed.source === "qscore" ? { ...seed, source: "pathways" as const, href: seed.href?.startsWith("/agents/qscore") ? "/agents/matchmaking" : seed.href } : seed;
const candidate = normalizeAgentNotification(candidateSeed, seeds);
const key = notificationKey(candidate);
if (seen.has(key)) continue;
completed.push(candidate);
seen.add(key);
}
return feedSchema.parse({ notifications: completed }).notifications;
}
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 (!HOME_FEED_AGENT_ENABLED) return [];
if (!config.llmApiKey && config.nodeEnv === "production") {
throw new HomeFeedAgentError("home_feed_agent_missing_llm_api_key");
}
if (!config.llmApiKey) return [];
let lastError: unknown;
for (let attempt = 1; attempt <= HOME_FEED_AGENT_ATTEMPTS; attempt += 1) {
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 = rawFeedSchema.parse(parseJsonObject(result.text));
const notifications = completeNotificationsWithSeeds(
parsed.notifications.map((item) => normalizeAgentNotification(item, input.seeds)),
input.seeds,
);
const now = new Date().toISOString();
return notifications.map((n, index) => ({
...n,
urgency: n.urgency as HomeUrgency,
id: stableId("agent-home", index),
createdAt: now,
}));
} catch (err) {
lastError = err;
log.debug({ err, userId: input.userId, attempt, attempts: HOME_FEED_AGENT_ATTEMPTS }, "home feed agent attempt failed");
}
}
throw new HomeFeedAgentError("home_feed_agent_generation_failed", lastError);
}
+3 -52
View File
@@ -17,7 +17,6 @@ import { interviewService, resumeService, roleplayService } from "../services/pr
import { buildServiceLink } from "../services/service-registry.js";
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../services/qscore-proxy.js";
import { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-ledger.js";
import { HomeFeedAgentError, refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
import { listAvailableMissionDefinitions } from "../missions/registry.js";
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
import { buildCuratorSprint } from "../v1/curator/curator-store.js";
@@ -546,14 +545,6 @@ function ensureCoverage(seeds: SeedNotification[], fallback: SeedNotification[])
}
function moduleCount(moduleId: HomeModuleId, notifications: HomeNotification[], ctx: HomeContext, mode: HomeFeedResponse["mode"]) {
if (mode === "demo") {
if (moduleId === "missions") return "1 active";
if (moduleId === "productivity") return "4 updates";
if (moduleId === "social") return "3 ready";
if (moduleId === "pathways") return "Soon";
if (moduleId === "rewards") return "Demo";
return String(notifications.length);
}
if (moduleId === "missions") {
return mode === "day1" && notifications.length === 0 ? "0" : String(Math.min(notifications.length, MISSION_MODULE_LIMIT));
}
@@ -618,12 +609,11 @@ export async function getHomeFeed(userId: string, opts: { refresh?: boolean; use
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");
const hasLegacyMock = hasLegacyMockSeed(persisted);
const fresh = newest > Date.now() - FRESH_MS;
if (persisted.length && !hasLegacyMock && (hasDemo || (!opts.refresh && fresh))) {
const mode = hasDemo ? "demo" : hasAnyRealActivity(ctx) ? "dynamic" : "day1";
if (persisted.length && !hasLegacyMock && !opts.refresh && fresh) {
const mode = hasAnyRealActivity(ctx) ? "dynamic" : "day1";
return {
generatedAt: new Date().toISOString(),
mode,
@@ -634,46 +624,7 @@ export async function getHomeFeed(userId: string, opts: { refresh?: boolean; use
const dayOneSeeds = buildDayOneSeeds();
const deterministic = hasAnyRealActivity(ctx) ? buildDynamicSeeds(ctx) : dayOneSeeds;
let agentNotifications: Awaited<ReturnType<typeof refineHomeNotificationsWithAgent>> = [];
try {
agentNotifications = await refineHomeNotificationsWithAgent({
userId,
context: {
qscore: ctx.qscore,
qscoreSignals: ctx.qscoreSignals,
activeMissions: ctx.activeMissions,
sessions: ctx.sessions,
artifacts: ctx.artifacts,
recentEvents: ctx.events,
serviceStates: ctx.serviceStates,
missionSuggestions: ctx.missionSuggestions,
userProfile: ctx.userProfile,
preferences: ctx.preferences,
routeRules: SERVICE_HREFS,
},
seeds: deterministic,
});
} catch (err) {
if (!(err instanceof HomeFeedAgentError)) throw err;
log.info({ userId }, "home feed agent unavailable, using deterministic notifications");
}
const generatedBy = agentNotifications.length ? "agent" : "deterministic";
const generatedSeeds: SeedNotification[] = agentNotifications.length
? agentNotifications.map((n, index) => ({
moduleId: n.moduleId,
title: n.title,
subtitle: n.subtitle,
tag: n.tag,
urgency: n.urgency,
href: n.href,
source: n.source,
reason: n.reason,
priority: 100 - index,
}))
: deterministic;
await replaceGeneratedNotifications(userId, ensureCoverage(generatedSeeds, dayOneSeeds), generatedBy);
await replaceGeneratedNotifications(userId, ensureCoverage(deterministic, dayOneSeeds), "deterministic");
const rows = await readPersistedNotifications(userId);
const mode = hasAnyRealActivity(ctx) ? "dynamic" : "day1";
-165
View File
@@ -1,165 +0,0 @@
import "dotenv/config";
import { and, eq, inArray } from "drizzle-orm";
import { db } from "../db/client.js";
import { growActiveMissions, growHomeNotifications, growQscoreLatest, growQscoreProjectionState, missionArtifacts, missionServiceSessions, users, type NewGrowHomeNotification } from "../db/schema.js";
import type { HomeModuleId, HomeSource, HomeUrgency } from "./types.js";
type DemoNotification = {
moduleId: HomeModuleId;
title: string;
subtitle: string;
tag: string;
urgency: HomeUrgency;
href: string;
source: HomeSource;
priority: number;
reason?: string;
};
const demoNotifications: DemoNotification[] = [
{ moduleId: "suggestions", title: "Approve resume v6", subtitle: "ATS 78 → 86 · two PM bullets are ready to ship.", tag: "Urgent", urgency: "now", href: "/agents/resume", source: "resume", priority: 120 },
{ moduleId: "suggestions", title: "Mock follow-up at 6:30 PM", subtitle: "Behavioral drill based on yesterday's review gaps.", tag: "Today", urgency: "today", href: "/agents/interview", source: "interview", priority: 116 },
{ moduleId: "suggestions", title: "Practice recruiter pushback", subtitle: "Roleplay can tighten your compensation and notice-period answer.", tag: "Soon", urgency: "soon", href: "/agents/roleplay", source: "roleplay", priority: 110 },
{ moduleId: "missions", title: "Interview-to-Offer — 72%", subtitle: "Resume fit done · mock review ready · final report locked.", tag: "Active", urgency: "today", href: "/missions", source: "mission", priority: 115 },
{ moduleId: "missions", title: "2 approvals pending", subtitle: "Resume v6 and Mock #4 feedback need your confirmation.", tag: "Action", urgency: "now", href: "/missions", source: "mission", priority: 113 },
{ moduleId: "missions", title: "Final readiness unlock", subtitle: "Complete one roleplay recovery drill to generate the final checklist.", tag: "Next", urgency: "soon", href: "/missions", source: "mission", priority: 106 },
{ moduleId: "social", title: "LinkedIn headline v3 ready", subtitle: "Clearer target: Product Intern · FinTech · Growth Systems.", tag: "Ready", urgency: "today", href: "/agents/social-branding", source: "social", priority: 104 },
{ moduleId: "social", title: "Featured section has 3 proof pins", subtitle: "Use resume scan, mock review, and Q Score delta as credibility blocks.", tag: "Proof", urgency: "soon", href: "/agents/social-branding", source: "social", priority: 100 },
{ moduleId: "social", title: "Banner options queued", subtitle: "Three calm orange/blue layouts are waiting for review.", tag: "Brand", urgency: "soon", href: "/agents/social-branding", source: "social", priority: 96 },
{ moduleId: "pathways", title: "Pathways stay locked for demo", subtitle: "Resume + interview data is enough; pathway service is not enabled yet.", tag: "Soon", urgency: "calm", href: "/agents/matchmaking", source: "pathways", priority: 70 },
{ moduleId: "pathways", title: "PM SaaS route predicted", subtitle: "Directional only until the pathways service is connected.", tag: "Preview", urgency: "calm", href: "/agents/matchmaking", source: "pathways", priority: 68 },
{ moduleId: "productivity", title: "Resume ATS 86", subtitle: "Keyword relevance +9 after JD tailoring. Open Resume Builder.", tag: "Resume", urgency: "today", href: "/agents/resume", source: "resume", priority: 118 },
{ moduleId: "productivity", title: "Interview review is ready", subtitle: "Overall 82 · storytelling and concise examples need one more drill.", tag: "Mock", urgency: "now", href: "/agents/interview", source: "interview", priority: 117 },
{ moduleId: "productivity", title: "Roleplay recruiter screen", subtitle: "Scenario ready · handle salary range and start-date objections.", tag: "Roleplay", urgency: "soon", href: "/agents/roleplay", source: "roleplay", priority: 105 },
{ moduleId: "productivity", title: "Q Score now 86", subtitle: "Signals: resume ATS, mock review, and roleplay completion.", tag: "Q Score", urgency: "today", href: "/agents/qscore", source: "qscore", priority: 102 },
{ moduleId: "rewards", title: "Mentor pass preview", subtitle: "Demo reward only · would unlock after 3 completed readiness actions.", tag: "Demo", urgency: "calm", href: "/rewards", source: "rewards", priority: 60 },
{ moduleId: "rewards", title: "Resume Pro credit queued", subtitle: "Tabled for v1; shown as a consistent notification tile.", tag: "Soon", urgency: "calm", href: "/rewards", source: "rewards", priority: 58 },
];
export async function seedDemoHome(userId: string) {
await db.insert(users).values({ id: userId, email: `${userId}@demo.local`, displayName: "Demo User" }).onConflictDoNothing();
await db
.delete(growHomeNotifications)
.where(and(eq(growHomeNotifications.userId, userId), inArray(growHomeNotifications.generatedBy, ["demo", "agent", "deterministic"])));
const expiresAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000);
const rows: NewGrowHomeNotification[] = demoNotifications.map((n) => ({
userId,
moduleId: n.moduleId,
title: n.title,
subtitle: n.subtitle,
tag: n.tag,
urgency: n.urgency,
href: n.href,
source: n.source,
priority: n.priority,
generatedBy: "demo",
reason: n.reason ?? "Seeded for multi-day dashboard demo.",
status: "active",
expiresAt,
updatedAt: new Date(),
}));
await db.insert(growHomeNotifications).values(rows);
await db.insert(growQscoreProjectionState).values({
userId,
score: 86,
signalCount: 14,
dimensions: { strengths: ["resume.ats_compatibility", "interview.overall_score"], growth: ["interview.response_clarity", "roleplay.problem_resolution"] },
summary: "Demo readiness score from resume, interview, and roleplay signals.",
updatedAt: new Date(),
}).onConflictDoUpdate({
target: growQscoreProjectionState.userId,
set: {
score: 86,
signalCount: 14,
dimensions: { strengths: ["resume.ats_compatibility", "interview.overall_score"], growth: ["interview.response_clarity", "roleplay.problem_resolution"] },
summary: "Demo readiness score from resume, interview, and roleplay signals.",
updatedAt: new Date(),
},
});
const signalRows = [
["resume.uploaded", 100, "resume-service"],
["resume.ats_compatibility", 86, "resume-service"],
["resume.keyword_relevance", 84, "resume-service"],
["interview.sessions_completed", 35, "interview-service"],
["interview.overall_score", 82, "interview-service"],
["interview.response_clarity", 74, "interview-service"],
["roleplay.scenarios_completed", 35, "roleplay-service"],
["roleplay.communication_effectiveness", 79, "roleplay-service"],
] as const;
for (const [signalId, score, source] of signalRows) {
await db.insert(growQscoreLatest).values({
userId,
signalId,
score,
present: true,
source,
raw: { demo: true },
occurredAt: new Date(),
updatedAt: new Date(),
}).onConflictDoUpdate({
target: [growQscoreLatest.userId, growQscoreLatest.signalId],
set: { score, present: true, source, raw: { demo: true }, occurredAt: new Date(), updatedAt: new Date() },
});
}
await db.insert(growActiveMissions).values({
instanceId: `demo-interview-to-offer-${userId}`,
userId,
missionId: "interview-to-offer",
workflowId: "interview-to-offer",
actorType: "interviewToOfferMissionActor",
title: "Interview-to-Offer Accelerator",
shortTitle: "Interview-to-Offer",
status: "active",
progressPercent: 72,
currentStageId: "mock-interview-review",
goal: "Land a PM internship offer",
mission: { demo: true, missionId: "interview-to-offer" },
snapshot: { demo: true, progressPercent: 72 },
updatedAt: new Date(),
}).onConflictDoUpdate({
target: growActiveMissions.instanceId,
set: { status: "active", progressPercent: 72, currentStageId: "mock-interview-review", snapshot: { demo: true, progressPercent: 72 }, updatedAt: new Date() },
});
await db.insert(missionServiceSessions).values([
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "resume-fit-scan", serviceId: "resume-service", externalId: `demo-resume-${userId}`, status: "completed", metadata: { demo: true, ats: 86 }, updatedAt: new Date() },
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "mock-interview", serviceId: "interview-service", externalId: `demo-interview-${userId}`, status: "review_ready", metadata: { demo: true, overall: 82 }, updatedAt: new Date() },
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "roleplay-recovery", serviceId: "roleplay-service", externalId: `demo-roleplay-${userId}`, status: "configured", metadata: { demo: true }, updatedAt: new Date() },
]).onConflictDoNothing();
await db.insert(missionArtifacts).values([
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "resume-fit-scan", serviceId: "resume-service", externalId: `demo-resume-${userId}`, type: "resume_fit_scan", title: "Resume Fit Scan", status: "ready", summary: "ATS 86 with stronger PM keywords.", metadata: { demo: true }, updatedAt: new Date() },
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "mock-interview", serviceId: "interview-service", externalId: `demo-interview-${userId}`, type: "mock_interview_review", title: "Mock Interview Review", status: "ready", summary: "Overall 82; improve story brevity and tradeoff framing.", metadata: { demo: true }, updatedAt: new Date() },
]).onConflictDoNothing();
return { inserted: rows.length, userId };
}
const entry = process.argv[1] ?? "";
if (entry.endsWith("seed-demo-home.ts") || entry.endsWith("seed-demo-home.js")) {
const userId = process.env.DEMO_USER_ID;
if (!userId) {
console.error("Set DEMO_USER_ID to seed demo home notifications.");
process.exit(1);
}
seedDemoHome(userId)
.then((result) => {
console.log(JSON.stringify(result, null, 2));
process.exit(0);
})
.catch((err) => {
console.error(err);
process.exit(1);
});
}
+1 -1
View File
@@ -34,7 +34,7 @@ export type HomeIdentity = {
export type HomeFeedResponse = {
generatedAt: string;
mode: "day1" | "dynamic" | "demo";
mode: "day1" | "dynamic";
identity: HomeIdentity;
modules: HomeModule[];
};
+1 -28
View File
@@ -1,9 +1,6 @@
import { Hono } from "hono";
import { config } from "../config.js";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { dismissHomeNotification, getHomeFeed, getHomeFeedDebugCounts } from "../home/home-feed.js";
import { HomeFeedAgentError } from "../home/home-feed-agent.js";
import { seedDemoHome } from "../home/seed-demo-home.js";
import { getRequestUserProfile } from "../services/user-context.js";
import { log } from "../log.js";
import {
@@ -16,10 +13,6 @@ import {
type SystemNotificationPreference,
} from "../notifications/system-notifications.js";
function canSeedDemo(userId: string) {
return config.nodeEnv !== "production" || config.adminUserIds.includes(userId);
}
export function homeRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
@@ -31,21 +24,7 @@ export function homeRoutes() {
log.warn({ err, userId }, "home feed continuing without user-service profile");
return {};
});
try {
return c.json(await getHomeFeed(userId, { refresh, ...profile }));
} catch (err) {
if (err instanceof HomeFeedAgentError) {
log.warn({ err, userId }, "home feed generation unavailable");
return c.json(
{
error: "home_feed_generation_unavailable",
message: "Home feed generation is temporarily unavailable. Please retry.",
},
503,
);
}
throw err;
}
return c.json(await getHomeFeed(userId, { refresh, ...profile }));
});
app.post("/notifications/:id/dismiss", async (c) => {
@@ -81,11 +60,5 @@ export function homeRoutes() {
app.get("/debug/counts", async (c) => c.json(await getHomeFeedDebugCounts(c.get("userId"))));
app.post("/demo/seed", async (c) => {
const userId = c.get("userId");
if (!canSeedDemo(userId)) return c.json({ error: "forbidden" }, 403);
return c.json(await seedDemoHome(userId));
});
return app;
}
+128 -2
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { db } from "../db/client.js";
import { users, userStacks, type UserStack } from "../db/schema.js";
import { onboarding, users, userStacks, type UserStack } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { log } from "../log.js";
import { config } from "../config.js";
@@ -12,6 +12,57 @@ import {
getLatestValidOnboardingLedgerEvent,
recordAndProcessOnboardingCompletion,
} from "../events/onboarding-ledger.js";
import { z } from "zod";
const onboardingPatchSchema = z.object({
expectedRevision: z.number().int().nonnegative().optional(),
data: z.record(z.unknown()),
});
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function strings(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
const curatorIcpByOnboardingIcp: Record<string, string> = {
student: "student_recent_grad",
intern: "intern",
fresher: "fresher_early_professional",
experienced: "experienced_professional",
freelancer: "freelancer",
founder: "founder",
};
function buildOnboardingPayload(data: Record<string, unknown>) {
const profile = asRecord(data.profile);
const responses = asRecord(data.responses);
const onboardingIcp = typeof profile.icp === "string" ? profile.icp : "fresher";
const targetField = typeof responses.target_field === "string"
? responses.target_field
: typeof responses.venture_industry === "string"
? responses.venture_industry
: null;
return {
schema_version: 1,
source_revision: typeof data.revision === "number" ? data.revision : 0,
primary_intent: typeof profile.intent === "string" ? profile.intent : null,
mode: typeof profile.mode === "string" ? profile.mode : null,
question_branch: typeof profile.question_branch === "string" ? profile.question_branch : null,
onboarding_icp: onboardingIcp,
curator_registry_icp: curatorIcpByOnboardingIcp[onboardingIcp] ?? "fresher_early_professional",
target_role: typeof responses.target_role === "string" ? responses.target_role : null,
target_field: targetField,
experience_context: typeof responses.experience_level === "string" ? responses.experience_level : null,
goals: strings(responses.desired_outcomes),
barriers: strings(responses.career_barriers),
priority: typeof responses.target_milestone === "string" ? responses.target_milestone : null,
weekly_time_commitment: typeof responses.weekly_time_commitment === "string" ? responses.weekly_time_commitment : null,
};
}
function publicStack(stack: UserStack | null | undefined) {
if (!stack) return stack;
@@ -77,6 +128,10 @@ export function userRoutes() {
);
}
const rawPlan = typeof userServiceUser.plan === "string" ? userServiceUser.plan.toLowerCase() : "free";
const plan = rawPlan === "pro" || rawPlan === "enterprise" ? rawPlan : "free";
await db.update(users).set({ plan, updatedAt: new Date() }).where(eq(users.id, userId));
const userRow = await db.query.users.findFirst({
where: eq(users.id, userId),
});
@@ -94,7 +149,10 @@ export function userRoutes() {
app.get("/onboarding-status", async (c) => {
const userId = c.get("userId");
const state = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, userId) });
const event = await getLatestValidOnboardingLedgerEvent(userId);
const data = asRecord(state?.data);
const completed = data.status === "completed" && typeof data.completed_at === "string";
return c.json({
userId,
@@ -107,7 +165,75 @@ export function userRoutes() {
processingStatus: event.processingStatus,
}
: null,
needsOnboarding: !event,
needsOnboarding: !completed,
});
});
app.get("/onboarding", async (c) => {
const userId = c.get("userId");
const state = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, userId) });
return c.json({
id: state?.id ?? null,
data: state?.data ?? null,
payload: state?.payload ?? null,
revision: typeof asRecord(state?.data).revision === "number" ? asRecord(state?.data).revision : 0,
updatedAt: state?.updatedAt?.toISOString() ?? null,
});
});
app.patch("/onboarding", async (c) => {
const userId = c.get("userId");
const body = onboardingPatchSchema.parse(await c.req.json());
const existing = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, userId) });
const existingData = asRecord(existing?.data);
const currentRevision = typeof existingData.revision === "number" ? existingData.revision : 0;
if (body.expectedRevision !== undefined && body.expectedRevision !== currentRevision) {
return c.json({
error: "onboarding_revision_conflict",
expectedRevision: body.expectedRevision,
currentRevision,
data: existing?.data ?? null,
payload: existing?.payload ?? null,
}, 409);
}
const now = new Date();
const nextData: Record<string, unknown> = {
...body.data,
schema_version: 3,
revision: currentRevision + 1,
updated_at: now.toISOString(),
};
const payload = buildOnboardingPayload(nextData);
const [saved] = await db.insert(onboarding).values({
userId,
data: nextData,
payload,
updatedAt: now,
}).onConflictDoUpdate({
target: onboarding.userId,
set: { data: nextData, payload, updatedAt: now },
}).returning();
if (!saved) throw new Error("failed_to_save_onboarding");
const completedAt = nextData.status === "completed" && typeof nextData.completed_at === "string"
? new Date(nextData.completed_at)
: null;
if (completedAt && !Number.isNaN(completedAt.getTime())) {
await recordAndProcessOnboardingCompletion({
userId,
completedAt,
source: "onboarding-data",
context: { onboarding: nextData, payload },
});
}
return c.json({
id: saved.id,
data: saved.data,
payload: saved.payload,
revision: currentRevision + 1,
updatedAt: saved.updatedAt.toISOString(),
});
});
+4 -62
View File
@@ -1,15 +1,11 @@
import { actor } from "rivetkit";
import { buildCuratorPlan, buildCuratorSprint, buildCuratorStreak, buildCuratorTasks, buildServiceCurationPreview, todayIsoDate } from "./curator-store.js";
import { curatorPlanSchema, curatorSprintResponseSchema, type CuratorImprovementSignal } from "./curator-types.js";
import { buildCuratorSprint, buildCuratorStreak, buildCuratorTasks, todayIsoDate } from "./curator-store.js";
import { curatorSprintResponseSchema, type CuratorImprovementSignal } from "./curator-types.js";
import { emitCuratorEvent } from "./curator-events.js";
import { runCuratorChat } from "./curator-agent.js";
import { prepareHandoffForTask } from "./curator-tools.js";
import type { CuratorIcpId } from "./icp-registry.js";
import { runCuratorOnboardingLoop } from "./curator-onboarding-loop.js";
type CuratorActorState = {
userId: string;
planGenerations: number;
sprintReads: number;
taskCompletions: number;
lastActionAt?: string;
@@ -46,33 +42,9 @@ function touch(c: { state: CuratorActorState }, input: { userId: string }) {
}
export const curatorService = {
async generatePlanRange(input: { userId: string; startDate?: string; endDate?: string; goals?: string[]; forceRegenerate?: boolean }) {
const startDate = input.startDate ?? todayIsoDate();
const endDate = input.endDate ?? startDate;
const plan = curatorPlanSchema.parse(await buildCuratorPlan(input.userId, { startDate, endDate, goals: input.goals }));
await emitCuratorEvent({
userId: input.userId,
type: "curator.plan.generated",
payload: {
startDate,
endDate,
planId: plan.id,
durationDays: plan.durationDays,
goals: input.goals ?? plan.goals,
weekCount: plan.weeks.length,
dayCount: plan.days.length,
plan,
},
});
return { plan };
},
async getPlan(input: { userId: string; startDate?: string; endDate?: string }) {
return this.generatePlanRange(input);
},
async previewCuration(input: { userId: string; startDate?: string; icpId?: CuratorIcpId; goals?: string[]; userContext?: Record<string, unknown> }) {
return buildServiceCurationPreview(input);
const sprint = curatorSprintResponseSchema.parse(await buildCuratorSprint(input.userId, input.startDate));
return { plan: sprint.plan };
},
async runOnboardingLoop(input: { userId: string; completedAt?: string }) {
@@ -106,10 +78,6 @@ export const curatorService = {
return sprint;
},
async chat(input: { userId: string; conversationId?: string; date?: string; taskId?: string; subtaskIndex?: number; subtask?: string; messages: Array<{ role: "user" | "assistant"; content: string }> }) {
return runCuratorChat(input);
},
async startTask(input: { userId: string; taskId: string; date?: string }) {
const date = taskActionDate(input);
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
@@ -130,14 +98,6 @@ export const curatorService = {
return { task: { ...task, status: "started" as const }, eventId: event.id };
},
async prepareTaskHandoff(input: { userId: string; taskId: string; date?: string }) {
const date = taskActionDate(input);
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
if (!task) throw new Error("curator_task_not_found");
if (task.serviceId) return prepareHandoffForTask(input.userId, task, task.serviceId);
throw new Error("curator_task_has_no_handoff");
},
async completeTask(input: { userId: string; taskId: string; date?: string; reason?: string }) {
const date = taskActionDate(input);
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
@@ -190,24 +150,14 @@ export const curatorActor = actor({
options: { name: "Curator Actor", icon: "sparkles", noSleep: true, actionTimeout: 300_000 },
state: {
userId: "",
planGenerations: 0,
sprintReads: 0,
taskCompletions: 0,
} as CuratorActorState,
actions: {
generatePlanRange: async (c, input: Parameters<typeof curatorService.generatePlanRange>[0]) => {
touch(c, input);
c.state.planGenerations += 1;
return curatorService.generatePlanRange(input);
},
getPlan: async (c, input: Parameters<typeof curatorService.getPlan>[0]) => {
touch(c, input);
return curatorService.getPlan(input);
},
previewCuration: async (c, input: Parameters<typeof curatorService.previewCuration>[0]) => {
touch(c, input);
return curatorService.previewCuration(input);
},
runOnboardingLoop: async (c, input: Parameters<typeof curatorService.runOnboardingLoop>[0]) => {
touch(c, input);
return curatorService.runOnboardingLoop(input);
@@ -222,20 +172,12 @@ export const curatorActor = actor({
c.state.sprintReads += 1;
return curatorService.getSprint(input);
},
chat: async (c, input: Parameters<typeof curatorService.chat>[0]) => {
touch(c, input);
return curatorService.chat(input);
},
startTask: async (c, input: Parameters<typeof curatorService.startTask>[0]) => {
touch(c, input);
const result = await curatorService.startTask(input);
c.state.lastEventId = result.eventId;
return result;
},
prepareTaskHandoff: async (c, input: Parameters<typeof curatorService.prepareTaskHandoff>[0]) => {
touch(c, input);
return curatorService.prepareTaskHandoff(input);
},
completeTask: async (c, input: Parameters<typeof curatorService.completeTask>[0]) => {
touch(c, input);
const result = await curatorService.completeTask(input);
-684
View File
@@ -1,684 +0,0 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { generateText } from "ai";
import { z } from "zod";
import { and, desc, eq } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growEvents } from "../../db/schema.js";
import { addMessagePg, createConversationPg, ensureCuratorTaskConversationPg, getConversationMetadataPg, listMessagesPg } from "../../grow/persistence.js";
import { generateConversationResponse, getConversationModel } from "../../actors/conversation/agent.js";
import { buildCuratorTasks, todayIsoDate } from "./curator-store.js";
import { emitCuratorEvent } from "./curator-events.js";
import type { CuratorChatResponse, CuratorSubtaskStatusUpdate } from "./curator-types.js";
import { prepareHandoffForTask } from "./curator-tools.js";
import { fallbackCuratorRole, resolveCuratorTargetRole } from "./curator-user-context.js";
const CURATOR_STREAK_CHAT_PROMPT = path.resolve(process.cwd(), "prompts/curator/streak-chat.md");
const FALLBACK_CURATOR_STREAK_CHAT_PROMPT = [
"You are the GrowQR V1 Curator in a daily or weekly streak chat modal.",
"Ask at most one clarifying question before a service preview handoff is ready.",
"If no target role is known for interview or roleplay, ask exactly: What role are you targeting?",
"If the target role is known, do not ask again. Proceed to a short summary and let the dashboard show the CTA.",
"Never include internal URLs, setup routes, API paths, JSON, or tool names in chat text.",
"Interview defaults: type=behavioral, difficulty=medium, duration=5.",
"Use ASCII punctuation only.",
].join("\n");
async function loadCuratorStreakPrompt() {
return readFile(CURATOR_STREAK_CHAT_PROMPT, "utf8").catch(() => FALLBACK_CURATOR_STREAK_CHAT_PROMPT);
}
const chatExtractSchema = z.object({
summary: z.string(),
userGoal: z.string().optional(),
serviceIntent: z.string().optional(),
shouldPrepareHandoff: z.boolean().default(false),
});
const subtaskStatusUpdateSchema = z.object({
status: z.enum(["needs_more_context", "ready_to_capture", "handoff_ready"]),
summary: z.string().min(1).max(280),
confidence: z.number().min(0).max(1).default(0.5),
nextMissingInfo: z.string().max(180).optional(),
});
function parseJsonObject(text: string) {
const trimmed = text.trim();
try {
return JSON.parse(trimmed);
} catch {
const match = trimmed.match(/\{[\s\S]*\}/);
if (!match) throw new Error("model_did_not_return_json");
return JSON.parse(match[0]);
}
}
function buildId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function sanitize(text: string) {
const withoutControlLines = text
.split(/\r?\n/)
.filter((line) => {
const trimmed = line.trim();
if (!trimmed) return true;
if (/^(date|curator task id|focused subtask|curator task title|curator task context|curator task subtasks|curator service|expected completion events|captured task memory|task title|task service|task context|all task subtasks|visible history):/i.test(trimmed)) return false;
if (/setup route|mission instance id|curator task id|access the setup at/i.test(trimmed)) return false;
if (/\/agents\/(interview|roleplay|resume|qscore)|\/analytics\?|\/social\?|\/pathways\?/i.test(trimmed)) return false;
if (/^```/.test(trimmed)) return false;
return true;
})
.join("\n")
.trim();
const withoutJsonEnvelope = withoutControlLines.replace(/^\s*\{[\s\S]*"reply"\s*:\s*"([^"]+)"[\s\S]*\}\s*$/i, "$1");
const withoutRoutes = withoutJsonEnvelope
.replace(/\b(?:Interview|Roleplay|Resume|Q Score)?\s*setup route:\s*\/\S+/gi, "")
.replace(/\/agents\/(?:interview|roleplay|resume|qscore)\/?\S*/gi, "")
.replace(/\/analytics\?\S*/gi, "")
.replace(/\/social\?\S*/gi, "")
.replace(/\/pathways\?\S*/gi, "");
return withoutRoutes
.replace(/[\u2013\u2014]/g, "-")
.replace(/[\u2018\u2019]/g, "'")
.replace(/[\u201C\u201D]/g, '"')
.replace(/\u2026/g, "...")
.replace(/^\s*(Perfect|Great|Absolutely|Sure)[.!,:;-]*\s*/i, "")
.trim();
}
function pushField(lines: string[], label: string, value?: string | number | null) {
if (value === undefined || value === null) return;
const stringValue = String(value).trim();
if (!stringValue) return;
lines.push(`${label}: ${stringValue}`);
}
function pushList(lines: string[], label: string, values?: string[]) {
const cleanValues = values?.map((value) => value.trim()).filter(Boolean) ?? [];
if (cleanValues.length === 0) return;
lines.push(`${label}: ${cleanValues.join(" | ")}`);
}
type CuratorMessage = Awaited<ReturnType<typeof listMessagesPg>>[number];
async function capturedSubtaskMemory(userId: string, taskId?: string) {
if (!taskId) return [];
const rows = await db
.select()
.from(growEvents)
.where(and(
eq(growEvents.userId, userId),
eq(growEvents.type, "curator.subtask.captured"),
))
.orderBy(desc(growEvents.occurredAt))
.limit(80);
return rows
.map((row) => row.payload ?? {})
.filter((payload) => payload.taskId === taskId)
.map((payload) => ({
subtaskIndex: typeof payload.subtaskIndex === "number" ? payload.subtaskIndex : undefined,
subtask: typeof payload.subtask === "string" ? payload.subtask : undefined,
summary: typeof (payload.statusUpdate as any)?.summary === "string" ? (payload.statusUpdate as any).summary : undefined,
}))
.filter((item) => item.summary)
.reverse();
}
function visibleCuratorMessages(messages: CuratorMessage[]) {
const filtered = messages.filter((message) => {
const content = message.content.trim();
if (message.role === "user") {
if (/^start$/i.test(content)) return false;
if (/^i opened /i.test(content)) return false;
return true;
}
return !/what should i capture/i.test(content);
});
return filtered.filter((message, index) => {
const previous = filtered[index - 1];
return !previous || previous.role !== message.role || previous.content.trim() !== message.content.trim();
});
}
function usefulUserMessages(messages: CuratorMessage[]) {
return messages
.filter((message) => message.role === "user")
.map((message) => message.content.trim())
.filter((content) => content && !/^start$/i.test(content) && !content.toLowerCase().includes("i opened "));
}
function targetRoleState(messages: CuratorMessage[], latest: string) {
const userMessages = usefulUserMessages(messages);
const all = [...userMessages, latest.trim()].filter(Boolean);
const lowerAll = all.join("\n").toLowerCase();
const shortAnswers = all.filter((content) => content.length <= 80);
const targetRole = shortAnswers.find((content) => {
const lower = content.toLowerCase();
return /manager|engineer|designer|analyst|developer|product|marketing|sales|founder|consultant|operator|lead|head|director/.test(lower);
});
const currentBackground = all.find((content) => {
const lower = content.toLowerCase();
return lower.includes("currently") || lower.includes("right now") || lower.includes("i am ") || lower.includes("i'm ") || lower.includes("my background") || lower.includes("experience");
});
const constraints = all.find((content) => {
const lower = content.toLowerCase();
return lower.includes("month") || lower.includes("week") || lower.includes("salary") || lower.includes("remote") || lower.includes("location") || lower.includes("visa") || lower.includes("timeline");
});
return {
targetRole,
currentBackground,
constraints,
hasAskedCurrent: lowerAll.includes("current background") || lowerAll.includes("current role") || lowerAll.includes("where you are starting"),
hasAskedConstraints: lowerAll.includes("constraint") || lowerAll.includes("timeline"),
};
}
const CURATOR_PROMPT_FILE = path.resolve(process.cwd(), "prompts", "curator-v1.md");
const DEFAULT_CURATOR_PROMPT = `You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.
Own 30 day direction, streak continuity, and service handoff decisions.
Do not ask the same question twice.
Use captured task memory and keep the user on the focused subtask.
When the user has answered enough, summarize what was captured and stop.
If more detail is needed, ask exactly one follow-up question.
For service work, prepare preview-oriented handoffs once enough context exists.`;
async function loadCuratorPromptTemplate() {
try {
return await readFile(CURATOR_PROMPT_FILE, "utf8");
} catch {
return DEFAULT_CURATOR_PROMPT;
}
}
async function curatorSystemAddendum(input: {
date: string;
taskId?: string;
subtaskIndex?: number;
subtask?: string;
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
taskMemory?: Array<{ subtaskIndex?: number; subtask?: string; summary?: string }>;
promptText: string;
targetRole?: string;
}) {
const template = await loadCuratorPromptTemplate();
const lines = [
input.promptText,
"",
...template
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter(Boolean),
];
pushField(lines, "Known target role", input.targetRole);
pushField(lines, "Date", input.date);
pushField(lines, "Curator task id", input.taskId);
pushField(lines, "Focused subtask index", Number.isInteger(input.subtaskIndex) ? input.subtaskIndex : undefined);
pushField(lines, "Focused subtask title", input.subtask);
pushField(lines, "Curator task title", input.task?.title);
pushField(lines, "Curator task context", input.task?.contextNarrative);
pushList(lines, "Curator task subtasks", input.task?.subtasks);
pushField(lines, "Curator service", input.task?.serviceName);
pushList(lines, "Expected completion events", input.task?.completionEvents);
const memory = input.taskMemory
?.map((item) => {
if (!item.summary) return "";
const subtask = item.subtask?.trim() || "Subtask";
const index = Number.isInteger(item.subtaskIndex) ? `[${item.subtaskIndex}] ` : "";
return `${index}${subtask}: ${item.summary}`;
})
.filter(Boolean);
pushList(lines, "Captured task memory", memory);
return lines.join("\n");
}
function curatorTaskKey(taskId?: string, subtaskIndex?: number) {
if (!taskId) return undefined;
return `${taskId}:${subtaskIndex ?? "task"}`;
}
function firstTurnPrompt(input: {
subtask?: string;
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
targetRole?: string;
}) {
return [
`The user opened this focused subtask: ${input.subtask ?? input.task?.title ?? "curator task"}.`,
"Generate the first live conversational question for this exact subtask.",
input.targetRole ? `Known target role: ${input.targetRole}. Do not ask for the role again.` : "If this is an interview or roleplay task and no target role is known, ask exactly: What role are you targeting?",
"Ask only one question. Do not use canned wording. Do not prepare any service handoff yet.",
].join("\n");
}
function isExplicitHandoffRequest(text: string) {
const trimmed = text.trim();
if (/^start$/i.test(trimmed)) return false;
return /\b(start|open|launch|begin|set up|setup|create|generate|room|ready|go|give)\b/i.test(trimmed);
}
function shouldPrepareServiceHandoff(status: CuratorSubtaskStatusUpdate, task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number]) {
if (!task?.serviceId) return false;
return status.status === "ready_to_capture" || status.status === "handoff_ready";
}
function isPreviewHandoffService(task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number]) {
return task?.serviceId === "interview-service" || task?.serviceId === "roleplay-service";
}
function servicePreviewSummary(task: Awaited<ReturnType<typeof buildCuratorTasks>>[number], targetRole?: string) {
const role = fallbackCuratorRole(targetRole);
if (task.serviceId === "interview-service") {
return `Prepared a 5-minute behavioral interview preview for ${role}.`;
}
if (task.serviceId === "roleplay-service") {
return `Prepared a 5-minute roleplay preview for ${role}.`;
}
return `${task.serviceName} handoff is ready.`;
}
function fallbackCuratorReply(input: {
latest: string;
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
subtask?: string;
targetRole?: string;
}) {
const latest = input.latest.trim();
const lowerTitle = input.task?.title.toLowerCase() ?? "";
const lowerSubtask = input.subtask?.toLowerCase() ?? "";
const role = fallbackCuratorRole(input.targetRole);
if ((input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") && !input.targetRole) {
return "What role are you targeting?";
}
if (/^start$/i.test(latest)) {
if (input.task?.serviceId === "qscore-service") {
return "Open your current Q Score and tell me which readiness signal looks weakest today.";
}
if (input.task?.serviceId === "resume-service") {
return "Upload your current resume or paste three recent wins so I can anchor this proof task.";
}
if (input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") {
return `I have your target role as ${role}. Say start when you want the preview opened.`;
}
if (lowerTitle.includes("role direction") || lowerSubtask.includes("role direction")) {
return "Which role family do you want this sprint to optimize toward?";
}
if (input.task?.taskType === "measurement") {
return "Open the current view and tell me the one gap or signal that stands out most.";
}
if (input.task?.taskType === "proof") {
return "Share the strongest proof you already have so we can build from something real.";
}
return "What is the single outcome you want from this task today?";
}
if (input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") {
if (isExplicitHandoffRequest(latest)) {
return servicePreviewSummary(input.task, input.targetRole);
}
return `Captured ${role} as the target role. Say start when you want the preview opened.`;
}
if (lowerTitle.includes("role direction") || lowerSubtask.includes("role direction")) {
return `Captured ${latest}. I will use that as the role direction for this sprint.`;
}
if (input.task?.serviceId === "resume-service") {
return "Captured. Open the resume flow when you are ready to turn this into proof.";
}
if (input.task?.serviceId === "qscore-service") {
return "Captured. Open the Q Score view and save the main readiness gap you want to work on.";
}
if (input.task?.taskType === "measurement") {
return "Captured the baseline signal for today.";
}
if (input.task?.taskType === "proof") {
return "Captured the proof point for today.";
}
if (input.task?.taskType === "practice") {
return "Captured the practice focus for today.";
}
return "Captured. We can use this to move the task forward.";
}
async function evaluateSubtaskStatus(input: {
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
subtask?: string;
subtaskIndex?: number;
latest: string;
reply: string;
history: CuratorMessage[];
}): Promise<CuratorSubtaskStatusUpdate> {
if (!input.subtask || /^start$/i.test(input.latest.trim())) {
return { status: "needs_more_context", summary: "Subtask opened.", confidence: 0.2 };
}
try {
const result = await generateText({
model: getConversationModel(),
system: [
"You are the GrowQR V1 Curator Actor state evaluator.",
"Return JSON only. Do not wrap it in markdown.",
"Shape: {\"status\": \"needs_more_context\" | \"ready_to_capture\" | \"handoff_ready\", \"summary\": string, \"confidence\": number, \"nextMissingInfo\"?: string}.",
"Evaluate only the focused subtask. Ignore other missions, other subtasks, and later checklist items.",
"Use ready_to_capture only when the latest user answer directly satisfies the focused subtask.",
"Use needs_more_context if the assistant reply asks another question or if the answer is too vague for this exact subtask.",
"Use handoff_ready only when the focused subtask explicitly asks to open or preview a service and the service setup details are present.",
"Use handoff_ready when the user explicitly says to start, open, launch, set up, or begin the service and the necessary setup context is already present.",
"Never mark ready just because one message exists.",
"Use ASCII punctuation only.",
].join("\n"),
prompt: (() => {
const lines: string[] = [];
pushField(lines, "Task title", input.task?.title);
pushField(lines, "Task service", input.task?.serviceName);
pushField(lines, "Focused subtask index", Number.isInteger(input.subtaskIndex) ? input.subtaskIndex : undefined);
pushField(lines, "Focused subtask", input.subtask);
pushField(lines, "Task context", input.task?.contextNarrative);
pushList(lines, "All task subtasks", input.task?.subtasks);
pushField(lines, "Latest user answer", input.latest);
pushField(lines, "Assistant reply", input.reply);
pushField(lines, "Visible history", input.history.map((message) => `${message.role}: ${message.content}`).join("\n"));
return lines.join("\n");
})(),
});
return subtaskStatusUpdateSchema.parse(parseJsonObject(result.text));
} catch (error) {
console.warn("curator status evaluation failed; keeping subtask open", {
taskId: input.task?.id,
subtaskIndex: input.subtaskIndex,
error: error instanceof Error ? error.message : String(error),
});
return { status: "needs_more_context", summary: "The curator needs one more answer before updating this subtask.", confidence: 0.1 };
}
}
async function ensureCuratorConversation(input: { userId: string; taskId?: string; date: string; subtaskIndex?: number; subtask?: string }) {
if (!input.taskId) return createConversationPg(input.userId, "V1 Curator chat");
const task = (await buildCuratorTasks(input.userId, input.date)).find((item) => item.id === input.taskId);
if (task) {
return ensureCuratorTaskConversationPg({
userId: input.userId,
curatorTaskId: task.id,
subtaskIndex: input.subtaskIndex,
subtask: input.subtask,
missionInstanceId: task.missionInstanceId,
missionId: task.missionId,
stageId: task.stageId,
title: input.subtask ?? task.title,
});
}
return createConversationPg(input.userId, "V1 Curator chat");
}
export async function runCuratorChat(input: {
userId: string;
conversationId?: string;
taskId?: string;
subtaskIndex?: number;
subtask?: string;
date?: string;
messages: Array<{ role: "user" | "assistant"; content: string }>;
}): Promise<CuratorChatResponse> {
const date = input.date ?? todayIsoDate();
const expectedTaskKey = curatorTaskKey(input.taskId, input.subtaskIndex);
let conversation = input.conversationId ? { id: input.conversationId } : undefined;
if (conversation?.id && expectedTaskKey) {
const metadata = await getConversationMetadataPg(input.userId, conversation.id);
if (metadata?.curatorTaskKey !== expectedTaskKey) {
conversation = undefined;
}
}
conversation ??= await ensureCuratorConversation({
userId: input.userId,
taskId: input.taskId,
date,
subtaskIndex: input.subtaskIndex,
subtask: input.subtask,
});
const latest = [...input.messages].reverse().find((message) => message.role === "user")?.content?.trim() ?? "start";
const tasks = await buildCuratorTasks(input.userId, date);
const task = input.taskId ? tasks.find((item) => item.id === input.taskId) : undefined;
const taskMemory = await capturedSubtaskMemory(input.userId, input.taskId);
const promptText = await loadCuratorStreakPrompt();
await addMessagePg(input.userId, {
id: buildId("user"),
conversationId: conversation.id,
role: "user",
sender: "User",
content: latest,
});
const conversationHistory = visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id));
const targetRole = await resolveCuratorTargetRole({
userId: input.userId,
task,
latest,
history: conversationHistory,
});
const isInitialOpen = /^start$/i.test(latest);
if (isInitialOpen && isPreviewHandoffService(task) && targetRole) {
const statusUpdate: CuratorSubtaskStatusUpdate = {
status: "handoff_ready",
summary: servicePreviewSummary(task!, targetRole),
confidence: 0.95,
};
const handoff = await prepareHandoffForTask(input.userId, task!, task!.serviceId, targetRole);
const reply = sanitize(statusUpdate.summary);
await emitCuratorEvent({
userId: input.userId,
type: "curator.subtask.captured",
mission: task ? { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId } : undefined,
payload: {
taskId: input.taskId,
subtaskIndex: input.subtaskIndex,
subtask: input.subtask,
statusUpdate,
},
});
await addMessagePg(input.userId, {
id: buildId("assistant"),
conversationId: conversation.id,
role: "assistant",
sender: "V1 Curator",
content: reply,
});
return {
conversationId: conversation.id,
taskId: input.taskId,
reply,
messages: visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id)),
statusUpdate,
handoff,
};
}
if (isInitialOpen && isPreviewHandoffService(task) && !targetRole) {
const statusUpdate: CuratorSubtaskStatusUpdate = {
status: "needs_more_context",
summary: "Target role needed before preparing the preview.",
confidence: 0.4,
nextMissingInfo: "target role",
};
const reply = "What role are you targeting?";
await addMessagePg(input.userId, {
id: buildId("assistant"),
conversationId: conversation.id,
role: "assistant",
sender: "V1 Curator",
content: reply,
});
return {
conversationId: conversation.id,
taskId: input.taskId,
reply,
messages: visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id)),
statusUpdate,
};
}
let reply = "";
let usedFallbackReply = false;
try {
try {
const extract = await generateText({
model: getConversationModel(),
system: [
"Extract compact curator memory from the user's latest message.",
"Return JSON only: {\"summary\": string, \"userGoal\"?: string, \"serviceIntent\"?: string, \"shouldPrepareHandoff\": boolean}.",
"Use ASCII punctuation only.",
].join("\n"),
prompt: (() => {
const lines: string[] = [];
pushField(lines, "Task", task?.title);
pushField(lines, "Subtask", input.subtask);
pushField(lines, "Service", task?.serviceName);
pushField(lines, "Message", latest);
return lines.join("\n");
})(),
});
const parsedExtract = chatExtractSchema.parse(parseJsonObject(extract.text));
await emitCuratorEvent({
userId: input.userId,
type: "curator.chat.context_extracted",
mission: task ? { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId } : undefined,
payload: { taskId: input.taskId, extract: parsedExtract },
});
} catch (error) {
console.warn("curator memory extraction failed; continuing chat", {
taskId: input.taskId,
subtaskIndex: input.subtaskIndex,
error: error instanceof Error ? error.message : String(error),
});
}
const modelMessages = conversationHistory.map((message) => ({
role: message.role,
content: message.content,
}));
if (/^start$/i.test(latest) && modelMessages.length === 0) {
modelMessages.push({ role: "user", content: firstTurnPrompt({ subtask: input.subtask, task, targetRole }) });
}
const result = await generateConversationResponse(modelMessages, {
userId: input.userId,
conversationId: conversation.id,
missionInstanceId: task?.missionInstanceId,
missionId: task?.missionId,
stageId: task?.stageId,
source: "curator-v1",
systemAddendum: await curatorSystemAddendum({ date, taskId: input.taskId, subtaskIndex: input.subtaskIndex, subtask: input.subtask, task, taskMemory, promptText, targetRole }),
});
reply = sanitize(result.text);
if (/what should i capture next/i.test(reply) || !reply) {
throw new Error("curator_generation_failed");
}
} catch (error) {
console.warn("curator chat generation failed", {
taskId: input.taskId,
subtaskIndex: input.subtaskIndex,
subtask: input.subtask,
error: error instanceof Error ? error.message : String(error),
});
reply = sanitize(fallbackCuratorReply({
latest,
task,
subtask: input.subtask,
targetRole,
}));
usedFallbackReply = true;
}
if (!reply) {
reply = sanitize(fallbackCuratorReply({
latest,
task,
subtask: input.subtask,
targetRole,
}));
usedFallbackReply = true;
}
let statusUpdate = await evaluateSubtaskStatus({
task,
subtask: input.subtask,
subtaskIndex: input.subtaskIndex,
latest,
reply,
history: conversationHistory,
});
if (task?.serviceId && (isExplicitHandoffRequest(latest) || statusUpdate.status === "ready_to_capture")) {
statusUpdate = {
status: "handoff_ready",
summary: servicePreviewSummary(task, targetRole),
confidence: Math.max(statusUpdate.confidence, 0.9),
};
}
if (usedFallbackReply && statusUpdate.status === "needs_more_context" && !statusUpdate.nextMissingInfo) {
statusUpdate = {
...statusUpdate,
summary: reply,
};
}
if (isPreviewHandoffService(task) && !isInitialOpen && usefulUserMessages(conversationHistory).length >= 1) {
statusUpdate = {
status: "handoff_ready",
summary: servicePreviewSummary(task!, targetRole),
confidence: Math.max(statusUpdate.confidence, 0.9),
};
}
const handoff = shouldPrepareServiceHandoff(statusUpdate, task)
? await prepareHandoffForTask(input.userId, task!, task!.serviceId, targetRole)
: undefined;
if (statusUpdate.status !== "needs_more_context") {
if (reply.includes("?") || handoff) {
reply = sanitize(statusUpdate.summary);
}
await emitCuratorEvent({
userId: input.userId,
type: "curator.subtask.captured",
mission: task ? { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId } : undefined,
payload: {
taskId: input.taskId,
subtaskIndex: input.subtaskIndex,
subtask: input.subtask,
statusUpdate,
},
});
}
await addMessagePg(input.userId, {
id: buildId("assistant"),
conversationId: conversation.id,
role: "assistant",
sender: "V1 Curator",
content: reply,
});
return {
conversationId: conversation.id,
taskId: input.taskId,
reply,
messages: visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id)),
statusUpdate,
handoff,
};
}
+1 -1
View File
@@ -198,7 +198,7 @@ async function upsertPlanReadyNotification(userId: string, sprint: CuratorSprint
id: notificationId,
userId,
moduleId: "missions",
title: "Your 30-day streak plan is ready",
title: `Your ${sprint.plan.durationDays}-day CareerSprint is ready`,
subtitle: dayOneSubtitle(sprint),
tag: "Day 1 ready",
urgency: "today",
-101
View File
@@ -1,101 +0,0 @@
import { createHash } from "node:crypto";
import type { CuratorIcpPlaybook } from "./icp-registry.js";
import type { CuratorUserContext } from "./curator-user-context.js";
export const CURATOR_PROMPT_VERSION = "service-curation-v1";
export type CuratorPromptAssembly = {
version: typeof CURATOR_PROMPT_VERSION;
hash: string;
prompt: string;
inputs: {
startDate: string;
durationDays: number;
userContext: CuratorUserContext;
playbook: CuratorIcpPlaybook;
goals: string[];
};
};
export function buildCuratorPlanPrompt(input: {
startDate: string;
durationDays: number;
userContext: CuratorUserContext;
playbook: CuratorIcpPlaybook;
goals?: string[];
}): CuratorPromptAssembly {
const goals = input.goals?.filter(Boolean) ?? [input.playbook.sprintTheme, input.playbook.goal];
const inputs = {
startDate: input.startDate,
durationDays: input.durationDays,
userContext: input.userContext,
playbook: input.playbook,
goals,
};
const prompt = [
"# GrowQR Service Curation Layer",
"",
"You generate deterministic 30-day streak plans from user context and an ICP playbook.",
"Do not invent services. Use only service ids present in the playbook and Service Registry.",
"Do not handcraft frontend URLs. Emit linkBuilder inputs; the backend Service Registry builds final deep links.",
"No randomness, no vague tasks, no duplicate same-day service tasks.",
"",
"## Output Contract",
"Return structured JSON only with:",
"- durationDays: 30",
"- calendarWeeks: Sunday-start calendar weeks covering all 30 days",
"- days: exactly 30 days, where Day 1 is the subscription/start date",
"- closeoutDays: day 29 and day 30",
"- each day has exactly 3 tasks: measurement, proof, practice",
"- every task includes taskType, serviceId, title, subtitle, qxImpact, effort, cta, expectedSignals, and linkBuilder input",
"- weekly themes must follow the ICP stage labels",
"",
"## Staging Rules",
"Start weekly grouping on Sunday. If the user subscribes on Monday, Day 1 is Monday inside a Sunday-start Week 1.",
"The sprint is always exactly 30 days. Do not extend or shorten it to fit a calendar week.",
"Use the first calendar week for Baseline + First Proof, then progress through the ICP stage labels.",
"Use Day 29 and Day 30 for next-sprint planning and strongest-proof packaging.",
"",
"## Personalization Rules",
"- Use targetRole for interview and roleplay links.",
"- Use resume/profile context when available; if missing, day 1 proof should collect it.",
"- Use QScore to prioritize measurement tasks.",
"- Use past activity to avoid repeating completed or recently-used actions.",
"- Map every goal to one of the ICP playbook service actions.",
"",
`Start date: ${input.startDate}`,
`Duration days: ${input.durationDays}`,
`Goals: ${goals.join(" | ")}`,
"",
"User context:",
stableStringify(input.userContext),
"",
"ICP playbook:",
stableStringify(input.playbook),
].join("\n");
return {
version: CURATOR_PROMPT_VERSION,
hash: stableHash({ version: CURATOR_PROMPT_VERSION, inputs }),
prompt,
inputs,
};
}
export function stableHash(value: unknown) {
return createHash("sha256").update(stableStringify(value)).digest("hex");
}
function stableStringify(value: unknown): string {
return JSON.stringify(sortKeys(value), null, 2);
}
function sortKeys(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeys);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => [key, sortKeys(item)]),
);
}
+11 -65
View File
@@ -1,10 +1,12 @@
import { Hono } from "hono";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { createClient, type Client } from "rivetkit/client";
import { requireUser, type AuthContext } from "../../auth/clerk.js";
import { config } from "../../config.js";
import { db } from "../../db/client.js";
import { onboarding } from "../../db/schema.js";
import type { Registry } from "../../actors/registry.js";
import { curatorService } from "./curator-actor.js";
import { buildCuratorSprint, buildCuratorSprintReport, buildCuratorTasks } from "./curator-store.js";
let _client: Client<Registry> | null = null;
@@ -16,37 +18,10 @@ function getCuratorActor(userId: string) {
return getClient().curatorActor.getOrCreate(["user", userId]);
}
const chatSchema = z.object({
conversationId: z.string().optional(),
taskId: z.string().optional(),
subtaskIndex: z.number().int().min(0).optional(),
subtask: z.string().optional(),
date: z.string().optional(),
messages: z.array(z.object({ role: z.enum(["user", "assistant"]), content: z.string() })).min(1).max(50),
});
const curationPreviewSchema = z.object({
startDate: z.string().optional(),
icpId: z.enum(["student_recent_grad", "intern", "fresher_early_professional", "experienced_professional", "freelancer", "founder", "enterprise"]).optional(),
goals: z.array(z.string()).optional(),
userContext: z.record(z.unknown()).optional(),
});
export function v1CuratorRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.post("/plan/generate", async (c) => {
const userId = c.get("userId");
const body = z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
goals: z.array(z.string()).optional(),
forceRegenerate: z.boolean().optional(),
}).parse(await c.req.json().catch(() => ({})));
return c.json(await getCuratorActor(userId).generatePlanRange({ userId, ...body }));
});
app.get("/plan", async (c) => {
const userId = c.get("userId");
return c.json(await getCuratorActor(userId).getPlan({
@@ -63,48 +38,30 @@ export function v1CuratorRoutes() {
app.get("/sprint", async (c) => {
const userId = c.get("userId");
const icpQuery = c.req.query("icpId");
const dayQuery = c.req.query("dayIndex");
const icpId = curationPreviewSchema.shape.icpId.safeParse(icpQuery).success ? icpQuery as NonNullable<z.infer<typeof curationPreviewSchema>["icpId"]> : undefined;
const dayIndex = dayQuery ? Number.parseInt(dayQuery, 10) : undefined;
return c.json(await buildCuratorSprint(userId, c.req.query("date"), {
icpId,
dayIndex: Number.isFinite(dayIndex) ? dayIndex : undefined,
}));
const savedOnboarding = await db.query.onboarding.findFirst({
where: eq(onboarding.userId, userId),
});
if (savedOnboarding?.data?.status !== "completed") {
return c.json({ error: "onboarding_incomplete" }, 409);
}
return c.json(await buildCuratorSprint(userId, c.req.query("date")));
});
app.get("/sprint/report", async (c) => {
const userId = c.get("userId");
const icpQuery = c.req.query("icpId");
const dayQuery = c.req.query("dayIndex");
const icpId = curationPreviewSchema.shape.icpId.safeParse(icpQuery).success ? icpQuery as NonNullable<z.infer<typeof curationPreviewSchema>["icpId"]> : undefined;
const dayIndex = dayQuery ? Number.parseInt(dayQuery, 10) : undefined;
return c.json(await buildCuratorSprintReport(userId, c.req.query("date"), {
icpId,
dayIndex: Number.isFinite(dayIndex) ? dayIndex : undefined,
}));
return c.json(await buildCuratorSprintReport(userId, c.req.query("date")));
});
app.post("/sprint/report/generate", async (c) => {
const userId = c.get("userId");
const body = z.object({
date: z.string().optional(),
icpId: curationPreviewSchema.shape.icpId.optional(),
dayIndex: z.number().int().min(1).max(30).optional(),
}).parse(await c.req.json().catch(() => ({})));
return c.json(await buildCuratorSprintReport(userId, body.date, {
icpId: body.icpId,
dayIndex: body.dayIndex,
save: true,
}));
});
app.post("/curation/preview", async (c) => {
const userId = c.get("userId");
const body = curationPreviewSchema.parse(await c.req.json().catch(() => ({})));
return c.json(await curatorService.previewCuration({ userId, ...body }));
});
app.post("/onboarding/run", async (c) => {
const userId = c.get("userId");
const body = z.object({
@@ -113,22 +70,11 @@ export function v1CuratorRoutes() {
return c.json(await getCuratorActor(userId).runOnboardingLoop({ userId, ...body }));
});
app.post("/chat", async (c) => {
const userId = c.get("userId");
const body = chatSchema.parse(await c.req.json());
return c.json(await getCuratorActor(userId).chat({ userId, ...body }));
});
app.post("/tasks/:taskId/start", async (c) => {
const userId = c.get("userId");
return c.json(await getCuratorActor(userId).startTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
});
app.post("/tasks/:taskId/handoff", async (c) => {
const userId = c.get("userId");
return c.json(await getCuratorActor(userId).prepareTaskHandoff({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
});
app.post("/tasks/:taskId/complete", async (c) => {
const userId = c.get("userId");
const body = z.object({ reason: z.string().optional() }).parse(await c.req.json().catch(() => ({})));
+73 -99
View File
@@ -1,12 +1,11 @@
import { and, desc, eq, gte, inArray, sql } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growEvents, missionArtifacts } from "../../db/schema.js";
import { growEvents, missionArtifacts, onboarding, users } from "../../db/schema.js";
import { listMissionDefinitions } from "../../missions/registry.js";
import { listServiceCapabilities } from "../../workflows/service-capabilities.js";
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
import { emitCuratorEvent } from "./curator-events.js";
import { buildCuratorUserContext } from "./curator-user-context.js";
import { stableHash } from "./curator-prompt-builder.js";
import { isCuratorIcpId, type CuratorIcpId } from "./icp-registry.js";
import type {
CuratorPlan,
@@ -66,8 +65,9 @@ const VALID_COMPLETION_TYPES = [
] as const;
const CURATOR_SOURCE = "curator-v1";
const CURATOR_PLAN_VERSION = "icp-v9-static";
const SPRINT_DURATION_DAYS = 30;
const CURATOR_PLAN_VERSION = "icp-v10-static";
const FULL_SPRINT_DURATION_DAYS = 7;
const TRIAL_SPRINT_DURATION_DAYS = 2;
const DAYS_PER_WEEK = 7;
const MAX_PLAN_WEEK_COUNT = 6;
@@ -98,6 +98,8 @@ type PlanDaySeed = {
type SprintState = {
startDate: string;
variantId: CuratorIcpId;
durationDays: 2 | 7;
access: "trial" | "full";
planDays: PlanDaySeed[];
};
@@ -152,8 +154,8 @@ function dayIndexInWeek(startDate: string, dayIndex: number) {
return ((dayIndex - 1) % DAYS_PER_WEEK) + 1;
}
function planWeekCount(startDate: string) {
return Math.ceil(SPRINT_DURATION_DAYS / DAYS_PER_WEEK);
function planWeekCount(durationDays: number) {
return Math.ceil(durationDays / DAYS_PER_WEEK);
}
function planFocus(weekTheme: string, seedTask: TaskSeed) {
@@ -364,14 +366,14 @@ function trialRecoveryTaskSeed(variantId: CuratorIcpId, missedDayIndex: number):
);
}
function planSeedsForVariant(templateSet: CuratorIcpTemplateSet, startDate: string): PlanDaySeed[] {
function planSeedsForVariant(templateSet: CuratorIcpTemplateSet, startDate: string, durationDays: 2 | 7): PlanDaySeed[] {
const carryForwardWeek = fallbackCarryForwardWeek(templateSet);
const weekTemplates = Array.from({ length: MAX_PLAN_WEEK_COUNT }, (_, index) => (
templateSet.weeks[index % templateSet.weeks.length] ?? carryForwardWeek
));
const planDays: PlanDaySeed[] = [];
for (let index = 0; index < SPRINT_DURATION_DAYS; index += 1) {
for (let index = 0; index < durationDays; index += 1) {
const dayIndex = index + 1;
const weekIndex = Math.floor(index / DAYS_PER_WEEK) + 1;
const dayOfWeek = (index % DAYS_PER_WEEK) + 1;
@@ -469,6 +471,10 @@ function eventText(value: unknown): string {
}
async function inferIcpVariant(userId: string): Promise<CuratorIcpId> {
const saved = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, userId) });
const canonical = saved?.payload && typeof saved.payload === "object" ? saved.payload.curator_registry_icp : undefined;
if (typeof canonical === "string" && isCuratorIcpId(canonical)) return canonical;
const rows = await db
.select({ type: growEvents.type, payload: growEvents.payload })
.from(growEvents)
@@ -497,13 +503,14 @@ function clonePlanDay(day: PlanDaySeed): PlanDaySeed {
};
}
function storedPlanDaysFromPayload(payload: Record<string, unknown> | undefined): PlanDaySeed[] | null {
const value = payload?.planDays;
if (!Array.isArray(value) || value.length !== SPRINT_DURATION_DAYS) return null;
return value.map((raw) => raw as PlanDaySeed);
}
async function loadSprintState(userId: string, todayDate = todayIso()): Promise<SprintState> {
const savedOnboarding = await db.query.onboarding.findFirst({
where: eq(onboarding.userId, userId),
});
if (savedOnboarding?.data?.status !== "completed") {
throw new Error("onboarding_incomplete");
}
const latest = await db
.select({ payload: growEvents.payload })
.from(growEvents)
@@ -518,48 +525,46 @@ async function loadSprintState(userId: string, todayDate = todayIso()): Promise<
const existingStartDate = typeof latest[0]?.payload?.startDate === "string" ? latest[0].payload.startDate : undefined;
const existingVersion = typeof latest[0]?.payload?.version === "string" ? latest[0].payload.version : undefined;
const existingVariant = typeof latest[0]?.payload?.variantId === "string" ? latest[0].payload.variantId : undefined;
const existingDuration = latest[0]?.payload?.durationDays === 7 ? 7 : latest[0]?.payload?.durationDays === 2 ? 2 : undefined;
const existingAccess = latest[0]?.payload?.access === "full" ? "full" : "trial";
if (
existingStartDate &&
existingVersion === CURATOR_PLAN_VERSION &&
existingVariant &&
isCuratorIcpId(existingVariant) &&
calendarDiffDays(existingStartDate, todayDate) < SPRINT_DURATION_DAYS
existingDuration &&
calendarDiffDays(existingStartDate, todayDate) < existingDuration
) {
const templateSet = templateSetFor(existingVariant as CuratorIcpId);
return {
startDate: existingStartDate,
variantId: existingVariant as CuratorIcpId,
planDays: storedPlanDaysFromPayload(latest[0]?.payload)?.map(clonePlanDay) ?? planSeedsForVariant(templateSet, existingStartDate),
durationDays: existingDuration,
access: existingAccess,
planDays: planSeedsForVariant(templateSet, existingStartDate, existingDuration),
};
}
const variantId = await inferIcpVariant(userId);
const templateSet = templateSetFor(variantId);
const planDays = planSeedsForVariant(templateSet, todayDate);
const userContext = await buildCuratorUserContext(userId);
const planHash = stableHash({ version: CURATOR_PLAN_VERSION, variantId, planDays });
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
const access = user?.plan === "pro" || user?.plan === "enterprise" ? "full" as const : "trial" as const;
const durationDays = access === "full" ? FULL_SPRINT_DURATION_DAYS : TRIAL_SPRINT_DURATION_DAYS;
const planDays = planSeedsForVariant(templateSet, todayDate, durationDays);
await emitCuratorEvent({
userId,
type: "curator.sprint.started",
payload: {
startDate: todayDate,
sprintId: sprintIdFor(todayDate),
durationDays: SPRINT_DURATION_DAYS,
durationDays,
access,
version: CURATOR_PLAN_VERSION,
variantId,
icpLabel: templateSet.label,
sprintTheme: templateSet.sprintTheme,
goals: [templateSet.sprintTheme, templateSet.goal],
userContext,
// agentGeneration is intentionally disabled. The curator consumes this
// static task registry and should not invent day tasks.
agentGeneration: "disabled",
prompt: undefined,
planHash,
planDays,
},
});
return { startDate: todayDate, variantId, planDays: planDays.map(clonePlanDay) };
return { startDate: todayDate, variantId, durationDays, access, planDays: planDays.map(clonePlanDay) };
}
async function loadRecentContextRows(userId: string, sinceDate: string) {
@@ -877,6 +882,7 @@ function buildTask(
completionRows: Awaited<ReturnType<typeof loadCompletionRows>>,
recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>,
focusDate: string,
durationDays: number,
targetRole?: string,
): CuratorTask {
seedTask = {
@@ -921,8 +927,8 @@ function buildTask(
{ label: "Week theme", value: weekTheme },
{ label: "Task type", value: seedTask.taskType },
{ label: "Service handoff", value: serviceName(seedTask.serviceId) },
{ label: "Target role", value: targetRole ?? "Product Manager" },
{ label: "Sprint day", value: `Day ${dayIndex}/${SPRINT_DURATION_DAYS}` },
...(targetRole ? [{ label: "Target role", value: targetRole }] : [{ label: "Target role", value: "Complete onboarding to set your role" }]),
{ label: "Sprint day", value: `Day ${dayIndex}/${durationDays}` },
],
contextNarrative: contextNarrative(seedTask, weekTheme, weekSummary),
subtasks: subtaskCopy(seedTask, weekTheme),
@@ -948,6 +954,7 @@ function buildTasksForPlanDay(
completionRows: Awaited<ReturnType<typeof loadCompletionRows>>,
recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>,
focusDate: string,
durationDays: number,
targetRole?: string,
) {
return planDay.plannedTasks.map((task) => (
@@ -962,6 +969,7 @@ function buildTasksForPlanDay(
completionRows,
recentRows,
focusDate,
durationDays,
targetRole,
)
));
@@ -970,7 +978,7 @@ function buildTasksForPlanDay(
export async function buildCuratorTasks(userId: string, date = todayIso()): Promise<CuratorTask[]> {
const sprintState = await loadSprintState(userId, date);
const sprintStartDate = sprintState.startDate;
const dayIndex = clamp(calendarDiffDays(sprintStartDate, date) + 1, 1, SPRINT_DURATION_DAYS);
const dayIndex = clamp(calendarDiffDays(sprintStartDate, date) + 1, 1, sprintState.durationDays);
const completionRows = await loadCompletionRows(userId, sprintStartDate);
const recentRows = await loadRecentContextRows(userId, sprintStartDate);
const currentQScore = await loadCurrentQScore(userId);
@@ -986,30 +994,25 @@ export async function buildCuratorTasks(userId: string, date = todayIso()): Prom
);
const planDay = adaptedPlanDays[dayIndex - 1];
if (!planDay) return [];
return buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, date, userContext.targetRole);
return buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, date, sprintState.durationDays, userContext.targetRole ?? undefined);
}
export async function buildCuratorStreak(userId: string): Promise<CuratorStreak> {
const rows = await db
.select({
day: sql<string>`to_char(${growEvents.occurredAt}, 'YYYY-MM-DD')`,
day: sql<string>`to_char(${growEvents.occurredAt} AT TIME ZONE 'Asia/Kolkata', 'YYYY-MM-DD')`,
})
.from(growEvents)
.where(and(eq(growEvents.userId, userId), inArray(growEvents.type, [...VALID_COMPLETION_TYPES])))
.groupBy(sql`to_char(${growEvents.occurredAt}, 'YYYY-MM-DD')`)
.orderBy(sql`to_char(${growEvents.occurredAt}, 'YYYY-MM-DD') desc`)
.groupBy(sql`to_char(${growEvents.occurredAt} AT TIME ZONE 'Asia/Kolkata', 'YYYY-MM-DD')`)
.orderBy(sql`to_char(${growEvents.occurredAt} AT TIME ZONE 'Asia/Kolkata', 'YYYY-MM-DD') desc`)
.limit(90);
const days = rows.map((row) => row.day);
return computeStreakFromDays(days);
}
type CuratorSprintBuildOptions = {
icpId?: CuratorIcpId;
dayIndex?: number;
};
type CuratorSprintReportBuildOptions = CuratorSprintBuildOptions & {
type CuratorSprintReportBuildOptions = {
save?: boolean;
};
@@ -1024,16 +1027,8 @@ const TERMINAL_REPORT_STATUSES: CuratorTask["status"][] = [
"abandoned",
];
async function buildCuratorSprintInternal(userId: string, focusDate = todayIso(), options: CuratorSprintBuildOptions = {}): Promise<CuratorSprintResponse> {
const demoDayIndex = options.dayIndex ? clamp(options.dayIndex, 1, SPRINT_DURATION_DAYS) : undefined;
const demoStartDate = demoDayIndex ? addDaysIso(focusDate, -(demoDayIndex - 1)) : focusDate;
const sprintState = options.icpId
? {
startDate: demoStartDate,
variantId: options.icpId,
planDays: planSeedsForVariant(templateSetFor(options.icpId), demoStartDate),
}
: await loadSprintState(userId, focusDate);
async function buildCuratorSprintInternal(userId: string, focusDate = todayIso()): Promise<CuratorSprintResponse> {
const sprintState = await loadSprintState(userId, focusDate);
const sprintStartDate = sprintState.startDate;
const templateSet = templateSetFor(sprintState.variantId);
const streak = await buildCuratorStreak(userId);
@@ -1041,26 +1036,24 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso()
const recentRows = await loadRecentContextRows(userId, sprintStartDate);
const currentQScore = await loadCurrentQScore(userId);
const userContext = await buildCuratorUserContext(userId);
const activeDayIndex = clamp(calendarDiffDays(sprintStartDate, focusDate) + 1, 1, SPRINT_DURATION_DAYS);
const planDays = options.icpId
? sprintState.planDays
: adaptCurrentDayPlan(
sprintStartDate,
sprintState.variantId,
activeDayIndex,
sprintState.planDays,
completionRows,
recentRows,
currentQScore,
);
const activeDayIndex = clamp(calendarDiffDays(sprintStartDate, focusDate) + 1, 1, sprintState.durationDays);
const planDays = adaptCurrentDayPlan(
sprintStartDate,
sprintState.variantId,
activeDayIndex,
sprintState.planDays,
completionRows,
recentRows,
currentQScore,
);
const days: CuratorPlanDay[] = [];
for (let index = 0; index < SPRINT_DURATION_DAYS; index += 1) {
for (let index = 0; index < sprintState.durationDays; index += 1) {
const dayIndex = index + 1;
const date = addDaysIso(sprintStartDate, index);
const planDay = planDays[index]!;
const tasks = date <= focusDate
? buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, focusDate, userContext.targetRole)
? buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, focusDate, sprintState.durationDays, userContext.targetRole ?? undefined)
: [];
const completedCount = tasks.filter((task) => task.status === "completed").length;
const unlockState = focusDate > date ? "completed" : focusDate === date ? "active" : "upcoming";
@@ -1086,7 +1079,7 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso()
});
}
const weeks: CuratorWeek[] = Array.from({ length: planWeekCount(sprintStartDate) }, (_, index) => {
const weeks: CuratorWeek[] = Array.from({ length: planWeekCount(sprintState.durationDays) }, (_, index) => {
const weekIndex = index + 1;
const template = planDays.find((day) => day.weekIndex === weekIndex);
const startDayIndex = planDays.find((day) => day.weekIndex === weekIndex)?.dayIndex ?? (index * DAYS_PER_WEEK + 1);
@@ -1120,10 +1113,10 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso()
id: sprintIdFor(sprintStartDate),
userId,
startDate: sprintStartDate,
endDate: addDaysIso(sprintStartDate, SPRINT_DURATION_DAYS - 1),
endDate: addDaysIso(sprintStartDate, sprintState.durationDays - 1),
goals: [templateSet.sprintTheme, templateSet.goal],
generatedAt: new Date().toISOString(),
durationDays: SPRINT_DURATION_DAYS,
durationDays: sprintState.durationDays,
weeks,
days,
streak,
@@ -1155,8 +1148,8 @@ export async function buildCuratorPlan(userId: string, input: { startDate: strin
return sprint.plan;
}
export async function buildCuratorSprint(userId: string, date = todayIso(), options: CuratorSprintBuildOptions = {}): Promise<CuratorSprintResponse> {
return buildCuratorSprintInternal(userId, date, options);
export async function buildCuratorSprint(userId: string, date = todayIso()): Promise<CuratorSprintResponse> {
return buildCuratorSprintInternal(userId, date);
}
function taskIsTerminalForReport(task: CuratorTask) {
@@ -1263,17 +1256,9 @@ export async function buildCuratorSprintReport(
date = todayIso(),
options: CuratorSprintReportBuildOptions = {},
): Promise<CuratorSprintReport> {
const demoDayIndex = options.dayIndex ? clamp(options.dayIndex, 1, SPRINT_DURATION_DAYS) : undefined;
const demoStartDate = demoDayIndex ? addDaysIso(date, -(demoDayIndex - 1)) : date;
const sprintState = options.icpId
? {
startDate: demoStartDate,
variantId: options.icpId,
planDays: planSeedsForVariant(templateSetFor(options.icpId), demoStartDate),
}
: await loadSprintState(userId, date);
const sprint = await buildCuratorSprintInternal(userId, date, options);
const savedReport = options.save || options.icpId ? null : await loadSavedSprintReport(userId, sprint.sprintId);
const sprintState = await loadSprintState(userId, date);
const sprint = await buildCuratorSprintInternal(userId, date);
const savedReport = options.save ? null : await loadSavedSprintReport(userId, sprint.sprintId);
if (savedReport && savedReport.icpId === sprintState.variantId) return savedReport;
const templateSet = templateSetFor(sprintState.variantId);
@@ -1318,38 +1303,27 @@ export async function buildServiceCurationPreview(input: ServiceCurationPreviewI
...await buildCuratorUserContext(input.userId),
...input.userContext,
};
const planDays = planSeedsForVariant(templateSet, startDate);
const planHash = stableHash({ version: CURATOR_PLAN_VERSION, icpId: inferredIcpId, planDays });
const planDays = planSeedsForVariant(templateSet, startDate, FULL_SPRINT_DURATION_DAYS);
const completionRows = await loadCompletionRows(input.userId, startDate);
const days = planDays.map((planDay, index) => {
const date = addDaysIso(startDate, index);
return {
...planDay,
date,
tasks: buildTasksForPlanDay(startDate, date, planDay, completionRows, [], date, userContext.targetRole),
tasks: buildTasksForPlanDay(startDate, date, planDay, completionRows, [], date, FULL_SPRINT_DURATION_DAYS, userContext.targetRole ?? undefined),
};
});
return {
source: CURATOR_SOURCE,
version: CURATOR_PLAN_VERSION,
idempotency: {
promptHash: "static-task-registry",
planHash,
},
userContext,
icpPlaybook: undefined,
prompt: {
version: CURATOR_PLAN_VERSION,
hash: "static-task-registry",
text: "Static task registry mode. Agent generation is disabled.",
},
plan: {
startDate,
endDate: addDaysIso(startDate, SPRINT_DURATION_DAYS - 1),
durationDays: SPRINT_DURATION_DAYS,
endDate: addDaysIso(startDate, FULL_SPRINT_DURATION_DAYS - 1),
durationDays: FULL_SPRINT_DURATION_DAYS,
goals: input.goals ?? [templateSet.sprintTheme, templateSet.goal],
calendarWeeks: Array.from({ length: planWeekCount(startDate) }, (_, index) => {
calendarWeeks: Array.from({ length: planWeekCount(FULL_SPRINT_DURATION_DAYS) }, (_, index) => {
const weekIndex = index + 1;
const weekDays = days.filter((day) => day.weekIndex === weekIndex);
return {
-566
View File
@@ -1,566 +0,0 @@
import { tool } from "ai";
import { z } from "zod";
import { eq, desc, and, inArray } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growEvents } from "../../db/schema.js";
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
import { interviewService, resumeService, roleplayService } from "../../services/product-service-clients.js";
import { listServices } from "../../services/service-registry.js";
import { createMissionAction, listMissionActions } from "../../missions/actions.js";
import { listActiveMissionsPg, listMessagesPg } from "../../grow/persistence.js";
import { buildCuratorStreak, buildCuratorTasks, listCuratorRegistryCapabilities } from "./curator-store.js";
import { actionLabel, serviceRoute } from "./curator-service-links.js";
import { curatorServiceIdSchema, type CuratorServiceHandoff, type CuratorTask } from "./curator-types.js";
import { emitCuratorEvent } from "./curator-events.js";
import { fallbackCuratorRole, resolveCuratorTargetRole } from "./curator-user-context.js";
async function findTask(userId: string, taskId: string, date: string) {
const tasks = await buildCuratorTasks(userId, date);
return tasks.find((task) => task.id === taskId) ?? null;
}
function conciseRoleHint(value: string | undefined) {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return trimmed.length > 80 ? `${trimmed.slice(0, 77).trimEnd()}...` : trimmed;
}
function buildRoleplayBrief(task: CuratorTask, targetRole: string) {
return `Practice a realistic ${task.title.toLowerCase()} conversation for ${targetRole}. Include one pushback moment, concise answers, and a clear next step.`;
}
async function missionGoalHint(userId: string, task: CuratorTask) {
if (!task.missionInstanceId) return undefined;
const active = await listActiveMissionsPg(userId);
const match = active.find((item) => item.mission.instanceId === task.missionInstanceId);
const goal = typeof match?.mission.goal === "string" ? match.mission.goal : undefined;
return conciseRoleHint(goal);
}
function asText(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return undefined;
}
function reviewField(payload: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
const direct = asText(payload[key]);
if (direct) return direct;
}
const review = payload.review && typeof payload.review === "object" ? payload.review as Record<string, unknown> : undefined;
if (!review) return undefined;
for (const key of keys) {
const nested = asText(review[key]);
if (nested) return nested;
}
return undefined;
}
async function latestInterviewResumeEvidence(userId: string) {
const rows = await db.select({
id: growEvents.id,
type: growEvents.type,
source: growEvents.source,
payload: growEvents.payload,
occurredAt: growEvents.occurredAt,
}).from(growEvents)
.where(and(
eq(growEvents.userId, userId),
inArray(growEvents.type as any, [
"interview.feedback.generated",
"interview.completed",
"roleplay.feedback.generated",
"roleplay.completed",
]),
))
.orderBy(desc(growEvents.occurredAt))
.limit(5);
const latest = rows[0];
if (!latest) return null;
const payload = latest.payload ?? {};
const strongestAnswer = reviewField(payload, [
"strongest_answer",
"strongestAnswer",
"best_answer",
"bestAnswer",
"top_answer",
"topAnswer",
]);
const improvementArea = reviewField(payload, [
"improvement_area",
"improvementArea",
"biggest_gap",
"biggestGap",
"coaching_note",
"coachingNote",
]);
const summary = reviewField(payload, [
"summary",
"feedback_summary",
"feedbackSummary",
"overall_feedback",
"overallFeedback",
]);
const carryForward = [
summary ? `Review summary: ${summary}` : undefined,
strongestAnswer ? `Strongest answer to convert into proof: ${strongestAnswer}` : undefined,
improvementArea ? `Weakest area to repair in resume positioning: ${improvementArea}` : undefined,
].filter((item): item is string => Boolean(item));
return {
eventId: latest.id,
source: latest.source,
type: latest.type,
occurredAt: latest.occurredAt,
carryForward,
};
}
export async function prepareHandoffForTask(
userId: string,
task: CuratorTask,
serviceId = task.serviceId,
targetRoleOverride?: string,
): Promise<CuratorServiceHandoff> {
if (!serviceId) throw new Error("Task has no service handoff.");
const resolvedTargetRole =
targetRoleOverride ??
(await missionGoalHint(userId, task)) ??
(await resolveCuratorTargetRole({ userId, task }));
const targetRole = fallbackCuratorRole(resolvedTargetRole);
const route = serviceRoute({
serviceId,
missionId: task.missionId,
missionInstanceId: task.missionInstanceId,
stageId: task.stageId,
taskId: task.id,
targetRole,
durationMinutes: 5,
difficulty: "medium",
personaId: serviceId === "roleplay-service" ? "emma" : "payal",
requestedMode: "video",
roleplayBrief: serviceId === "roleplay-service" ? buildRoleplayBrief(task, targetRole) : undefined,
});
let actionId: string | undefined;
if (task.missionInstanceId && task.missionId !== "curator-sprint") {
const action = await createMissionAction({
userId,
missionInstanceId: task.missionInstanceId,
missionId: task.missionId,
stageId: task.stageId,
agentId: "curator-v1",
agentName: "V1 Curator Actor",
baseAgent: "Curator Agent",
serviceId,
toolName: task.toolName,
mode: "suggestion",
status: "queued",
title: task.title,
body: task.subtitle,
prompt: `Prepare ${task.serviceName} handoff for ${task.title}.`,
payload: { href: route, route, taskId: task.id, source: "curator-v1" },
idempotencyKey: `curator-v1:${task.id}:${serviceId}`,
priority: 50,
urgency: "today",
});
actionId = action?.id;
}
await emitCuratorEvent({
userId,
type: "task.opened",
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
payload: {
taskId: task.id,
curatorTaskId: task.id,
serviceId,
route,
actionId,
expectedCompletionEvents: task.completionEvents,
},
});
return {
taskId: task.id,
serviceId,
route,
actionId,
actionRoute: route,
actionLabel: actionLabel({ ...task, serviceId }),
status: "prepared",
};
}
export function buildCuratorTools(ctx: { userId: string; date: string; conversationId?: string; taskId?: string }) {
return {
get_onboarding_context: tool({
description: "Read available onboarding and profile context from recent platform events.",
inputSchema: z.object({}),
execute: async () => {
const events = await db.select().from(growEvents)
.where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.category, "usage")))
.orderBy(desc(growEvents.occurredAt))
.limit(20);
return { events };
},
}),
get_user_goals: tool({
description: "Infer currently known goals from active missions and mission goals.",
inputSchema: z.object({}),
execute: async () => {
const active = await listActiveMissionsPg(ctx.userId);
return { goals: active.map((item) => ({ missionId: item.mission.missionId, title: item.mission.title, goal: item.mission.goal })) };
},
}),
get_curator_plan: tool({
description: "Read today's curator tasks and streak state.",
inputSchema: z.object({}),
execute: async () => ({ date: ctx.date, tasks: await buildCuratorTasks(ctx.userId, ctx.date), streak: await buildCuratorStreak(ctx.userId) }),
}),
get_today_tasks: tool({
description: "List today's V1 curator tasks.",
inputSchema: z.object({}),
execute: async () => ({ tasks: await buildCuratorTasks(ctx.userId, ctx.date) }),
}),
get_curator_streak: tool({
description: "Read the user's curator streak from allowed completion events.",
inputSchema: z.object({}),
execute: async () => ({ streak: await buildCuratorStreak(ctx.userId) }),
}),
read_curator_memory: tool({
description: "Read recent curator memory from existing grow events.",
inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(10) }),
execute: async ({ limit }) => {
const events = await db.select().from(growEvents)
.where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.source, "curator-v1")))
.orderBy(desc(growEvents.occurredAt))
.limit(limit);
return { events };
},
}),
write_curator_memory: tool({
description: "Write a durable curator memory event. Use this for chat extracts and status updates.",
inputSchema: z.object({ summary: z.string(), tags: z.array(z.string()).default([]) }),
execute: async ({ summary, tags }) => emitCuratorEvent({ userId: ctx.userId, type: "curator.memory.updated", payload: { summary, tags } }),
}),
read_conversation_context: tool({
description: "Read the current conversation history from existing conversation storage.",
inputSchema: z.object({ conversationId: z.string().optional() }),
execute: async ({ conversationId }) => ({ messages: conversationId || ctx.conversationId ? await listMessagesPg(ctx.userId, conversationId ?? ctx.conversationId!) : [] }),
}),
list_service_capabilities: tool({
description: "List deterministic service capabilities from existing service registries.",
inputSchema: z.object({}),
execute: listCuratorRegistryCapabilities,
}),
list_available_service_routes: tool({
description: "Return known handoff routes for registered services.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async ({ taskId }) => {
const task = taskId ? await findTask(ctx.userId, taskId, ctx.date) : null;
return {
routes: listServices().map((service) => ({
serviceId: service.id,
route: serviceRoute({ serviceId: service.id, missionId: task?.missionId, missionInstanceId: task?.missionInstanceId, stageId: task?.stageId, taskId: task?.id }),
})),
};
},
}),
validate_service_handoff: tool({
description: "Validate whether a requested service handoff exists in the registry.",
inputSchema: z.object({ serviceId: curatorServiceIdSchema }),
execute: async ({ serviceId }) => {
const capabilities = await listCuratorRegistryCapabilities();
return { valid: capabilities.services.some((service) => service.id === serviceId), serviceId };
},
}),
map_task_to_service: tool({
description: "Map a curator task to its service capability and handoff route.",
inputSchema: z.object({ taskId: z.string() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId, ctx.date);
return { task, route: task ? serviceRoute({ serviceId: task.serviceId, missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId, taskId }) : null };
},
}),
prepare_interview_setup: tool({
description: "Prepare an interview setup handoff. This creates a mission action and route, not the full interview workflow.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
if (!task) return { error: "task_not_found" };
return prepareHandoffForTask(ctx.userId, task, "interview-service");
},
}),
prepare_interview_preview: tool({
description: "Prepare the interview preview route after setup context exists.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
if (!task) return { error: "task_not_found" };
return prepareHandoffForTask(ctx.userId, task, "interview-service");
},
}),
read_interview_report: tool({
description: "Read available interview page/report state from the existing interview service.",
inputSchema: z.object({ sessionId: z.string().optional() }),
execute: async ({ sessionId }) => sessionId ? interviewService.review(sessionId) : interviewService.pageState(ctx.userId),
}),
list_interview_sessions: tool({
description: "List interview service page state for the user.",
inputSchema: z.object({}),
execute: async () => interviewService.pageState(ctx.userId),
}),
get_interview_latest_status: tool({
description: "Read the latest interview-related events.",
inputSchema: z.object({ limit: z.number().int().min(1).max(20).default(5) }),
execute: async ({ limit }) => db.select().from(growEvents).where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.source, "interview"))).orderBy(desc(growEvents.occurredAt)).limit(limit),
}),
prepare_resume_upload: tool({
description: "Prepare resume upload or resume builder handoff.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
if (!task) return { error: "task_not_found" };
return prepareHandoffForTask(ctx.userId, task, "resume-service");
},
}),
extract_resume_context: tool({
description: "Extract basic context from pasted resume text for curator reasoning.",
inputSchema: z.object({ text: z.string().min(1) }),
execute: async ({ text }) => ({
length: text.length,
hasExperience: /experience|work|employment/i.test(text),
hasEducation: /education|degree|university|college/i.test(text),
hasSkills: /skills|tools|technologies/i.test(text),
preview: text.slice(0, 500),
}),
}),
read_resume_report: tool({
description: "Read existing resume service state.",
inputSchema: z.object({ resumeId: z.string().optional() }),
execute: async ({ resumeId }) => resumeId ? resumeService.getResume(resumeId) : resumeService.state(ctx.userId),
}),
prepare_resume_rewrite: tool({
description: "Prepare a resume rewrite handoff.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
if (!task) return { error: "task_not_found" };
return prepareHandoffForTask(ctx.userId, task, "resume-service");
},
}),
prepare_resume_talking_points: tool({
description: "Prepare resume talking point handoff.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
if (!task) return { error: "task_not_found" };
return prepareHandoffForTask(ctx.userId, task, "resume-service");
},
}),
prepare_resume_gap_scan: tool({
description: "Prepare resume gap scan handoff.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
if (!task) return { error: "task_not_found" };
return prepareHandoffForTask(ctx.userId, task, "resume-service");
},
}),
prepare_resume_from_interview_evidence: tool({
description: "Prepare a resume handoff that carries forward recent interview or roleplay review evidence into the proof task.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
if (!task) return { error: "task_not_found" };
const handoff = await prepareHandoffForTask(ctx.userId, task, "resume-service");
const evidence = await latestInterviewResumeEvidence(ctx.userId);
if (!evidence?.carryForward?.length) {
const interviewFallback = await prepareHandoffForTask(ctx.userId, task, "interview-service");
return {
handoff,
carryForward: [],
requiresInterviewEvidence: true,
recommendedNextAction: "No recent interview or roleplay review evidence is available yet. Run an interview rep first so the resume proof can be generated from real conversation evidence.",
fallbackHandoff: interviewFallback,
};
}
return {
handoff,
carryForward: evidence?.carryForward ?? [],
sourceEventId: evidence?.eventId,
sourceEventType: evidence?.type,
sourceService: evidence?.source,
sourceOccurredAt: evidence?.occurredAt,
};
},
}),
prepare_roleplay_setup: tool({
description: "Prepare roleplay setup handoff.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
if (!task) return { error: "task_not_found" };
return prepareHandoffForTask(ctx.userId, task, "roleplay-service");
},
}),
prepare_roleplay_preview: tool({
description: "Prepare roleplay preview handoff.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
if (!task) return { error: "task_not_found" };
return prepareHandoffForTask(ctx.userId, task, "roleplay-service");
},
}),
suggest_roleplay_scenario: tool({
description: "Suggest a roleplay scenario from current task context.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
return { scenario: task?.title ?? "Practice a high-stakes workplace conversation", outcome: "Clear next step and confident response" };
},
}),
read_roleplay_report: tool({
description: "Read roleplay service report or page state.",
inputSchema: z.object({ sessionId: z.string().optional() }),
execute: async ({ sessionId }) => sessionId ? roleplayService.review(sessionId) : roleplayService.pageState(ctx.userId),
}),
list_roleplay_sessions: tool({
description: "List roleplay service page state for the user.",
inputSchema: z.object({}),
execute: async () => roleplayService.pageState(ctx.userId),
}),
read_qscore_state: tool({
description: "Read current Q-score projection state.",
inputSchema: z.object({}),
execute: async () => {
const result = await getQscoreFromService(ctx.userId, DEFAULT_QSCORE_ORG_ID);
return result ? [{ score: result.q_score, iq_score: result.iq_score, eq_score: result.eq_score, sq_score: result.sq_score, formula_version: result.formula_version }] : [];
},
}),
read_qscore_signals: tool({
description: "Read latest Q-score signals.",
inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(20) }),
execute: async () => {
const result = await getQscoreFromService(ctx.userId, DEFAULT_QSCORE_ORG_ID);
return result?.breakdown?.signals ?? [];
},
}),
explain_qscore_movement: tool({
description: "Explain recent Q-score movement from available signals.",
inputSchema: z.object({}),
execute: async () => {
const result = await getQscoreFromService(ctx.userId, DEFAULT_QSCORE_ORG_ID);
return { state: result ? [{ score: result.q_score, iq_score: result.iq_score, eq_score: result.eq_score, sq_score: result.sq_score }] : [], breakdown: result?.breakdown ?? null };
},
}),
map_task_to_qscore_signals: tool({
description: "Map a curator task to the Q-score signals it can affect.",
inputSchema: z.object({ taskId: z.string() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId, ctx.date);
return { taskId, signals: task?.signals ?? [] };
},
}),
prepare_qscore_review: tool({
description: "Disabled for curator task handoffs; QScore is read-only for dashboard scoring and should not be assigned as a curator task.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async () => ({ error: "qscore_curator_handoff_disabled", replacementServices: ["interview-service", "roleplay-service", "resume-service", "matchmaking-service", "courses-service"] }),
}),
emit_curator_event: tool({
description: "Emit a curator event through existing Grow event ingestion.",
inputSchema: z.object({ type: z.string(), payload: z.record(z.string(), z.unknown()).default({}) }),
execute: async ({ type, payload }) => emitCuratorEvent({ userId: ctx.userId, type, payload }),
}),
read_recent_grow_events: tool({
description: "Read recent Grow events for this user.",
inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(20) }),
execute: async ({ limit }) => db.select().from(growEvents).where(eq(growEvents.userId, ctx.userId)).orderBy(desc(growEvents.occurredAt)).limit(limit),
}),
find_matching_service_event: tool({
description: "Find service events that can complete a task.",
inputSchema: z.object({ taskId: z.string() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId, ctx.date);
if (!task) return { task: null, events: [] };
const events = await db.select().from(growEvents).where(and(eq(growEvents.userId, ctx.userId), inArray(growEvents.type as any, task.completionEvents))).orderBy(desc(growEvents.occurredAt)).limit(20);
return { task, events };
},
}),
complete_task_from_event: tool({
description: "Complete a task only when a valid service or platform event exists.",
inputSchema: z.object({ taskId: z.string(), eventId: z.string() }),
execute: async ({ taskId, eventId }) => {
const task = await findTask(ctx.userId, taskId, ctx.date);
if (!task) return { error: "task_not_found" };
const [event] = await db.select().from(growEvents).where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.id, eventId))).limit(1);
if (!event || !task.completionEvents.includes(event.type)) return { completed: false, reason: "event_not_allowed" };
return emitCuratorEvent({
userId: ctx.userId,
type: "curator.task.completed",
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
payload: { taskId, sourceEventId: eventId, qxDelta: task.rewardCoins, rewardCoins: task.rewardCoins, qxImpact: task.qxImpact },
});
},
}),
update_streak_from_completion: tool({
description: "Read streak after completion events.",
inputSchema: z.object({}),
execute: async () => ({ streak: await buildCuratorStreak(ctx.userId) }),
}),
list_mission_actions: tool({
description: "List existing mission actions so the curator does not duplicate handoffs.",
inputSchema: z.object({}),
execute: async () => listMissionActions(ctx.userId, { openOnly: false }),
}),
};
}
+19 -41
View File
@@ -36,9 +36,9 @@ export const curatorWeekPerformanceSchema = z.enum(["Missed", "Okayish", "Avg",
export const curatorTaskSchema = z.object({
id: z.string(),
date: z.string(),
dayIndex: z.number().int().min(1).max(30),
dayIndex: z.number().int().min(1).max(7),
dayIndexInWeek: z.number().int().min(1).max(7),
weekIndex: z.number().int().min(1).max(6),
weekIndex: z.number().int().min(1).max(1),
taskType: curatorTaskTypeSchema,
title: z.string(),
subtitle: z.string(),
@@ -67,16 +67,20 @@ export const curatorTaskSchema = z.object({
});
export const curatorStreakSchema = z.object({
current: z.number().int().min(0),
longest: z.number().int().min(0),
lastCompletedDate: z.string().nullable(),
policy_version: z.literal("qualifying-activity-v1"),
timezone: z.literal("Asia/Kolkata"),
as_of: z.string(),
current_days: z.number().int().min(0),
longest_days: z.number().int().min(0),
last_completed_on: z.string().nullable(),
completed_today: z.boolean(),
});
export const curatorPlanDaySchema = z.object({
date: z.string(),
dayIndex: z.number().int().min(1).max(30),
dayIndex: z.number().int().min(1).max(7),
dayIndexInWeek: z.number().int().min(1).max(7),
weekIndex: z.number().int().min(1).max(6),
weekIndex: z.number().int().min(1).max(1),
weekTheme: z.string(),
weekSummary: z.string(),
focus: z.string().optional(),
@@ -94,12 +98,12 @@ export const curatorPlanDaySchema = z.object({
});
export const curatorWeekSchema = z.object({
weekIndex: z.number().int().min(1).max(6),
weekIndex: z.number().int().min(1).max(1),
title: z.string(),
theme: z.string(),
summary: z.string(),
startDayIndex: z.number().int().min(1).max(30),
endDayIndex: z.number().int().min(1).max(30),
startDayIndex: z.number().int().min(1).max(7),
endDayIndex: z.number().int().min(1).max(7),
lifecycle: curatorWeekLifecycleSchema,
performance: curatorWeekPerformanceSchema,
completedTaskCount: z.number().int().min(0),
@@ -115,9 +119,9 @@ export const curatorPlanSchema = z.object({
endDate: z.string(),
goals: z.array(z.string()),
generatedAt: z.string(),
durationDays: z.literal(30),
weeks: z.array(curatorWeekSchema).min(5).max(6),
days: z.array(curatorPlanDaySchema).length(30),
durationDays: z.union([z.literal(2), z.literal(7)]),
weeks: z.array(curatorWeekSchema).min(1).max(1),
days: z.array(curatorPlanDaySchema).min(2).max(7),
streak: curatorStreakSchema,
source: z.literal("curator-v1"),
});
@@ -140,9 +144,9 @@ export const curatorSprintResponseSchema = z.object({
sprintId: z.string(),
plan: curatorPlanSchema,
activeWeek: curatorWeekSchema,
activeWeekIndex: z.number().int().min(1).max(6),
activeWeekIndex: z.number().int().min(1).max(1),
activeDay: curatorPlanDaySchema,
activeDayIndex: z.number().int().min(1).max(30),
activeDayIndex: z.number().int().min(1).max(7),
todayTasks: z.array(curatorTaskSchema).min(3).max(4),
streak: curatorStreakSchema,
completedCount: z.number().int().min(0),
@@ -214,29 +218,3 @@ export type CuratorTodayResponse = {
totalCount: number;
source: "curator-v1";
};
export type CuratorSubtaskStatusUpdate = {
status: "needs_more_context" | "ready_to_capture" | "handoff_ready";
summary: string;
confidence: number;
nextMissingInfo?: string;
};
export type CuratorChatResponse = {
conversationId: string;
taskId?: string;
reply: string;
messages: Array<{ id: string; role: "user" | "assistant"; sender: string; content: string; createdAt: number }>;
statusUpdate?: CuratorSubtaskStatusUpdate;
handoff?: CuratorServiceHandoff;
};
export type CuratorServiceHandoff = {
taskId: string;
serviceId: CuratorServiceId;
route: string;
actionId?: string;
actionRoute: string;
actionLabel: string;
status: "prepared";
};
+25 -7
View File
@@ -1,6 +1,6 @@
import { and, desc, eq } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growEvents } from "../../db/schema.js";
import { growEvents, onboarding } from "../../db/schema.js";
import { asRecord, getString } from "../../events/envelope.js";
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
import type { CuratorTask } from "./curator-types.js";
@@ -19,7 +19,7 @@ function stringArray(value: unknown): string[] {
export type CuratorUserContext = {
userId: string;
targetRole: string;
targetRole: string | null;
experienceLevel: "student" | "intern" | "early" | "experienced" | "unknown";
resume: {
available: boolean;
@@ -128,6 +128,10 @@ export async function resolveCuratorTargetRole(input: {
latest?: string;
history?: Array<{ role: "user" | "assistant"; content: string }>;
}) {
const saved = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, input.userId) });
const savedRole = getString(asRecord(saved?.payload).target_role);
if (savedRole) return savedRole;
const latestRole = inferRoleFromText(input.latest);
if (latestRole) return latestRole;
@@ -156,10 +160,12 @@ export async function resolveCuratorTargetRole(input: {
}
export function fallbackCuratorRole(role?: string) {
return role?.trim() || "Product Manager";
return role?.trim() || undefined;
}
export async function buildCuratorUserContext(userId: string): Promise<CuratorUserContext> {
const savedOnboarding = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, userId) });
const onboardingPayload = asRecord(savedOnboarding?.payload);
const rows = await db
.select({ type: growEvents.type, source: growEvents.source, payload: growEvents.payload, occurredAt: growEvents.occurredAt })
.from(growEvents)
@@ -171,9 +177,12 @@ export async function buildCuratorUserContext(userId: string): Promise<CuratorUs
const breakdown = qscoreResult?.breakdown ?? {};
const breakdownSignals = Array.isArray(breakdown.signals) ? breakdown.signals : [];
const targetRole = fallbackCuratorRole(await resolveCuratorTargetRole({ userId }));
const targetRole = fallbackCuratorRole(await resolveCuratorTargetRole({ userId })) ?? null;
const corpus = rows.map((row) => `${row.type} ${row.source} ${payloadText(row.payload)}`).join(" ").toLowerCase();
const goals = uniqueStrings(rows.flatMap((row) => goalsFromPayload(row.payload)));
const goals = uniqueStrings([
...stringArray(onboardingPayload.goals),
...rows.flatMap((row) => goalsFromPayload(row.payload)),
]);
const skills = uniqueStrings(rows.flatMap((row) => stringArray(asRecord(row.payload).skills))).slice(0, 12);
const latestResume = rows
.map((row) => resumeSummaryFromPayload(row.payload))
@@ -182,13 +191,13 @@ export async function buildCuratorUserContext(userId: string): Promise<CuratorUs
return {
userId,
targetRole,
experienceLevel: inferExperienceLevel(corpus),
experienceLevel: experienceLevelFromOnboarding(onboardingPayload) ?? inferExperienceLevel(corpus),
resume: {
available: Boolean(latestResume || /\bresume|cv|linkedin\b/i.test(corpus)),
latestSummary: latestResume,
skills,
},
goals: goals.length ? goals.slice(0, 8) : [targetRole],
goals: goals.length ? goals.slice(0, 8) : targetRole ? [targetRole] : [],
pastActivity: {
recentEventCount: rows.length,
serviceSources: uniqueStrings(rows.map((row) => row.source)).slice(0, 12),
@@ -208,6 +217,15 @@ export async function buildCuratorUserContext(userId: string): Promise<CuratorUs
};
}
function experienceLevelFromOnboarding(payload: Record<string, unknown>): CuratorUserContext["experienceLevel"] | null {
const icp = getString(payload.onboarding_icp);
if (icp === "student") return "student";
if (icp === "intern") return "intern";
if (icp === "fresher") return "early";
if (icp === "experienced") return "experienced";
return null;
}
function goalsFromPayload(payload: Record<string, unknown>) {
const preferences = asRecord(payload.preferences);
return [
+20 -6
View File
@@ -8,6 +8,15 @@ export function todayIso(date = new Date()): string {
return date.toISOString().slice(0, 10);
}
export function indiaTodayIso(date = new Date()): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Kolkata",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
}
export function addDaysIso(startDate: string, days: number): string {
const date = new Date(`${startDate}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() + days);
@@ -27,14 +36,15 @@ export function addDaysIso(startDate: string, days: number): string {
*/
export function computeStreakFromDays(
days: string[],
today: string = todayIso(),
today: string = indiaTodayIso(),
): CuratorStreak {
// Deduplicate defensively: a repeated day string must never inflate the count.
const uniqueDesc = [...new Set(days)];
let current = 0;
let cursor = today;
const daySet = new Set(uniqueDesc);
const completedToday = daySet.has(today);
let current = 0;
let cursor = completedToday ? today : addDaysIso(today, -1);
while (daySet.has(cursor)) {
current += 1;
cursor = addDaysIso(cursor, -1);
@@ -51,8 +61,12 @@ export function computeStreakFromDays(
}
return {
current,
longest: Math.max(longest, current),
lastCompletedDate: uniqueDesc[0] ?? null,
policy_version: "qualifying-activity-v1",
timezone: "Asia/Kolkata",
as_of: new Date().toISOString(),
current_days: current,
longest_days: Math.max(longest, current),
last_completed_on: uniqueDesc[0] ?? null,
completed_today: completedToday,
};
}