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