Backend: Add Curator 30-Day Streak Curation and Onboarding Loop #8

Merged
dv merged 1 commits from backend/service-curation-layer into staging 2026-06-23 18:48:20 +00:00
13 changed files with 903 additions and 56 deletions

View File

@@ -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;
}

View File

@@ -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) => {

View File

@@ -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<string, unknown>;
const preferences = userProfile.preferences;
const normalizedPreferences = preferences && typeof preferences === "object" && !Array.isArray(preferences)
? (preferences as Record<string, unknown>)
: undefined;
await ensureOnboardingBaselineQscore(
c.get("userId"),
preferences && typeof preferences === "object" && !Array.isArray(preferences)
? (preferences as Record<string, unknown>)
: 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");
}
}

View File

@@ -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.

View File

@@ -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<string, unknown> }) {
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));

View File

@@ -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<CuratorIcpId, CuratorIcpPlaybook> = {
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 };
}

View File

@@ -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<string, unknown>;
};
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<string, unknown> | undefined) {
const onboarding = asRecord(preferences?.onboarding);
return parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt);
}
export function onboardingCompletedAtFromEvent(event: Pick<GrowEventRow, "type" | "payload" | "occurredAt">) {
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<GrowEventRow, "type" | "payload" | "occurredAt">) {
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<string, unknown>;
}) {
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<Record<string, unknown> | 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<string, unknown> | 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<OnboardingLoopResult> {
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<OnboardingLoopResult> {
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<OnboardingLoopResult> {
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<OnboardingLoopResult> {
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" };
}
}

View File

@@ -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<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => [key, sortKeys(item)]),
);
}

View File

@@ -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<AuthContext>();
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());

View File

@@ -25,6 +25,17 @@ export function serviceRoute(input: ServiceRouteInput) {
return buildCuratorServiceRoute(input);
}
export function buildCuratorTaskDeepLink(task: Pick<CuratorTask, "serviceId" | "missionId" | "missionInstanceId" | "stageId" | "id">, 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);
}

View File

@@ -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<Awaited<ReturnType<typeof buildCuratorUserContext>>>;
};
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<ReturnType<typeof loadCompletionRows>>,
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<ReturnType<typeof loadCompletionRows>>,
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<CuratorStreak> {
@@ -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) => ({

View File

@@ -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),

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, 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<string, unknown> | 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<CuratorUserContext> {
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<string, unknown>) {
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<string, unknown>) {
return getString(
payload.resumeSummary ??
payload.summary ??
payload.resume_text ??
payload.resumeText ??
asRecord(payload.resume).summary,
)?.slice(0, 900);
}
function eventSummary(payload: Record<string, unknown>) {
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<string, unknown>).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<string | undefined>) {
return [...new Set(values.map((value) => value?.trim()).filter((value): value is string => Boolean(value)))];
}