From 17a888bd67189802fc4646aee71fb1fabaa21f45 Mon Sep 17 00:00:00 2001 From: NinjasPyajamas Date: Mon, 22 Jun 2026 22:24:27 +0530 Subject: [PATCH] feat: update curator schemas to support 6-week plans and enhance user context - Increased weekIndex max from 5 to 6 in curator task, plan day, and week schemas. - Adjusted days array in curator week schema to allow a minimum of 1 day. - Modified weeks array in curator plan schema to accept between 5 and 6 weeks. - Enhanced CuratorUserContext type to include detailed user information and QScore. - Introduced Curator ICP Playbooks for various user profiles with structured actions. - Implemented onboarding loop for user onboarding completion and notification. - Added prompt builder for generating structured 30-day plans based on user context and playbooks. --- src/events/redis-consumer.ts | 2 + src/routes/events.ts | 12 +- src/routes/users.ts | 25 +- src/v1/curator/README.md | 20 ++ src/v1/curator/curator-actor.ts | 31 ++- src/v1/curator/curator-icp-playbooks.ts | 103 +++++++ src/v1/curator/curator-onboarding-loop.ts | 314 ++++++++++++++++++++++ src/v1/curator/curator-prompt-builder.ts | 101 +++++++ src/v1/curator/curator-routes.ts | 21 ++ src/v1/curator/curator-service-links.ts | 11 + src/v1/curator/curator-store.ts | 182 ++++++++++--- src/v1/curator/curator-types.ts | 12 +- src/v1/curator/curator-user-context.ts | 125 ++++++++- 13 files changed, 903 insertions(+), 56 deletions(-) create mode 100644 src/v1/curator/curator-icp-playbooks.ts create mode 100644 src/v1/curator/curator-onboarding-loop.ts create mode 100644 src/v1/curator/curator-prompt-builder.ts diff --git a/src/events/redis-consumer.ts b/src/events/redis-consumer.ts index eef51ef..5b9d50e 100644 --- a/src/events/redis-consumer.ts +++ b/src/events/redis-consumer.ts @@ -3,6 +3,7 @@ import { config } from "../config.js"; import { log } from "../log.js"; import { recordGrowEvent } from "./record-grow-event.js"; import { routeGrowEventToUserActor } from "./route-to-user-actor.js"; +import { runCuratorOnboardingLoopForEventSafely } from "../v1/curator/curator-onboarding-loop.js"; // This file has two Redis ingestion modes: // 1. Canonical GrowEvent stream: grow.events.raw — future service event bus. @@ -150,6 +151,7 @@ async function recordAndRoute(input: unknown) { await routeGrowEventToUserActor(event).catch((err) => { log.warn({ err, eventId: event.id, userId: event.userId }, "failed to route grow event to user actor"); }); + await runCuratorOnboardingLoopForEventSafely(event); return event; } diff --git a/src/routes/events.ts b/src/routes/events.ts index 36fbde8..f4e2445 100644 --- a/src/routes/events.ts +++ b/src/routes/events.ts @@ -6,6 +6,7 @@ import { growEvents } from "../db/schema.js"; import { requireUser, type AuthContext } from "../auth/clerk.js"; import { recordGrowEvent } from "../events/record-grow-event.js"; import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js"; +import { runCuratorOnboardingLoopForEventSafely } from "../v1/curator/curator-onboarding-loop.js"; function serviceAuthorized(auth: string | undefined) { const token = (auth ?? "").replace(/^Bearer\s+/i, "").trim(); @@ -20,7 +21,8 @@ async function ingest(body: unknown, userId?: string, source?: string) { routed: false as const, reason: err instanceof Error ? err.message : String(err), })); - return { event, route }; + const curatorOnboarding = await runCuratorOnboardingLoopForEventSafely(event); + return { event, route, curatorOnboarding }; } export function eventRoutes() { @@ -30,8 +32,8 @@ export function eventRoutes() { app.post("/ingest", requireUser, async (c) => { const userId = c.get("userId"); const body = await c.req.json().catch(() => ({})); - const { event, route } = await ingest(body, userId); - return c.json({ eventId: event.id, processingStatus: event.processingStatus, route }, 202); + const { event, route, curatorOnboarding } = await ingest(body, userId); + return c.json({ eventId: event.id, processingStatus: event.processingStatus, route, curatorOnboarding }, 202); }); // Service-to-service ingress. Services may include userId directly, or we resolve it from session correlation. @@ -41,8 +43,8 @@ export function eventRoutes() { } const body = await c.req.json().catch(() => ({})); const source = c.req.header("x-growqr-source") ?? undefined; - const { event, route } = await ingest(body, undefined, source); - return c.json({ eventId: event.id, processingStatus: event.processingStatus, route }, 202); + const { event, route, curatorOnboarding } = await ingest(body, undefined, source); + return c.json({ eventId: event.id, processingStatus: event.processingStatus, route, curatorOnboarding }, 202); }); app.get("/", requireUser, async (c) => { diff --git a/src/routes/users.ts b/src/routes/users.ts index 72b1f79..370de26 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -7,6 +7,10 @@ import { provisionUserStack } from "../docker/manager.js"; import { log } from "../log.js"; import { config } from "../config.js"; import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js"; +import { + onboardingCompletedAtFromPreferences, + runCuratorOnboardingLoopSafely, +} from "../v1/curator/curator-onboarding-loop.js"; function publicStack(stack: UserStack | null | undefined) { if (!stack) return stack; @@ -102,14 +106,27 @@ export function userRoutes() { try { const userProfile = JSON.parse(text) as Record; const preferences = userProfile.preferences; + const normalizedPreferences = preferences && typeof preferences === "object" && !Array.isArray(preferences) + ? (preferences as Record) + : undefined; await ensureOnboardingBaselineQscore( c.get("userId"), - preferences && typeof preferences === "object" && !Array.isArray(preferences) - ? (preferences as Record) - : undefined, + normalizedPreferences, ); + const completedAt = onboardingCompletedAtFromPreferences(normalizedPreferences); + if (completedAt) { + await runCuratorOnboardingLoopSafely({ + userId: c.get("userId"), + completedAt, + source: "user-service-profile", + context: { + preferences: normalizedPreferences, + profile: userProfile, + }, + }); + } } catch (err) { - log.warn({ err, userId: c.get("userId") }, "failed to seed onboarding Q Score baseline after user update"); + log.warn({ err, userId: c.get("userId") }, "failed to run onboarding side effects after user update"); } } diff --git a/src/v1/curator/README.md b/src/v1/curator/README.md index afcc8e5..769dfc1 100644 --- a/src/v1/curator/README.md +++ b/src/v1/curator/README.md @@ -8,3 +8,23 @@ V1 replaces the old Daily Mission path with a single Curator layer. - Services still own their workflows. Curator tools prepare handoffs and routes. Completion is event gated. A checkbox or chat message cannot complete a task unless a matching service or platform event exists. + +## Service Curation Layer + +- `curator-icp-playbooks.ts` defines ICP playbooks and maps each persona goal to registry-backed service actions. +- `curator-user-context.ts` assembles deterministic user context from Grow events and QScore projection state. +- `curator-prompt-builder.ts` builds the LLM-ready curation prompt and stable prompt hash. +- `curator-store.ts` keeps generation idempotent by storing sprint starts in `grow_events` with the plan version, ICP, user context, prompt hash, playbook, plan hash, and 30-day plan days. +- `curator-service-links.ts` is the link builder over the Service Registry. Generated tasks use it to produce actionable frontend deep links. +- `POST /v1/curator/curation/preview` accepts optional `icpId`, `goals`, and `userContext` overrides and returns the assembled prompt, ICP playbook, idempotency hashes, Sunday-start `calendarWeeks`, `days` (all 30 days), `closeoutDays` (day 29-30), and deep-linked tasks. + +## Curator Onboarding Loop + +- `curator-onboarding-loop.ts` runs once after onboarding completion and creates the user's persisted 30-day streak plan through the curation layer. +- Trigger paths: + - Grow event ingestion: `onboarding.completed`, `user.onboarding.completed`, `profile.onboarding.completed`, or payloads/preferences with `onboarding.completed_at`. + - User profile updates: `PATCH /api/users/me` runs the loop when user-service returns onboarding preferences with `completed_at`. + - QA retry: `POST /v1/curator/onboarding/run` accepts optional `completedAt` and returns `ready` or `already_ready`. +- Before generation, the loop snapshots onboarding context into `grow_events` so curation sees the user-service profile/preferences. Event-only triggers also attempt an internal user-service fetch via the service-token path. +- Idempotency is based on the one-time `curator.onboarding_plan.ready` event. Retries do not duplicate the plan-ready analytics event or in-app notification. +- The loop stores the sprint as `curator.sprint.started`, emits `curator.onboarding_plan.ready` with weekly themes and Day 1 task links, and creates a persistent home notification pointing users to their active plan. diff --git a/src/v1/curator/curator-actor.ts b/src/v1/curator/curator-actor.ts index 2e0df09..bf6627a 100644 --- a/src/v1/curator/curator-actor.ts +++ b/src/v1/curator/curator-actor.ts @@ -1,15 +1,30 @@ -import { buildCuratorPlan, buildCuratorSprint, buildCuratorStreak, buildCuratorTasks, todayIsoDate } from "./curator-store.js"; +import { buildCuratorPlan, buildCuratorSprint, buildCuratorStreak, buildCuratorTasks, buildServiceCurationPreview, todayIsoDate } from "./curator-store.js"; import { curatorPlanSchema, 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 "./curator-icp-playbooks.js"; +import { runCuratorOnboardingLoop } from "./curator-onboarding-loop.js"; export const curatorActor = { 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, 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 }; }, @@ -17,6 +32,18 @@ export const curatorActor = { return this.generatePlanRange(input); }, + async previewCuration(input: { userId: string; startDate?: string; icpId?: CuratorIcpId; goals?: string[]; userContext?: Record }) { + return buildServiceCurationPreview(input); + }, + + async runOnboardingLoop(input: { userId: string; completedAt?: string }) { + return runCuratorOnboardingLoop({ + userId: input.userId, + completedAt: input.completedAt, + source: "curator-api", + }); + }, + async getToday(input: { userId: string; date?: string }) { const date = input.date ?? todayIsoDate(); const sprint = curatorSprintResponseSchema.parse(await buildCuratorSprint(input.userId, date)); diff --git a/src/v1/curator/curator-icp-playbooks.ts b/src/v1/curator/curator-icp-playbooks.ts new file mode 100644 index 0000000..db58270 --- /dev/null +++ b/src/v1/curator/curator-icp-playbooks.ts @@ -0,0 +1,103 @@ +import type { CuratorServiceId, CuratorTaskType } from "./curator-types.js"; + +export type CuratorIcpId = + | "student_recent_grad" + | "intern" + | "fresher_early_professional" + | "experienced_professional"; + +export type CuratorPlaybookAction = { + taskType: CuratorTaskType; + serviceId: CuratorServiceId; + goal: string; + action: string; + deepLinkIntent: string; + expectedSignals: string[]; +}; + +export type CuratorIcpPlaybook = { + id: CuratorIcpId; + label: string; + sprintTheme: string; + goal: string; + stageLabels: [string, string, string, string, string]; + serviceActions: CuratorPlaybookAction[]; +}; + +export const CURATOR_ICP_PLAYBOOKS: Record = { + student_recent_grad: { + id: "student_recent_grad", + label: "Student / Recent Grad", + sprintTheme: "First Role Readiness Sprint", + goal: "Have a credible resume, practiced interviews, visible proof, and a clear target role by the end of the sprint.", + stageLabels: ["Baseline + First Proof", "Fix Obvious Gaps", "Build Proof Momentum", "Market-Ready Practice", "Closeout + Next Sprint"], + serviceActions: [ + play("measurement", "qscore-service", "baseline", "Establish readiness baseline and weakest drivers.", "analytics", ["qscore baseline", "weakest driver"]), + play("proof", "resume-service", "first proof", "Import resume and convert projects into proof bullets.", "resume workspace", ["resume import", "project proof"]), + play("proof", "social-branding-service", "visible credibility", "Turn proof into public-safe profile and post artifacts.", "social profile flow", ["headline", "public proof"]), + play("practice", "interview-service", "interview confidence", "Run behavioral and project interview reps.", "interview preview", ["mock interview", "feedback"]), + play("practice", "matchmaking-service", "role direction", "Shortlist realistic first-role opportunities.", "pathways", ["target roles", "opportunity shortlist"]), + ], + }, + intern: { + id: "intern", + label: "Intern", + sprintTheme: "Intern-to-Offer Sprint", + goal: "Convert internship work into stronger impact proof, return-offer readiness, and external backup options.", + stageLabels: ["Baseline + First Proof", "Fix Obvious Gaps", "Build Proof Momentum", "Market-Ready Practice", "Closeout + Next Sprint"], + serviceActions: [ + play("measurement", "qscore-service", "return-offer baseline", "Measure return-offer proof gaps and readiness.", "analytics", ["return offer", "readiness"]), + play("proof", "resume-service", "internship proof", "Document project decisions, metrics, and impact bullets.", "resume workspace", ["internship proof", "impact log"]), + play("proof", "social-branding-service", "manager visibility", "Prepare manager updates, feedback asks, and visibility notes.", "social profile flow", ["manager update", "feedback ask"]), + play("practice", "roleplay-service", "conversion conversations", "Practice mentor, manager, and return-offer asks.", "roleplay builder", ["conversion ask", "stakeholder conversation"]), + play("practice", "matchmaking-service", "backup options", "Maintain credible external backup opportunities.", "pathways", ["backup roles", "pipeline"]), + ], + }, + fresher_early_professional: { + id: "fresher_early_professional", + label: "Fresher / Early Professional", + sprintTheme: "Callback-to-Offer Sprint", + goal: "Improve callback conversion, sharpen proof, and build stronger interview confidence across the sprint.", + stageLabels: ["Baseline + First Proof", "Fix Obvious Gaps", "Build Proof Momentum", "Market-Ready Practice", "Closeout + Next Sprint"], + serviceActions: [ + play("measurement", "qscore-service", "readiness baseline", "Anchor the sprint in current QScore and missing signals.", "analytics", ["qscore", "readiness"]), + play("proof", "resume-service", "role-fit proof", "Tailor resume proof to target roles and outcomes.", "resume workspace", ["resume proof", "role fit"]), + play("proof", "social-branding-service", "credibility signal", "Create visible credibility updates from real work.", "social profile flow", ["credibility", "visibility"]), + play("practice", "interview-service", "callback conversion", "Run focused interview reps for weak question types.", "interview preview", ["interview practice", "callback"]), + play("practice", "roleplay-service", "confidence conversations", "Practice recruiter intros, objections, and pitch clarity.", "roleplay builder", ["recruiter intro", "confidence"]), + ], + }, + experienced_professional: { + id: "experienced_professional", + label: "Experienced Professional", + sprintTheme: "Leadership Readiness Sprint", + goal: "Strengthen leadership proof, senior interview readiness, and authority positioning for the next move.", + stageLabels: ["Leadership Baseline + Strategic Proof", "Strategic Positioning + Authority", "Negotiation + Market Action", "Conversion + Closeout", "Momentum + Carry Forward"], + serviceActions: [ + play("measurement", "qscore-service", "senior readiness baseline", "Identify leadership readiness and authority gaps.", "analytics", ["leadership baseline", "authority"]), + play("proof", "resume-service", "leadership proof", "Translate execution into scope, team, and business impact.", "resume workspace", ["leadership proof", "business impact"]), + play("proof", "social-branding-service", "authority positioning", "Turn strategic lessons into public-safe authority signals.", "social profile flow", ["authority post", "positioning"]), + play("practice", "interview-service", "senior interviews", "Practice stakeholder, strategy, and leadership interview reps.", "interview preview", ["senior interview", "strategy"]), + play("practice", "roleplay-service", "negotiation and pushback", "Practice compensation, scope, promotion, and objection conversations.", "roleplay builder", ["negotiation", "pushback"]), + ], + }, +}; + +export function isCuratorIcpId(value: string): value is CuratorIcpId { + return value in CURATOR_ICP_PLAYBOOKS; +} + +export function curatorPlaybookFor(id: CuratorIcpId) { + return CURATOR_ICP_PLAYBOOKS[id] ?? CURATOR_ICP_PLAYBOOKS.fresher_early_professional; +} + +function play( + taskType: CuratorTaskType, + serviceId: CuratorServiceId, + goal: string, + action: string, + deepLinkIntent: string, + expectedSignals: string[], +): CuratorPlaybookAction { + return { taskType, serviceId, goal, action, deepLinkIntent, expectedSignals }; +} diff --git a/src/v1/curator/curator-onboarding-loop.ts b/src/v1/curator/curator-onboarding-loop.ts new file mode 100644 index 0000000..37a072f --- /dev/null +++ b/src/v1/curator/curator-onboarding-loop.ts @@ -0,0 +1,314 @@ +import { and, desc, eq } from "drizzle-orm"; +import { db } from "../../db/client.js"; +import { growEvents, growHomeNotifications, type GrowEventRow } from "../../db/schema.js"; +import { asRecord, getString } from "../../events/envelope.js"; +import { recordGrowEvent } from "../../events/record-grow-event.js"; +import { log } from "../../log.js"; +import { config } from "../../config.js"; +import { buildCuratorSprint, todayIsoDate } from "./curator-store.js"; +import { emitCuratorEvent } from "./curator-events.js"; +import type { CuratorSprintResponse } from "./curator-types.js"; + +const CURATOR_SOURCE = "curator-v1"; +const ONBOARDING_READY_EVENT = "curator.onboarding_plan.ready"; +const ONBOARDING_SKIPPED_EVENT = "curator.onboarding_plan.skipped"; + +type OnboardingLoopInput = { + userId: string; + completedAt?: string | Date | null; + sourceEventId?: string; + source?: string; + context?: Record; +}; + +type OnboardingLoopResult = + | { status: "ready"; sprint: CuratorSprintResponse; eventId: string } + | { status: "already_ready"; readyEventId: string; sprint?: CuratorSprintResponse } + | { status: "skipped"; reason: string }; + +function isoDateFrom(value: string | Date | null | undefined) { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? todayIsoDate() : value.toISOString().slice(0, 10); + } + if (typeof value === "string" && value.trim()) { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? todayIsoDate() : parsed.toISOString().slice(0, 10); + } + return todayIsoDate(); +} + +function parseCompletedAt(value: unknown): string | undefined { + const raw = getString(value); + if (!raw) return undefined; + const parsed = new Date(raw); + return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString(); +} + +export function onboardingCompletedAtFromPreferences(preferences: Record | undefined) { + const onboarding = asRecord(preferences?.onboarding); + return parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt); +} + +export function onboardingCompletedAtFromEvent(event: Pick) { + const payload = asRecord(event.payload); + const preferences = asRecord(payload.preferences); + const onboarding = asRecord(payload.onboarding); + return ( + parseCompletedAt(payload.completedAt ?? payload.completed_at) ?? + onboardingCompletedAtFromPreferences(preferences) ?? + parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt) ?? + (isOnboardingCompletionEvent(event) ? event.occurredAt.toISOString() : undefined) + ); +} + +export function isOnboardingCompletionEvent(event: Pick) { + const normalizedType = event.type.toLowerCase().replaceAll("_", "."); + if ( + normalizedType === "onboarding.completed" || + normalizedType === "user.onboarding.completed" || + normalizedType === "profile.onboarding.completed" + ) { + return true; + } + + const payload = asRecord(event.payload); + const preferences = asRecord(payload.preferences); + const onboarding = asRecord(payload.onboarding); + return Boolean( + onboardingCompletedAtFromPreferences(preferences) ?? + parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt) ?? + parseCompletedAt(payload.onboarding_completed_at ?? payload.onboardingCompletedAt), + ); +} + +async function findExistingReadyEvent(userId: string) { + const [existing] = await db + .select({ id: growEvents.id, payload: growEvents.payload }) + .from(growEvents) + .where(and( + eq(growEvents.userId, userId), + eq(growEvents.source, CURATOR_SOURCE), + eq(growEvents.type, ONBOARDING_READY_EVENT), + )) + .orderBy(desc(growEvents.occurredAt)) + .limit(1); + return existing; +} + +async function recordOnboardingContextSnapshot(input: { + userId: string; + startDate: string; + completedAt?: string | Date | null; + source?: string; + sourceEventId?: string; + context?: Record; +}) { + if (!input.context || !Object.keys(input.context).length) return; + await recordGrowEvent({ + source: input.source ?? "onboarding", + type: "onboarding.completed", + category: "usage", + userId: input.userId, + occurredAt: input.completedAt instanceof Date + ? input.completedAt.toISOString() + : typeof input.completedAt === "string" && input.completedAt.trim() + ? input.completedAt + : new Date().toISOString(), + correlation: { sourceEventId: input.sourceEventId }, + payload: { + completedAt: input.completedAt instanceof Date ? input.completedAt.toISOString() : input.completedAt, + ...input.context, + }, + dedupeKey: `curator:onboarding-context:${input.userId}:${input.startDate}`, + }, { userId: input.userId, source: input.source ?? "onboarding" }); +} + +async function fetchUserServiceContext(userId: string): Promise | undefined> { + const token = config.serviceToken || (config.nodeEnv !== "production" ? config.a2aAllowedKey : ""); + if (!token) return undefined; + + const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, "")); + const res = await fetch(target, { + method: "GET", + headers: { + authorization: `Bearer ${token}`, + "x-growqr-user": userId, + }, + }).catch((err) => { + log.warn({ err, userId }, "curator onboarding could not fetch user-service profile"); + return null; + }); + if (!res?.ok) return undefined; + + const profile = await res.json().catch(() => null) as Record | null; + if (!profile) return undefined; + const preferences = asRecord(profile.preferences); + return { profile, preferences }; +} + +function dayOneSubtitle(sprint: CuratorSprintResponse) { + const task = sprint.plan.days[0]?.tasks[0] ?? sprint.todayTasks[0]; + if (!task) return "Your personalized Day 1 tasks are ready on the home dashboard."; + return `Day 1 starts with ${task.title.toLowerCase()}.`; +} + +async function upsertPlanReadyNotification(userId: string, sprint: CuratorSprintResponse) { + const notificationId = `curator:onboarding-plan-ready:${userId}`; + const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 14); + await db + .insert(growHomeNotifications) + .values({ + id: notificationId, + userId, + moduleId: "missions", + title: "Your 30-day streak plan is ready", + subtitle: dayOneSubtitle(sprint), + tag: "Day 1 ready", + urgency: "today", + href: "/missions/active", + source: "system", + sourceRef: { + sprintId: sprint.sprintId, + planId: sprint.plan.id, + activeDayIndex: sprint.activeDayIndex, + source: CURATOR_SOURCE, + }, + priority: 95, + generatedBy: "manual", + reason: "Created by the curator onboarding loop after onboarding completion.", + status: "active", + expiresAt, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: growHomeNotifications.id, + set: { + subtitle: dayOneSubtitle(sprint), + sourceRef: { + sprintId: sprint.sprintId, + planId: sprint.plan.id, + activeDayIndex: sprint.activeDayIndex, + source: CURATOR_SOURCE, + }, + status: "active", + expiresAt, + updatedAt: new Date(), + }, + }); +} + +function weeklyThemes(sprint: CuratorSprintResponse) { + return sprint.plan.weeks.map((week) => ({ + weekIndex: week.weekIndex, + theme: week.theme, + summary: week.summary, + startDayIndex: week.startDayIndex, + endDayIndex: week.endDayIndex, + })); +} + +function dayOneTasks(sprint: CuratorSprintResponse) { + return (sprint.plan.days[0]?.tasks ?? sprint.todayTasks).map((task) => ({ + id: task.id, + title: task.title, + serviceId: task.serviceId, + route: task.route, + cta: task.cta, + rewardCoins: task.rewardCoins, + })); +} + +export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Promise { + const userId = input.userId.trim(); + if (!userId) return { status: "skipped", reason: "missing_user_id" }; + + const existing = await findExistingReadyEvent(userId); + if (existing) { + return { status: "already_ready", readyEventId: existing.id }; + } + + const startDate = isoDateFrom(input.completedAt); + const context = input.context ?? await fetchUserServiceContext(userId); + await recordOnboardingContextSnapshot({ + userId, + startDate, + completedAt: input.completedAt, + source: input.source, + sourceEventId: input.sourceEventId, + context, + }); + const sprint = await buildCuratorSprint(userId, startDate); + await upsertPlanReadyNotification(userId, sprint); + + const event = await emitCuratorEvent({ + userId, + type: ONBOARDING_READY_EVENT, + payload: { + source: input.source ?? "onboarding", + sourceEventId: input.sourceEventId, + completedAt: input.completedAt instanceof Date ? input.completedAt.toISOString() : input.completedAt, + startDate: sprint.plan.startDate, + endDate: sprint.plan.endDate, + sprintId: sprint.sprintId, + planId: sprint.plan.id, + durationDays: sprint.plan.durationDays, + weekCount: sprint.plan.weeks.length, + dayCount: sprint.plan.days.length, + activeDayIndex: sprint.activeDayIndex, + weeklyThemes: weeklyThemes(sprint), + dayOneTasks: dayOneTasks(sprint), + notificationId: `curator:onboarding-plan-ready:${userId}`, + }, + }); + + return { status: "ready", sprint, eventId: event.id }; +} + +export async function runCuratorOnboardingLoopForEvent(event: GrowEventRow): Promise { + if (!event.userId) return { status: "skipped", reason: "missing_user_id" }; + if (!isOnboardingCompletionEvent(event)) return { status: "skipped", reason: "not_onboarding_completion" }; + return runCuratorOnboardingLoop({ + userId: event.userId, + completedAt: onboardingCompletedAtFromEvent(event), + sourceEventId: event.id, + source: event.source, + }); +} + +export async function runCuratorOnboardingLoopSafely(input: OnboardingLoopInput): Promise { + try { + return await runCuratorOnboardingLoop(input); + } catch (err) { + log.error({ err, userId: input.userId }, "curator onboarding loop failed"); + await emitCuratorEvent({ + userId: input.userId, + type: ONBOARDING_SKIPPED_EVENT, + payload: { + reason: "loop_failed", + message: err instanceof Error ? err.message : String(err), + sourceEventId: input.sourceEventId, + }, + }).catch((emitErr) => log.warn({ emitErr, userId: input.userId }, "failed to emit curator onboarding failure event")); + return { status: "skipped", reason: "loop_failed" }; + } +} + +export async function runCuratorOnboardingLoopForEventSafely(event: GrowEventRow): Promise { + try { + return await runCuratorOnboardingLoopForEvent(event); + } catch (err) { + log.error({ err, eventId: event.id, userId: event.userId }, "curator onboarding event loop failed"); + if (event.userId) { + await emitCuratorEvent({ + userId: event.userId, + type: ONBOARDING_SKIPPED_EVENT, + payload: { + reason: "event_loop_failed", + message: err instanceof Error ? err.message : String(err), + sourceEventId: event.id, + }, + }).catch((emitErr) => log.warn({ emitErr, userId: event.userId }, "failed to emit curator onboarding event failure")); + } + return { status: "skipped", reason: "loop_failed" }; + } +} diff --git a/src/v1/curator/curator-prompt-builder.ts b/src/v1/curator/curator-prompt-builder.ts new file mode 100644 index 0000000..e9a8e70 --- /dev/null +++ b/src/v1/curator/curator-prompt-builder.ts @@ -0,0 +1,101 @@ +import { createHash } from "node:crypto"; +import type { CuratorIcpPlaybook } from "./curator-icp-playbooks.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) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => [key, sortKeys(item)]), + ); +} diff --git a/src/v1/curator/curator-routes.ts b/src/v1/curator/curator-routes.ts index 1da8779..773813f 100644 --- a/src/v1/curator/curator-routes.ts +++ b/src/v1/curator/curator-routes.ts @@ -12,6 +12,13 @@ const chatSchema = z.object({ 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"]).optional(), + goals: z.array(z.string()).optional(), + userContext: z.record(z.unknown()).optional(), +}); + export function v1CuratorRoutes() { const app = new Hono(); app.use("*", requireUser); @@ -46,6 +53,20 @@ export function v1CuratorRoutes() { return c.json(await curatorActor.getSprint({ userId, date: c.req.query("date") })); }); + app.post("/curation/preview", async (c) => { + const userId = c.get("userId"); + const body = curationPreviewSchema.parse(await c.req.json().catch(() => ({}))); + return c.json(await curatorActor.previewCuration({ userId, ...body })); + }); + + app.post("/onboarding/run", async (c) => { + const userId = c.get("userId"); + const body = z.object({ + completedAt: z.string().optional(), + }).parse(await c.req.json().catch(() => ({}))); + return c.json(await curatorActor.runOnboardingLoop({ userId, ...body })); + }); + app.post("/chat", async (c) => { const userId = c.get("userId"); const body = chatSchema.parse(await c.req.json()); diff --git a/src/v1/curator/curator-service-links.ts b/src/v1/curator/curator-service-links.ts index a70f2e6..3226a0a 100644 --- a/src/v1/curator/curator-service-links.ts +++ b/src/v1/curator/curator-service-links.ts @@ -25,6 +25,17 @@ export function serviceRoute(input: ServiceRouteInput) { return buildCuratorServiceRoute(input); } +export function buildCuratorTaskDeepLink(task: Pick, targetRole?: string) { + return buildCuratorServiceRoute({ + serviceId: task.serviceId, + missionId: task.missionId, + missionInstanceId: task.missionInstanceId, + stageId: task.stageId, + taskId: task.id, + targetRole, + }); +} + export function serviceName(serviceId?: CuratorServiceId, fallback = "Mission planner") { return getServiceDisplayName(serviceId, fallback); } diff --git a/src/v1/curator/curator-store.ts b/src/v1/curator/curator-store.ts index e4adf33..e790942 100644 --- a/src/v1/curator/curator-store.ts +++ b/src/v1/curator/curator-store.ts @@ -4,6 +4,9 @@ import { growEvents, growQscoreProjectionState } from "../../db/schema.js"; import { listMissionDefinitions } from "../../missions/registry.js"; import { listServiceCapabilities } from "../../workflows/service-capabilities.js"; import { emitCuratorEvent } from "./curator-events.js"; +import { buildCuratorUserContext } from "./curator-user-context.js"; +import { buildCuratorPlanPrompt, stableHash } from "./curator-prompt-builder.js"; +import { curatorPlaybookFor, isCuratorIcpId, type CuratorIcpId } from "./curator-icp-playbooks.js"; import type { CuratorPlan, CuratorPlanDay, @@ -15,7 +18,7 @@ import type { CuratorTaskType, CuratorWeek, } from "./curator-types.js"; -import { completionEventsForService, serviceName, serviceRoute, serviceToolName } from "./curator-service-links.js"; +import { buildCuratorTaskDeepLink, completionEventsForService, serviceName, serviceToolName } from "./curator-service-links.js"; const VALID_COMPLETION_TYPES = [ "resume.analysis_completed", @@ -33,10 +36,10 @@ const VALID_COMPLETION_TYPES = [ ] as const; const CURATOR_SOURCE = "curator-v1"; -const CURATOR_PLAN_VERSION = "icp-v4"; +const CURATOR_PLAN_VERSION = "icp-v6"; const SPRINT_DURATION_DAYS = 30; const DAYS_PER_WEEK = 7; -const PLAN_WEEK_COUNT = 5; +const MAX_PLAN_WEEK_COUNT = 6; type TaskSeed = { taskType: CuratorTaskType; @@ -59,12 +62,6 @@ type WeekTemplate = { }>; }; -type CuratorIcpId = - | "student_recent_grad" - | "intern" - | "fresher_early_professional" - | "experienced_professional"; - type CuratorIcpTemplateSet = { id: CuratorIcpId; label: string; @@ -96,6 +93,14 @@ type SprintState = { planDays: PlanDaySeed[]; }; +type ServiceCurationPreviewInput = { + userId: string; + startDate?: string; + icpId?: CuratorIcpId; + goals?: string[]; + userContext?: Partial>>; +}; + const FRESHER_WEEK_TEMPLATES: WeekTemplate[] = [ { theme: "Baseline + First Proof", @@ -837,17 +842,32 @@ function taskIdFor(startDate: string, dayIndex: number, taskType: CuratorTaskTyp } function stageIdFor(dayIndex: number, weekIndex: number, taskType: CuratorTaskType) { - return `wk-${weekIndex}:day-${dayIndexInWeek(dayIndex)}:${taskType}`; + return `wk-${weekIndex}:day-${dayIndex}:${taskType}`; } -function weekForDayIndex(dayIndex: number) { - if (dayIndex > DAYS_PER_WEEK * 4) return PLAN_WEEK_COUNT; - return Math.ceil(dayIndex / DAYS_PER_WEEK); +function dateFromIso(value: string) { + return new Date(`${value}T00:00:00.000Z`); } -function dayIndexInWeek(dayIndex: number) { - if (dayIndex > DAYS_PER_WEEK * 4) return dayIndex - DAYS_PER_WEEK * 4; - return ((dayIndex - 1) % DAYS_PER_WEEK) + 1; +function sundayWeekStartIso(value: string) { + const date = dateFromIso(value); + date.setUTCDate(date.getUTCDate() - date.getUTCDay()); + return todayIso(date); +} + +function weekForDayIndex(startDate: string, dayIndex: number) { + const weekZero = sundayWeekStartIso(startDate); + const date = addDaysIso(startDate, dayIndex - 1); + return Math.floor(calendarDiffDays(weekZero, date) / DAYS_PER_WEEK) + 1; +} + +function dayIndexInWeek(startDate: string, dayIndex: number) { + const date = dateFromIso(addDaysIso(startDate, dayIndex - 1)); + return date.getUTCDay() + 1; +} + +function planWeekCount(startDate: string) { + return weekForDayIndex(startDate, SPRINT_DURATION_DAYS); } function planFocus(weekTheme: string, seedTask: TaskSeed) { @@ -997,14 +1017,18 @@ function fallbackCarryForwardWeek(templateSet: CuratorIcpTemplateSet): WeekTempl }; } -function planSeedsForVariant(templateSet: CuratorIcpTemplateSet): PlanDaySeed[] { - const weekTemplates = [...templateSet.weeks, fallbackCarryForwardWeek(templateSet)]; +function planSeedsForVariant(templateSet: CuratorIcpTemplateSet, startDate: string): PlanDaySeed[] { + const carryForwardWeek = fallbackCarryForwardWeek(templateSet); + const weekTemplates = [ + ...templateSet.weeks, + ...Array.from({ length: MAX_PLAN_WEEK_COUNT - templateSet.weeks.length }, () => carryForwardWeek), + ]; const planDays: PlanDaySeed[] = []; for (let index = 0; index < SPRINT_DURATION_DAYS; index += 1) { const dayIndex = index + 1; - const weekIndex = weekForDayIndex(dayIndex); - const dayOfWeek = dayIndexInWeek(dayIndex); + const weekIndex = weekForDayIndex(startDate, dayIndex); + const dayOfWeek = dayIndexInWeek(startDate, dayIndex); const weekTemplate = weekTemplates[weekIndex - 1] ?? weekTemplates[0]!; const dayTemplate = weekTemplate.days[dayOfWeek - 1] ?? weekTemplate.days[0]!; planDays.push({ @@ -1128,20 +1152,29 @@ async function loadSprintState(userId: string, todayDate = todayIso()): Promise< existingStartDate && existingVersion === CURATOR_PLAN_VERSION && existingVariant && - existingVariant in ICP_TEMPLATE_SETS && + isCuratorIcpId(existingVariant) && calendarDiffDays(existingStartDate, todayDate) < SPRINT_DURATION_DAYS ) { const templateSet = templateSetFor(existingVariant as CuratorIcpId); return { startDate: existingStartDate, variantId: existingVariant as CuratorIcpId, - planDays: storedPlanDaysFromPayload(latest[0]?.payload)?.map(clonePlanDay) ?? planSeedsForVariant(templateSet), + planDays: storedPlanDaysFromPayload(latest[0]?.payload)?.map(clonePlanDay) ?? planSeedsForVariant(templateSet, existingStartDate), }; } const variantId = await inferIcpVariant(userId); const templateSet = templateSetFor(variantId); - const planDays = planSeedsForVariant(templateSet); + const planDays = planSeedsForVariant(templateSet, todayDate); + const userContext = await buildCuratorUserContext(userId); + const icpPlaybook = curatorPlaybookFor(variantId); + const promptAssembly = buildCuratorPlanPrompt({ + startDate: todayDate, + durationDays: SPRINT_DURATION_DAYS, + userContext, + playbook: icpPlaybook, + goals: [templateSet.sprintTheme, templateSet.goal], + }); await emitCuratorEvent({ userId, type: "curator.sprint.started", @@ -1154,6 +1187,14 @@ async function loadSprintState(userId: string, todayDate = todayIso()): Promise< icpLabel: templateSet.label, sprintTheme: templateSet.sprintTheme, goals: [templateSet.sprintTheme, templateSet.goal], + userContext, + icpPlaybook, + prompt: { + version: promptAssembly.version, + hash: promptAssembly.hash, + text: promptAssembly.prompt, + }, + planHash: stableHash({ version: CURATOR_PLAN_VERSION, variantId, promptHash: promptAssembly.hash, planDays }), planDays, }, }); @@ -1316,8 +1357,9 @@ function buildTask( weekSummary: string, seedTask: TaskSeed, completionRows: Awaited>, + targetRole?: string, ): CuratorTask { - const dayOfWeek = dayIndexInWeek(dayIndex); + const dayOfWeek = dayIndexInWeek(sprintStartDate, dayIndex); const id = taskIdFor(sprintStartDate, dayIndex, seedTask.taskType); const missionInstanceId = sprintIdFor(sprintStartDate); const stageId = stageIdFor(dayIndex, weekIndex, seedTask.taskType); @@ -1341,18 +1383,13 @@ function buildTask( rewardCoins: seedTask.taskType === "measurement" ? 12 : seedTask.taskType === "proof" ? 15 : 18, qxImpact: seedTask.qxImpact, effort: seedTask.effort, - route: serviceRoute({ - serviceId: seedTask.serviceId, - missionId: "curator-sprint", - missionInstanceId, - stageId, - taskId: id, - }), + route: "", cta: seedTask.cta, context: [ { 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}` }, ], contextNarrative: contextNarrative(seedTask, weekTheme, weekSummary), @@ -1361,10 +1398,12 @@ function buildTask( completionEvents: completionEventsForService(seedTask.serviceId), source: "service-registry", }; - return { + const taskWithRoute = { ...task, - status: taskCompletedByEvents(task, completionRows) ? "completed" : "ready", - }; + route: buildCuratorTaskDeepLink(task, targetRole), + status: taskCompletedByEvents(task, completionRows) ? "completed" as const : "ready" as const, + } satisfies CuratorTask; + return taskWithRoute; } function buildTasksForPlanDay( @@ -1372,6 +1411,7 @@ function buildTasksForPlanDay( date: string, planDay: PlanDaySeed, completionRows: Awaited>, + targetRole?: string, ) { return planDay.plannedTasks.map((task) => ( buildTask( @@ -1383,6 +1423,7 @@ function buildTasksForPlanDay( planDay.weekSummary, task, completionRows, + targetRole, ) )); } @@ -1394,6 +1435,7 @@ export async function buildCuratorTasks(userId: string, date = todayIso()): Prom const completionRows = await loadCompletionRows(userId, sprintStartDate); const recentRows = await loadRecentContextRows(userId, sprintStartDate); const currentQScore = await loadCurrentQScore(userId); + const userContext = await buildCuratorUserContext(userId); const adaptedPlanDays = adaptCurrentDayPlan( sprintStartDate, dayIndex, @@ -1404,7 +1446,7 @@ export async function buildCuratorTasks(userId: string, date = todayIso()): Prom ); const planDay = adaptedPlanDays[dayIndex - 1]; if (!planDay) return []; - return buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows); + return buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, userContext.targetRole); } export async function buildCuratorStreak(userId: string): Promise { @@ -1447,6 +1489,7 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso() const completionRows = await loadCompletionRows(userId, sprintStartDate); 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 = adaptCurrentDayPlan( sprintStartDate, @@ -1462,7 +1505,7 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso() const dayIndex = index + 1; const date = addDaysIso(sprintStartDate, index); const planDay = planDays[index]!; - const tasks = date <= focusDate ? buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows) : []; + const tasks = date <= focusDate ? buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, userContext.targetRole) : []; const completedCount = tasks.filter((task) => task.status === "completed").length; const unlockState = focusDate > date ? "completed" : focusDate === date ? "active" : "upcoming"; days.push({ @@ -1483,7 +1526,7 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso() }); } - const weeks: CuratorWeek[] = Array.from({ length: PLAN_WEEK_COUNT }, (_, index) => { + const weeks: CuratorWeek[] = Array.from({ length: planWeekCount(sprintStartDate) }, (_, 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); @@ -1510,7 +1553,7 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso() }; }); - const activeWeekIndex = weekForDayIndex(activeDayIndex); + const activeWeekIndex = weekForDayIndex(sprintStartDate, activeDayIndex); const activeDay = (days[activeDayIndex - 1] ?? days[0])!; const activeWeek = (weeks[activeWeekIndex - 1] ?? weeks[0])!; const plan: CuratorPlan = { @@ -1556,6 +1599,69 @@ export async function buildCuratorSprint(userId: string, date = todayIso()): Pro return buildCuratorSprintInternal(userId, date); } +export async function buildServiceCurationPreview(input: ServiceCurationPreviewInput) { + const startDate = input.startDate ?? todayIso(); + const inferredIcpId = input.icpId ?? await inferIcpVariant(input.userId); + const templateSet = templateSetFor(inferredIcpId); + const userContext = { + ...await buildCuratorUserContext(input.userId), + ...input.userContext, + }; + const icpPlaybook = curatorPlaybookFor(inferredIcpId); + const promptAssembly = buildCuratorPlanPrompt({ + startDate, + durationDays: SPRINT_DURATION_DAYS, + userContext, + playbook: icpPlaybook, + goals: input.goals ?? [templateSet.sprintTheme, templateSet.goal], + }); + const planDays = planSeedsForVariant(templateSet, startDate); + 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, userContext.targetRole), + }; + }); + + return { + source: CURATOR_SOURCE, + version: CURATOR_PLAN_VERSION, + idempotency: { + promptHash: promptAssembly.hash, + planHash: stableHash({ version: CURATOR_PLAN_VERSION, icpId: inferredIcpId, promptHash: promptAssembly.hash, planDays }), + }, + userContext, + icpPlaybook, + prompt: { + version: promptAssembly.version, + hash: promptAssembly.hash, + text: promptAssembly.prompt, + }, + plan: { + startDate, + endDate: addDaysIso(startDate, SPRINT_DURATION_DAYS - 1), + durationDays: SPRINT_DURATION_DAYS, + goals: input.goals ?? [templateSet.sprintTheme, templateSet.goal], + calendarWeeks: Array.from({ length: planWeekCount(startDate) }, (_, index) => { + const weekIndex = index + 1; + const weekDays = days.filter((day) => day.weekIndex === weekIndex); + return { + weekIndex, + theme: planDays.find((day) => day.weekIndex === weekIndex)?.weekTheme ?? `Week ${weekIndex}`, + startDate: weekDays[0]?.date, + endDate: weekDays[weekDays.length - 1]?.date, + days: weekDays, + }; + }), + closeoutDays: days.slice(-2), + days, + }, + }; +} + export async function listCuratorRegistryCapabilities() { return { missions: listMissionDefinitions().map((mission) => ({ diff --git a/src/v1/curator/curator-types.ts b/src/v1/curator/curator-types.ts index 58ebcbe..91b4e9e 100644 --- a/src/v1/curator/curator-types.ts +++ b/src/v1/curator/curator-types.ts @@ -30,7 +30,7 @@ export const curatorTaskSchema = z.object({ date: z.string(), dayIndex: z.number().int().min(1).max(30), dayIndexInWeek: z.number().int().min(1).max(7), - weekIndex: z.number().int().min(1).max(5), + weekIndex: z.number().int().min(1).max(6), taskType: curatorTaskTypeSchema, title: z.string(), subtitle: z.string(), @@ -65,7 +65,7 @@ export const curatorPlanDaySchema = z.object({ date: z.string(), dayIndex: z.number().int().min(1).max(30), dayIndexInWeek: z.number().int().min(1).max(7), - weekIndex: z.number().int().min(1).max(5), + weekIndex: z.number().int().min(1).max(6), weekTheme: z.string(), weekSummary: z.string(), focus: z.string().optional(), @@ -79,7 +79,7 @@ export const curatorPlanDaySchema = z.object({ }); export const curatorWeekSchema = z.object({ - weekIndex: z.number().int().min(1).max(5), + weekIndex: z.number().int().min(1).max(6), title: z.string(), theme: z.string(), summary: z.string(), @@ -90,7 +90,7 @@ export const curatorWeekSchema = z.object({ completedTaskCount: z.number().int().min(0), totalTaskCount: z.number().int().min(0), completionPercent: z.number().min(0).max(100), - days: z.array(curatorPlanDaySchema).min(2).max(7), + days: z.array(curatorPlanDaySchema).min(1).max(7), }); export const curatorPlanSchema = z.object({ @@ -101,7 +101,7 @@ export const curatorPlanSchema = z.object({ goals: z.array(z.string()), generatedAt: z.string(), durationDays: z.literal(30), - weeks: z.array(curatorWeekSchema).length(5), + weeks: z.array(curatorWeekSchema).min(5).max(6), days: z.array(curatorPlanDaySchema).length(30), streak: curatorStreakSchema, source: z.literal("curator-v1"), @@ -125,7 +125,7 @@ export const curatorSprintResponseSchema = z.object({ sprintId: z.string(), plan: curatorPlanSchema, activeWeek: curatorWeekSchema, - activeWeekIndex: z.number().int().min(1).max(5), + activeWeekIndex: z.number().int().min(1).max(6), activeDay: curatorPlanDaySchema, activeDayIndex: z.number().int().min(1).max(30), todayTasks: z.array(curatorTaskSchema).length(3), diff --git a/src/v1/curator/curator-user-context.ts b/src/v1/curator/curator-user-context.ts index 83fbf07..e74d2bb 100644 --- a/src/v1/curator/curator-user-context.ts +++ b/src/v1/curator/curator-user-context.ts @@ -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, growQscoreProjectionState } from "../../db/schema.js"; import { asRecord, getString } from "../../events/envelope.js"; import type { CuratorTask } from "./curator-types.js"; @@ -12,6 +12,29 @@ function stringArray(value: unknown): string[] { : []; } +export type CuratorUserContext = { + userId: string; + targetRole: string; + experienceLevel: "student" | "intern" | "early" | "experienced" | "unknown"; + resume: { + available: boolean; + latestSummary?: string; + skills: string[]; + }; + goals: string[]; + pastActivity: { + recentEventCount: number; + serviceSources: string[]; + latestEvents: Array<{ type: string; source: string; occurredAt: string; summary?: string }>; + }; + qscore: { + score: number | null; + signalCount: number; + summary: string | null; + dimensions: Record | null; + }; +}; + function firstRoleFromValue(value: unknown): string | undefined { const direct = getString(value); if (direct) return direct; @@ -130,3 +153,103 @@ export async function resolveCuratorTargetRole(input: { export function fallbackCuratorRole(role?: string) { return role?.trim() || "Product Manager"; } + +export async function buildCuratorUserContext(userId: string): Promise { + const rows = await db + .select({ type: growEvents.type, source: growEvents.source, payload: growEvents.payload, occurredAt: growEvents.occurredAt }) + .from(growEvents) + .where(eq(growEvents.userId, userId)) + .orderBy(desc(growEvents.occurredAt)) + .limit(80); + + const [qscore] = await db + .select({ + score: growQscoreProjectionState.score, + signalCount: growQscoreProjectionState.signalCount, + summary: growQscoreProjectionState.summary, + dimensions: growQscoreProjectionState.dimensions, + }) + .from(growQscoreProjectionState) + .where(eq(growQscoreProjectionState.userId, userId)) + .limit(1); + + const targetRole = fallbackCuratorRole(await resolveCuratorTargetRole({ userId })); + const corpus = rows.map((row) => `${row.type} ${row.source} ${payloadText(row.payload)}`).join(" ").toLowerCase(); + const goals = uniqueStrings(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)) + .find(Boolean); + + return { + userId, + targetRole, + experienceLevel: inferExperienceLevel(corpus), + resume: { + available: Boolean(latestResume || /\bresume|cv|linkedin\b/i.test(corpus)), + latestSummary: latestResume, + skills, + }, + goals: goals.length ? goals.slice(0, 8) : [targetRole], + pastActivity: { + recentEventCount: rows.length, + serviceSources: uniqueStrings(rows.map((row) => row.source)).slice(0, 12), + latestEvents: rows.slice(0, 12).map((row) => ({ + type: row.type, + source: row.source, + occurredAt: row.occurredAt.toISOString(), + summary: eventSummary(row.payload), + })), + }, + qscore: { + score: typeof qscore?.score === "number" ? qscore.score : null, + signalCount: typeof qscore?.signalCount === "number" ? qscore.signalCount : 0, + summary: qscore?.summary ?? null, + dimensions: qscore?.dimensions ?? null, + }, + }; +} + +function goalsFromPayload(payload: Record) { + const preferences = asRecord(payload.preferences); + return [ + ...stringArray(preferences.target_roles ?? preferences.targetRoles), + ...stringArray(payload.goals), + getString(payload.goal), + getString(payload.userGoal), + getString(payload.target_role ?? payload.targetRole), + ].filter((item): item is string => Boolean(item)); +} + +function resumeSummaryFromPayload(payload: Record) { + return getString( + payload.resumeSummary ?? + payload.summary ?? + payload.resume_text ?? + payload.resumeText ?? + asRecord(payload.resume).summary, + )?.slice(0, 900); +} + +function eventSummary(payload: Record) { + return getString(payload.summary ?? payload.title ?? payload.goal ?? payload.serviceIntent)?.slice(0, 220); +} + +function payloadText(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) return value.map(payloadText).join(" "); + if (value && typeof value === "object") return Object.values(value as Record).map(payloadText).join(" "); + return ""; +} + +function inferExperienceLevel(corpus: string): CuratorUserContext["experienceLevel"] { + if (/\b(intern|internship|return offer|return-offer)\b/.test(corpus)) return "intern"; + if (/\b(student|recent grad|recent graduate|campus|college|university)\b/.test(corpus)) return "student"; + if (/\b(staff|principal|director|vp|head of|leadership|executive|10\+ years|5\+ years)\b/.test(corpus)) return "experienced"; + if (/\b(fresher|junior|entry level|entry-level|early career|0-2 years|1 year|2 years)\b/.test(corpus)) return "early"; + return "unknown"; +} + +function uniqueStrings(values: Array) { + return [...new Set(values.map((value) => value?.trim()).filter((value): value is string => Boolean(value)))]; +} -- 2.49.1