Backend: Add Curator 30-Day Streak Curation and Onboarding Loop #8
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
103
src/v1/curator/curator-icp-playbooks.ts
Normal file
103
src/v1/curator/curator-icp-playbooks.ts
Normal 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 };
|
||||
}
|
||||
314
src/v1/curator/curator-onboarding-loop.ts
Normal file
314
src/v1/curator/curator-onboarding-loop.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
101
src/v1/curator/curator-prompt-builder.ts
Normal file
101
src/v1/curator/curator-prompt-builder.ts
Normal 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)]),
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)))];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user