From 2de70d3b8c8a711466a6b98fa888edf3e4ca3699 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Tue, 23 Jun 2026 20:38:33 +0000 Subject: [PATCH 1/4] Solve PRM-71 curator backend QA loop --- src/v1/analytics/analytics-routes.ts | 109 ++++++++++++++++++++ src/v1/curator/curator-store.ts | 149 ++++++++++++++++++++++++--- src/v1/curator/curator-tools.ts | 11 +- src/v1/curator/curator-types.ts | 9 +- src/v1/events/events-routes.ts | 113 ++++++++++++++++++++ src/v1/index.ts | 4 + src/v1/qscore/qscore-routes.ts | 84 +++++++++++++++ 7 files changed, 460 insertions(+), 19 deletions(-) create mode 100644 src/v1/events/events-routes.ts create mode 100644 src/v1/qscore/qscore-routes.ts diff --git a/src/v1/analytics/analytics-routes.ts b/src/v1/analytics/analytics-routes.ts index 33db615..df4f22e 100644 --- a/src/v1/analytics/analytics-routes.ts +++ b/src/v1/analytics/analytics-routes.ts @@ -1,8 +1,25 @@ import { Hono } from "hono"; import { z } from "zod"; +import { and, desc, eq, gte, sql } from "drizzle-orm"; import { requireUser, type AuthContext } from "../../auth/clerk.js"; +import { db } from "../../db/client.js"; +import { growEvents, growQscoreLatest, growQscoreProjectionState } from "../../db/schema.js"; import { v1AnalyticsActor } from "./analytics-actor.js"; +function daysAgo(days: number) { + return new Date(Date.now() - days * 86400000); +} + +function sourceBucket(source: string) { + if (source.includes("interview")) return "interview"; + if (source.includes("roleplay")) return "roleplay"; + if (source.includes("resume")) return "resume"; + if (source.includes("qscore")) return "qscore"; + if (source.includes("curator")) return "curator"; + if (source.includes("match")) return "opportunities"; + return source || "unknown"; +} + export function v1AnalyticsRoutes() { const app = new Hono(); app.use("*", requireUser); @@ -19,6 +36,98 @@ export function v1AnalyticsRoutes() { return c.json(await v1AnalyticsActor.getUserActivity({ userId })); }); + app.get("/insight-snapshot", async (c) => { + const userId = c.get("userId"); + const [projection] = await db + .select() + .from(growQscoreProjectionState) + .where(eq(growQscoreProjectionState.userId, userId)) + .limit(1); + const latestSignals = await db + .select() + .from(growQscoreLatest) + .where(eq(growQscoreLatest.userId, userId)) + .orderBy(desc(growQscoreLatest.updatedAt)) + .limit(20); + const recentEvents = await db + .select() + .from(growEvents) + .where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14)))) + .orderBy(desc(growEvents.occurredAt)) + .limit(100); + const [counts] = await db + .select({ + total: sql`count(*)::int`, + completed: sql`count(*) filter (where ${growEvents.type} ilike '%completed%' or ${growEvents.type} ilike '%review_completed%')::int`, + opened: sql`count(*) filter (where ${growEvents.type} = 'task.opened' or ${growEvents.type} ilike '%started%')::int`, + }) + .from(growEvents) + .where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14)))); + + const serviceCounts = new Map(); + for (const event of recentEvents) { + const bucket = sourceBucket(event.source); + serviceCounts.set(bucket, (serviceCounts.get(bucket) ?? 0) + 1); + } + const score = projection?.score ?? null; + const strongestSignal = [...latestSignals].sort((a, b) => b.score - a.score)[0]; + const weakestSignal = [...latestSignals].sort((a, b) => a.score - b.score)[0]; + + return c.json({ + roleFit: { + score, + label: score === null ? "baseline_needed" : score >= 75 ? "strong" : score >= 55 ? "building" : "needs_focus", + strongestSignal: strongestSignal?.signalId ?? null, + weakestSignal: weakestSignal?.signalId ?? null, + }, + readinessTrend: { + signalCount: projection?.signalCount ?? latestSignals.length, + lastUpdatedAt: projection?.updatedAt?.toISOString() ?? latestSignals[0]?.updatedAt?.toISOString() ?? null, + summary: projection?.summary ?? "No projected readiness summary is available yet.", + }, + activity: { + totalEvents14d: counts?.total ?? 0, + completedEvents14d: counts?.completed ?? 0, + openedEvents14d: counts?.opened ?? 0, + services: Array.from(serviceCounts.entries()).map(([service, count]) => ({ service, count })), + }, + opportunities: { + events14d: recentEvents.filter((event) => sourceBucket(event.source) === "opportunities").length, + latestEventAt: recentEvents.find((event) => sourceBucket(event.source) === "opportunities")?.occurredAt.toISOString() ?? null, + }, + source: "grow_events", + }); + }); + + app.get("/activity-history", async (c) => { + const userId = c.get("userId"); + const limit = Math.min(200, Math.max(1, Number(c.req.query("limit") ?? 80))); + const since = c.req.query("since"); + const sinceDate = since ? new Date(since) : daysAgo(30); + const rows = await db + .select() + .from(growEvents) + .where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, Number.isNaN(sinceDate.getTime()) ? daysAgo(30) : sinceDate))) + .orderBy(desc(growEvents.occurredAt)) + .limit(limit); + return c.json({ + events: rows.map((event) => ({ + id: event.id, + source: event.source, + type: event.type, + category: event.category, + occurredAt: event.occurredAt.toISOString(), + processingStatus: event.processingStatus, + mission: event.mission, + subject: event.subject, + correlation: event.correlation, + payload: event.payload, + })), + count: rows.length, + source: "grow_events", + }); + }); + app.post("/nightly/run", async (c) => { const userId = c.get("userId"); const body = z.object({ diff --git a/src/v1/curator/curator-store.ts b/src/v1/curator/curator-store.ts index e790942..87924a1 100644 --- a/src/v1/curator/curator-store.ts +++ b/src/v1/curator/curator-store.ts @@ -21,6 +21,7 @@ import type { import { buildCuratorTaskDeepLink, completionEventsForService, serviceName, serviceToolName } from "./curator-service-links.js"; const VALID_COMPLETION_TYPES = [ + "service.completed", "resume.analysis_completed", "resume.parsed", "resume.updated", @@ -82,7 +83,7 @@ type PlanDaySeed = { weekTheme: string; weekSummary: string; focus: string; - plannedTasks: [PlannedTask, PlannedTask, PlannedTask]; + plannedTasks: PlannedTask[]; generationStatus: "seeded" | "generated" | "adapted"; adaptationReason?: string; }; @@ -873,6 +874,7 @@ function planWeekCount(startDate: string) { function planFocus(weekTheme: string, seedTask: TaskSeed) { if (seedTask.taskType === "measurement") return `Measure today against the ${weekTheme.toLowerCase()} theme.`; if (seedTask.taskType === "proof") return `Turn progress from ${weekTheme.toLowerCase()} into visible proof.`; + if (seedTask.taskType === "recovery") return `Recover momentum from the last incomplete day before adding more work.`; return `Practice one concrete move that advances ${weekTheme.toLowerCase()}.`; } @@ -1058,6 +1060,13 @@ function performanceLabel(percent: number): CuratorWeek["performance"] { } function subtaskCopy(seedTask: TaskSeed, weekTheme: string) { + if (seedTask.taskType === "recovery") { + return [ + "Review the missed or abandoned task trail", + "Choose the smallest useful recovery action", + `Save the next move for ${weekTheme.toLowerCase()}`, + ]; + } if (seedTask.taskType === "measurement") { return [ "Open the current score or readiness view", @@ -1080,6 +1089,13 @@ function subtaskCopy(seedTask: TaskSeed, weekTheme: string) { } function contextNarrative(seedTask: TaskSeed, weekTheme: string, weekSummary: string) { + if (seedTask.taskType === "recovery") { + return [ + `This recovery task appears because the previous curator day was not fully completed.`, + weekSummary, + "Use the linked service to convert the missed, partial, or abandoned work into a constructive next action.", + ].join(" "); + } return [ `This ${seedTask.taskType} task belongs to the ${weekTheme} week of the curator sprint.`, weekSummary, @@ -1123,7 +1139,7 @@ function templateSetFor(variantId: CuratorIcpId) { function clonePlanDay(day: PlanDaySeed): PlanDaySeed { return { ...day, - plannedTasks: day.plannedTasks.map((task) => ({ ...task })) as PlanDaySeed["plannedTasks"], + plannedTasks: day.plannedTasks.map((task) => ({ ...task })), }; } @@ -1203,7 +1219,13 @@ async function loadSprintState(userId: string, todayDate = todayIso()): Promise< async function loadRecentContextRows(userId: string, sinceDate: string) { return db - .select({ type: growEvents.type, source: growEvents.source, payload: growEvents.payload, occurredAt: growEvents.occurredAt }) + .select({ + type: growEvents.type, + source: growEvents.source, + payload: growEvents.payload, + correlation: growEvents.correlation, + occurredAt: growEvents.occurredAt, + }) .from(growEvents) .where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, new Date(`${sinceDate}T00:00:00.000Z`)))) .orderBy(desc(growEvents.occurredAt)) @@ -1235,8 +1257,18 @@ function taskCompletedByTaskId(taskId: string, completionEvents: string[], rows: return rows.some((row) => { const payload = row.payload ?? {}; const correlation = row.correlation ?? {}; - const eventTaskId = payload.taskId ?? correlation.taskId; - return eventTaskId === taskId && (row.type === "curator.task.completed" || completionEvents.includes(row.type)); + const eventTaskId = + payload.taskId ?? + payload.curatorTaskId ?? + payload.curator_task_id ?? + correlation.taskId ?? + correlation.curatorTaskId ?? + correlation.curator_task_id; + return eventTaskId === taskId && ( + row.type === "curator.task.completed" || + row.type === "service.completed" || + completionEvents.includes(row.type) + ); }); } @@ -1248,6 +1280,69 @@ function hasRecentServiceSignal(rows: Awaited pattern.test(`${row.source} ${row.type} ${eventText(row.payload)}`.toLowerCase())); } +function taskEventId(row: Pick>[number], "payload" | "correlation">) { + const payload = row.payload ?? {}; + const correlation = row.correlation ?? {}; + const value = + payload.taskId ?? + payload.curatorTaskId ?? + payload.curator_task_id ?? + correlation.taskId ?? + correlation.curatorTaskId ?? + correlation.curator_task_id; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function rowsForTask(taskId: string, rows: Awaited>) { + return rows.filter((row) => taskEventId(row) === taskId); +} + +function taskOpenedOrStarted(taskRows: Awaited>) { + return taskRows.some((row) => ( + row.type === "task.opened" || + row.type === "curator.task.started" || + row.type === "curator.service_handoff.opened" || + row.type.includes("started") || + row.type.includes("configured") + )); +} + +function taskHandoffPrepared(taskRows: Awaited>) { + return taskRows.some((row) => row.type === "task.opened" || row.type === "curator.service_handoff.opened"); +} + +function classifyTaskStatus(input: { + task: CuratorTask; + focusDate: string; + completionRows: Awaited>; + recentRows: Awaited>; +}): CuratorTask["status"] { + if (taskCompletedByEvents(input.task, input.completionRows)) return "completed"; + const taskRows = rowsForTask(input.task.id, input.recentRows); + if (input.task.date < input.focusDate) { + return taskOpenedOrStarted(taskRows) ? "abandoned" : "skipped"; + } + if (taskHandoffPrepared(taskRows)) return "handoff_prepared"; + if (taskOpenedOrStarted(taskRows)) return "started"; + return "ready"; +} + +function recoveryTaskSeed(previousDayIndex: number, openedIncompleteCount: number, skippedCount: number): TaskSeed { + const reason = openedIncompleteCount > 0 + ? `${openedIncompleteCount} opened task${openedIncompleteCount === 1 ? "" : "s"} did not produce completion events` + : `${skippedCount} planned task${skippedCount === 1 ? "" : "s"} had no opened or completion events`; + return seed( + "recovery", + "qscore-service", + `Recover Day ${previousDayIndex} momentum`, + `Yesterday's event trail shows ${reason}. Review the blocker, pick one constructive adjustment, and keep the streak aligned.`, + "5 min", + "+5 projected", + "Review Q Score", + ["recovery", "alignment", "event trail"], + ); +} + function adaptCurrentDayPlan( sprintStartDate: string, focusDayIndex: number, @@ -1277,7 +1372,7 @@ function adaptCurrentDayPlan( reasons.push("resume baseline missing"); } - if (!hasInterviewSignal && !hasRoleplaySignal && focusDayIndex >= 3 && current.plannedTasks[2].serviceId === "matchmaking-service") { + if (!hasInterviewSignal && !hasRoleplaySignal && focusDayIndex >= 3 && current.plannedTasks[2]?.serviceId === "matchmaking-service") { current.plannedTasks[2] = makePlannedTask( seed("practice", "interview-service", "Run a real interview warm-up rep", "Start a live interview-style practice so the sprint captures real readiness movement, not just planning.", "10 min", "+10 projected", "Open interview preview", ["interview warm-up", "first rep"]), current.weekTheme, @@ -1302,14 +1397,32 @@ function adaptCurrentDayPlan( )).length; if (previousCompleted < 3) { + const previousClassifications = previous.plannedTasks.map((task) => { + const taskId = taskIdFor(sprintStartDate, previous.dayIndex, task.taskType); + const taskRows = rowsForTask(taskId, recentRows); + return { + taskId, + opened: taskOpenedOrStarted(taskRows), + completed: taskCompletedByTaskId(taskId, completionEventsForService(task.serviceId), completionRows), + }; + }); + const openedIncompleteCount = previousClassifications.filter((item) => item.opened && !item.completed).length; + const skippedCount = previousClassifications.filter((item) => !item.opened && !item.completed).length; current.plannedTasks[0] = makePlannedTask( seed("measurement", "qscore-service", `Review what blocked Day ${previous.dayIndex}`, "Check the strongest blocker from yesterday before generating more blind work.", "5 min", "+5 projected", "Review Q Score", ["blocker review", "momentum"]), current.weekTheme, current.weekSummary, ); + if (!current.plannedTasks.some((task) => task.taskType === "recovery")) { + current.plannedTasks.push(makePlannedTask( + recoveryTaskSeed(previous.dayIndex, openedIncompleteCount, skippedCount), + current.weekTheme, + current.weekSummary, + )); + } current.focus = `Review what blocked Day ${previous.dayIndex}, then continue with today’s proof and practice plan.`; adapted = true; - reasons.push(`day ${previous.dayIndex} incomplete`); + reasons.push(`day ${previous.dayIndex} incomplete: ${openedIncompleteCount} abandoned/partial, ${skippedCount} skipped`); } } @@ -1342,7 +1455,7 @@ function adaptCurrentDayPlan( current.generationStatus = "generated"; } - current.plannedTasks = current.plannedTasks.map((task) => normalizeEventBackedTask(task, current, recentRows)) as PlanDaySeed["plannedTasks"]; + current.plannedTasks = current.plannedTasks.map((task) => normalizeEventBackedTask(task, current, recentRows)); plan[focusDayIndex - 1] = current; return plan; @@ -1357,6 +1470,8 @@ function buildTask( weekSummary: string, seedTask: TaskSeed, completionRows: Awaited>, + recentRows: Awaited>, + focusDate: string, targetRole?: string, ): CuratorTask { const dayOfWeek = dayIndexInWeek(sprintStartDate, dayIndex); @@ -1380,7 +1495,7 @@ function buildTask( actorName: "Curator sprint planner", toolName: serviceToolName(seedTask.serviceId), status: "ready", - rewardCoins: seedTask.taskType === "measurement" ? 12 : seedTask.taskType === "proof" ? 15 : 18, + rewardCoins: seedTask.taskType === "measurement" ? 12 : seedTask.taskType === "proof" ? 15 : seedTask.taskType === "recovery" ? 8 : 18, qxImpact: seedTask.qxImpact, effort: seedTask.effort, route: "", @@ -1401,7 +1516,7 @@ function buildTask( const taskWithRoute = { ...task, route: buildCuratorTaskDeepLink(task, targetRole), - status: taskCompletedByEvents(task, completionRows) ? "completed" as const : "ready" as const, + status: classifyTaskStatus({ task, focusDate, completionRows, recentRows }), } satisfies CuratorTask; return taskWithRoute; } @@ -1411,6 +1526,8 @@ function buildTasksForPlanDay( date: string, planDay: PlanDaySeed, completionRows: Awaited>, + recentRows: Awaited>, + focusDate: string, targetRole?: string, ) { return planDay.plannedTasks.map((task) => ( @@ -1423,6 +1540,8 @@ function buildTasksForPlanDay( planDay.weekSummary, task, completionRows, + recentRows, + focusDate, targetRole, ) )); @@ -1446,7 +1565,7 @@ export async function buildCuratorTasks(userId: string, date = todayIso()): Prom ); const planDay = adaptedPlanDays[dayIndex - 1]; if (!planDay) return []; - return buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, userContext.targetRole); + return buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, date, userContext.targetRole); } export async function buildCuratorStreak(userId: string): Promise { @@ -1505,7 +1624,9 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso() const dayIndex = index + 1; const date = addDaysIso(sprintStartDate, index); const planDay = planDays[index]!; - const tasks = date <= focusDate ? buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, userContext.targetRole) : []; + const tasks = date <= focusDate + ? buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, focusDate, userContext.targetRole) + : []; const completedCount = tasks.filter((task) => task.status === "completed").length; const unlockState = focusDate > date ? "completed" : focusDate === date ? "active" : "upcoming"; days.push({ @@ -1520,7 +1641,7 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso() generationStatus: planDay.generationStatus, adaptationReason: planDay.adaptationReason, completedCount, - totalCount: 3, + totalCount: planDay.plannedTasks.length, unlockState, tasks, }); @@ -1622,7 +1743,7 @@ export async function buildServiceCurationPreview(input: ServiceCurationPreviewI return { ...planDay, date, - tasks: buildTasksForPlanDay(startDate, date, planDay, completionRows, userContext.targetRole), + tasks: buildTasksForPlanDay(startDate, date, planDay, completionRows, [], date, userContext.targetRole), }; }); diff --git a/src/v1/curator/curator-tools.ts b/src/v1/curator/curator-tools.ts index bb6fe6d..04c77c0 100644 --- a/src/v1/curator/curator-tools.ts +++ b/src/v1/curator/curator-tools.ts @@ -176,9 +176,16 @@ export async function prepareHandoffForTask( await emitCuratorEvent({ userId, - type: "curator.service_handoff.opened", + type: "task.opened", mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId }, - payload: { taskId: task.id, serviceId, route, actionId }, + payload: { + taskId: task.id, + curatorTaskId: task.id, + serviceId, + route, + actionId, + expectedCompletionEvents: task.completionEvents, + }, }); return { diff --git a/src/v1/curator/curator-types.ts b/src/v1/curator/curator-types.ts index 6ed9524..e3cac58 100644 --- a/src/v1/curator/curator-types.ts +++ b/src/v1/curator/curator-types.ts @@ -15,7 +15,7 @@ export const curatorServiceIdSchema = z.enum([ export type CuratorServiceId = z.infer; -export const curatorTaskTypeSchema = z.enum(["measurement", "proof", "practice"]); +export const curatorTaskTypeSchema = z.enum(["measurement", "proof", "practice", "recovery"]); export type CuratorTaskType = z.infer; export const curatorTaskStatusSchema = z.enum([ @@ -24,6 +24,9 @@ export const curatorTaskStatusSchema = z.enum([ "handoff_prepared", "completed", "blocked", + "partial", + "skipped", + "abandoned", ]); export const curatorWeekLifecycleSchema = z.enum(["done", "active", "upcoming"]); @@ -73,7 +76,7 @@ export const curatorPlanDaySchema = z.object({ weekTheme: z.string(), weekSummary: z.string(), focus: z.string().optional(), - plannedServices: z.array(curatorServiceIdSchema).max(3).default([]), + plannedServices: z.array(curatorServiceIdSchema).max(4).default([]), generationStatus: z.enum(["seeded", "generated", "adapted"]).default("seeded"), adaptationReason: z.string().optional(), completedCount: z.number().int().min(0), @@ -132,7 +135,7 @@ export const curatorSprintResponseSchema = z.object({ activeWeekIndex: z.number().int().min(1).max(6), activeDay: curatorPlanDaySchema, activeDayIndex: z.number().int().min(1).max(30), - todayTasks: z.array(curatorTaskSchema).length(3), + todayTasks: z.array(curatorTaskSchema).min(3).max(4), streak: curatorStreakSchema, completedCount: z.number().int().min(0), totalCount: z.number().int().min(0), diff --git a/src/v1/events/events-routes.ts b/src/v1/events/events-routes.ts new file mode 100644 index 0000000..da92815 --- /dev/null +++ b/src/v1/events/events-routes.ts @@ -0,0 +1,113 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { requireUser, type AuthContext } from "../../auth/clerk.js"; +import { applyQscoreProjection } from "../../events/projectors/qscore-projector.js"; +import { applyServiceSessionProjection } from "../../events/projectors/service-session-projector.js"; +import { markGrowEventFailed, markGrowEventProcessed, markGrowEventProcessing, recordGrowEvent } from "../../events/record-grow-event.js"; +import { runCuratorOnboardingLoopForEventSafely } from "../curator/curator-onboarding-loop.js"; + +const eventTrackSchema = z.object({ + id: z.string().optional(), + source: z.string().min(1), + type: z.string().min(1).optional(), + action: z.string().min(1).optional(), + category: z.enum(["mission", "service", "artifact", "usage", "qscore", "entitlement", "system"]).default("service"), + userId: z.string().optional(), + user_id: z.string().optional(), + orgId: z.string().optional(), + org_id: z.string().optional(), + timestamp: z.string().optional(), + occurredAt: z.string().optional(), + occurred_at: z.string().optional(), + mission: z.record(z.unknown()).optional(), + subject: z.record(z.unknown()).optional(), + correlation: z.record(z.unknown()).optional(), + metadata: z.record(z.unknown()).optional(), + payload: z.record(z.unknown()).optional(), + dedupeKey: z.string().optional(), + dedupe_key: z.string().optional(), + taskId: z.string().optional(), + curatorTaskId: z.string().optional(), + curator_task_id: z.string().optional(), + serviceId: z.string().optional(), + service_id: z.string().optional(), +}); + +function compactRecord(value: Record) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); +} + +export function v1EventRoutes() { + const app = new Hono(); + app.use("*", requireUser); + + app.post("/track", async (c) => { + const authUserId = c.get("userId"); + const body = eventTrackSchema.parse(await c.req.json()); + const type = body.type ?? body.action; + if (!type) return c.json({ error: "event_type_required" }, 400); + + const payload = { + ...(body.payload ?? {}), + ...(body.metadata ? { metadata: body.metadata } : {}), + taskId: body.taskId, + curatorTaskId: body.curatorTaskId ?? body.curator_task_id ?? body.taskId, + serviceId: body.serviceId ?? body.service_id, + status: (body.payload?.status ?? body.metadata?.status) as unknown, + }; + const correlation = compactRecord({ + ...(body.correlation ?? {}), + taskId: body.taskId, + curatorTaskId: body.curatorTaskId ?? body.curator_task_id ?? body.taskId, + serviceId: body.serviceId ?? body.service_id, + }); + + const event = await recordGrowEvent({ + id: body.id, + source: body.source, + type, + category: body.category, + userId: authUserId, + orgId: body.orgId ?? body.org_id, + occurredAt: body.occurredAt ?? body.occurred_at ?? body.timestamp ?? new Date().toISOString(), + mission: body.mission, + subject: body.subject, + correlation, + payload: compactRecord(payload), + raw: body, + dedupeKey: body.dedupeKey ?? body.dedupe_key ?? body.id, + }, { userId: authUserId, source: body.source }); + + if (event.processingStatus === "processed") { + return c.json({ + eventId: event.id, + processingStatus: "processed", + idempotent: true, + }, 202); + } + + await markGrowEventProcessing(event.id); + try { + const serviceSession = await applyServiceSessionProjection(event); + const qscore = await applyQscoreProjection(event); + const curatorOnboarding = await runCuratorOnboardingLoopForEventSafely(event); + await markGrowEventProcessed(event.id); + return c.json({ + eventId: event.id, + processingStatus: "processed", + serviceSession, + qscore, + curatorOnboarding, + }, 202); + } catch (err) { + await markGrowEventFailed(event.id, err); + return c.json({ + eventId: event.id, + processingStatus: "failed", + error: err instanceof Error ? err.message : String(err), + }, 500); + } + }); + + return app; +} diff --git a/src/v1/index.ts b/src/v1/index.ts index 5fb16a1..e917698 100644 --- a/src/v1/index.ts +++ b/src/v1/index.ts @@ -1,10 +1,14 @@ import { Hono } from "hono"; import { v1CuratorRoutes } from "./curator/curator-routes.js"; import { v1AnalyticsRoutes } from "./analytics/analytics-routes.js"; +import { v1EventRoutes } from "./events/events-routes.js"; +import { v1QscoreRoutes } from "./qscore/qscore-routes.js"; export function v1Routes() { const app = new Hono(); app.route("/curator", v1CuratorRoutes()); app.route("/analytics", v1AnalyticsRoutes()); + app.route("/events", v1EventRoutes()); + app.route("/qscore", v1QscoreRoutes()); return app; } diff --git a/src/v1/qscore/qscore-routes.ts b/src/v1/qscore/qscore-routes.ts new file mode 100644 index 0000000..7f50d3a --- /dev/null +++ b/src/v1/qscore/qscore-routes.ts @@ -0,0 +1,84 @@ +import { Hono } from "hono"; +import { desc, eq } from "drizzle-orm"; +import { requireUser, type AuthContext } from "../../auth/clerk.js"; +import { db } from "../../db/client.js"; +import { growQscoreLatest, growQscoreProjectionState } from "../../db/schema.js"; + +function groupDimensions(signals: Array) { + const grouped = new Map }>(); + for (const signal of signals) { + const id = signal.signalId.split(".")[0] || "readiness"; + const current = grouped.get(id) ?? { score: 0, count: 0, sources: new Set() }; + current.score += signal.score; + current.count += 1; + if (signal.source) current.sources.add(signal.source); + grouped.set(id, current); + } + return Array.from(grouped.entries()).map(([id, group]) => ({ + id, + label: id.replace(/-/g, " ").replace(/^./, (char) => char.toUpperCase()), + score: Math.round(group.score / Math.max(group.count, 1)), + signalCount: group.count, + sources: Array.from(group.sources), + })); +} + +export function v1QscoreRoutes() { + const app = new Hono(); + app.use("*", requireUser); + + app.get("/latest", async (c) => { + const userId = c.get("userId"); + const [projection] = await db + .select() + .from(growQscoreProjectionState) + .where(eq(growQscoreProjectionState.userId, userId)) + .limit(1); + const signals = await db + .select() + .from(growQscoreLatest) + .where(eq(growQscoreLatest.userId, userId)) + .orderBy(desc(growQscoreLatest.updatedAt)); + + if (!projection && signals.length === 0) { + return c.json({ + status: "baseline_needed", + score: null, + dimensions: [], + trendLabel: "No QScore signals yet", + lastUpdatedAt: null, + explanation: "No onboarding, service completion, or readiness signals have been projected for this user.", + signalCount: 0, + signals: [], + source: "grow_qscore_projection_state", + }); + } + + const score = projection?.score && projection.score > 0 + ? projection.score + : Math.round(signals.reduce((sum, signal) => sum + signal.score, 0) / Math.max(signals.length, 1)); + const lastUpdatedAt = projection?.updatedAt ?? signals[0]?.updatedAt ?? null; + + return c.json({ + status: "ready", + score, + dimensions: groupDimensions(signals), + trendLabel: signals.length > 1 ? "Updated from recent activity" : "Baseline established", + lastUpdatedAt: lastUpdatedAt?.toISOString() ?? null, + explanation: projection?.summary ?? `Readiness score computed from ${signals.length} current signal${signals.length === 1 ? "" : "s"}.`, + signalCount: projection?.signalCount ?? signals.length, + signals: signals.map((signal) => ({ + signalId: signal.signalId, + score: Math.round(signal.score), + present: signal.present, + source: signal.source, + sourceEventId: signal.sourceEventId, + occurredAt: signal.occurredAt.toISOString(), + updatedAt: signal.updatedAt.toISOString(), + })), + source: "grow_qscore_projection_state", + }); + }); + + return app; +} -- 2.49.1 From a83a27eb502c6f76133f5a925e16fab77f624fee Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Tue, 23 Jun 2026 20:45:12 +0000 Subject: [PATCH 2/4] Recognize explicit abandoned curator service events --- src/v1/curator/curator-store.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/v1/curator/curator-store.ts b/src/v1/curator/curator-store.ts index 87924a1..b94646c 100644 --- a/src/v1/curator/curator-store.ts +++ b/src/v1/curator/curator-store.ts @@ -1303,7 +1303,9 @@ function taskOpenedOrStarted(taskRows: Awaited Date: Tue, 23 Jun 2026 21:01:10 +0000 Subject: [PATCH 3/4] Project scored service completions into QScore --- src/events/projectors/qscore-projector.ts | 46 +++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/events/projectors/qscore-projector.ts b/src/events/projectors/qscore-projector.ts index 84c4e42..a5636bc 100644 --- a/src/events/projectors/qscore-projector.ts +++ b/src/events/projectors/qscore-projector.ts @@ -113,11 +113,57 @@ function extractRoleplaySignals(event: GrowEventRow): QscoreSignal[] { return signals; } +function sourceSignalPrefix(source: string) { + return source + .toLowerCase() + .replace(/-service$/, "") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") || "service"; +} + +function extractScoredServiceSignals(event: GrowEventRow): QscoreSignal[] { + const payload = event.payload ?? {}; + const review = asRecord(payload.review ?? payload.result ?? payload); + const status = String(review.status ?? payload.status ?? ""); + const isCompletion = + event.type.includes("completed") || + event.type.includes("updated") || + event.type.includes("signal_projected") || + status === "completed"; + if (!isCompletion) return []; + + const score = getNumber( + payload.score ?? + payload.qscore ?? + payload.q_score ?? + payload.readiness_score ?? + payload.overall_score ?? + review.score ?? + review.qscore ?? + review.q_score ?? + review.readiness_score ?? + review.overall_score, + ); + if (score === undefined) return []; + + const prefix = sourceSignalPrefix(event.source); + return [ + signal(`${prefix}.service_completion_score`, score, { + eventId: event.id, + source: event.source, + type: event.type, + }), + ]; +} + export function extractQscoreSignals(event: GrowEventRow): QscoreSignal[] { const source = event.source.toLowerCase(); if (source.includes("resume") || event.type.startsWith("resume.")) return extractResumeSignals(event); if (source.includes("interview") || event.type.startsWith("interview.")) return extractInterviewSignals(event); if (source.includes("roleplay") || event.type.startsWith("roleplay.")) return extractRoleplaySignals(event); + if (source.includes("qscore") || event.type.startsWith("qscore.")) return extractScoredServiceSignals(event); + const scoredServiceSignals = extractScoredServiceSignals(event); + if (scoredServiceSignals.length) return scoredServiceSignals; if (event.type === "mission.interview_to_offer.started") { return [signal("goals.goals_set", 100, { eventId: event.id })]; } -- 2.49.1 From 97ed70a9215f7ab0826dc4012a2cbeefdd914806 Mon Sep 17 00:00:00 2001 From: sai karthik Date: Wed, 24 Jun 2026 02:35:47 +0530 Subject: [PATCH 4/4] Add PRM-71 backend QA evidence --- docs/qa/prm-71-backend-qa-evidence.md | 154 ++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/qa/prm-71-backend-qa-evidence.md diff --git a/docs/qa/prm-71-backend-qa-evidence.md b/docs/qa/prm-71-backend-qa-evidence.md new file mode 100644 index 0000000..90bc414 --- /dev/null +++ b/docs/qa/prm-71-backend-qa-evidence.md @@ -0,0 +1,154 @@ +# PRM-71 Backend QA Evidence + +This file keeps the PRM-71 backend QA proof inside the backend PR. The checks below were run against the real deployed API at `https://app.sai-onchain.me/api/growqr`, not against mocks or fallback-only fixtures. + +## Deployed Target + +- Public backend base: `https://app.sai-onchain.me/api/growqr` +- Local backend base on VPS: `http://127.0.0.1:4000` +- Branch: `prm-71-backend-qa-curator-streak-loop` +- Runtime implementation commit verified: `0bfc18305bd2462fc7c0fcbfb2a3f5cd76df3f9d` +- PR: `https://git.openputer.com/growqr-app/growqr-backend/pulls/10` + +## Service Commit SHAs + +- `growqr-backend`: `0bfc18305bd2462fc7c0fcbfb2a3f5cd76df3f9d` +- `growqr-dashboard`: `c4e79d7a17767a083f19f02ba1ca4065f1d415d7` +- `interview-service`: `61b238b00463bc3a1e283bf3b850c97279d94ece` +- `roleplay-service`: `b4a4913df28c00985578e3af5f1a95e12cf4260e` +- `resume-service`: `ebcc6e0826c2e7762251080b6365ebb6b5439c93` +- `qscore-service`: `058903f9686067398640a6a56aebce0b57408ccb` +- `matchmaking-service`: `e36e831794cccb0e176df4e9113ab1957d4c3612` +- `courses-service`: `f702728247bb4e66edf4552d792d25825ceb44fe` +- `assessment-service`: `d2885ad2c83c86a95b6a8d9a46dafe5415678422` +- `pathways-service`: `b20abed9d7a5fb9c68804b986a9d46a1015d54af` +- `social-branding-service`: `98463cdcf75f720a3035c2954b2a847956df24f2` + +## Health Proof + +- Backend container: `growqr-backend Up ... (healthy)` +- Local backend health: `GET http://127.0.0.1:4000/healthz` returned `{"ok":true}` +- Public API health was exercised through authenticated real API calls at `https://app.sai-onchain.me/api/growqr/...` +- Gateway health passed for `interview`, `roleplay`, `resume`, and `social` +- Direct declared health paths passed for `qscore-service`, `matchmaking-service`, `courses-service`, `assessment-service`, and `pathways-service` + +## Real API Evidence Users + +- Full evidence flow user: `qa-prm71-full-flow-1782248569` +- Full handoff sample user: `qa-prm71-handoffs-1782248569` +- Final battle-test flow user: `qa-prm71-battle-flow-1782248509` +- Final battle-test all-complete user: `qa-prm71-battle-complete-1782248509` + +## API Contract Evidence + +The full evidence run captured: + +- `GET /v1/curator/today?date=2026-06-23` for a fresh test seeker +- `POST /v1/curator/tasks/:taskId/handoff` samples for: + - `interview-service` + - `roleplay-service` + - `resume-service` + - `qscore-service` +- `POST /v1/events/track` sample payloads for: + - `service.started` + - `service.abandoned` + - `service.completed` +- `GET /v1/qscore/latest` before and after completion +- `GET /v1/analytics/insight-snapshot` before and after completion +- `GET /v1/analytics/activity-history` after event ingestion + +The battle-test run additionally checked auth rejection, malformed event rejection, idempotent duplicate event replay, cross-user isolation, large activity-history limit clamping, all-complete Day 1 behavior, and recovery Day 2 behavior. + +## Day 1 To Day 2 Replan Proof + +Fresh seeker flow: + +- Day 1 returned exactly 3 tasks: `measurement`, `proof`, `practice` +- A practice handoff recorded `task.opened` +- Real event payloads recorded `service.started` and `service.abandoned` +- Day 2 returned 4 tasks with a `recovery` task +- Day 1 statuses after replan included `skipped`, `skipped`, and `abandoned` +- Adaptation reason: `day 1 incomplete: 1 abandoned/partial, 2 skipped` + +All-complete control flow: + +- Day 1 tasks were completed with real `service.completed` events +- Duplicate completion replays returned idempotent responses +- Day 2 did not include a recovery task +- Day 1 statuses were all `completed` + +## QScore And Analytics Proof + +- QScore before completion: `null` / `baseline_needed` +- QScore after completion: `89` / `ready` +- Analytics roleFit before completion: `baseline_needed` +- Analytics roleFit after completion: `strong` with score `89` +- Follow-up battle test verified a scored `service.completed` event updates QScore/readiness state, closing the earlier gap where generic scored completions could process without moving QScore. + +## Event Storage Proof + +Database proof for the full evidence flow: + +```text +curator.day.opened|pending|4 +curator.onboarding_plan.ready|pending|1 +curator.sprint.started|pending|1 +service.abandoned|processed|1 +service.completed|processed|1 +service.started|processed|1 +task.opened|pending|2 +``` + +API proof was also captured through `GET /v1/analytics/activity-history`, which returned the ingested event stream for the test seeker. + +## Battle-Test Checklist + +Final battle-test result on the deployed real API: `23/23` checks passed. + +- [x] Public health endpoint is reachable +- [x] Protected endpoint rejects missing auth +- [x] Event contract rejects missing type/action +- [x] Fresh QScore is `baseline_needed` +- [x] Fresh analytics roleFit is `baseline_needed` +- [x] Onboarding run succeeds +- [x] Day 1 returns three frontend-consumable tasks +- [x] Day 1 tasks include service routing metadata +- [x] Curator handoff succeeds +- [x] `service.started` processes +- [x] Duplicate started event is idempotent +- [x] `service.abandoned` processes +- [x] Day 2 adds recovery after abandoned Day 1 +- [x] Day 1 statuses reflect skipped/abandoned work +- [x] `service.completed` processes +- [x] Duplicate completed event is idempotent +- [x] QScore updates after real completion +- [x] Analytics updates after real completion +- [x] Activity history clamps large limits +- [x] Duplicate completed event is stored only once +- [x] All-complete Day 1 has no recovery on Day 2 +- [x] All-complete Day 1 statuses are completed +- [x] Payload `userId` cannot write into another user's stream + +## Rollback Notes + +If the deployed VPS backend must be rolled back to staging: + +```bash +cd /opt/growqr/growqr-backend +git fetch origin --prune +git checkout staging +git reset --hard origin/staging +docker compose up -d --build backend +curl -fsS http://127.0.0.1:4000/healthz +``` + +Revert alternative from the PR branch: + +```bash +git revert $(git rev-list --reverse origin/staging..HEAD) +docker compose up -d --build backend +``` + +## Current Formal Caveat + +PRM-71's real API/backend production-slice evidence is satisfied by this PR and the deployed checks above. The Linear parent DoD also says grouped backend child issues must be merged/deployed or explicitly deferred with owner approval. At the time of this evidence pass, the PRM-71 parent has PR #10 attached and several grouped child Linear issues are still not formally marked done in Linear. This PR therefore provides the deployed PRM-71 proof, while final parent closure still depends on the owner's desired handling of those child issue statuses. -- 2.49.1