From c7c6f1b8cc7d707ae939fc9f18e2663e8aeb0359 Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:31:02 +0530 Subject: [PATCH 01/16] fix curator sprint ICP reconciliation --- package.json | 1 + scripts/curator-onboarding-reconcile.test.ts | 30 ++ scripts/curator-persisted-reconcile.test.ts | 133 +++++++++ src/v1/curator/curator-events.ts | 17 +- src/v1/curator/curator-onboarding-loop.ts | 44 ++- src/v1/curator/curator-store.ts | 280 ++++++++++++++----- src/v1/curator/curator-types.ts | 7 + 7 files changed, 432 insertions(+), 80 deletions(-) create mode 100644 scripts/curator-persisted-reconcile.test.ts diff --git a/package.json b/package.json index 4ee2daa..5995cbd 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test:missions": "tsx scripts/mission-lifecycle.test.ts", "test:passive-actions": "tsx scripts/passive-actions.test.ts", "test:curator-static": "tsx scripts/curator-static-registry.test.ts", + "test:curator-reconcile": "tsx scripts/curator-persisted-reconcile.test.ts", "start": "node dist/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", "workflows:smoke": "tsx src/workflows/smoke-test.ts", diff --git a/scripts/curator-onboarding-reconcile.test.ts b/scripts/curator-onboarding-reconcile.test.ts index 41362cd..e3c4e09 100644 --- a/scripts/curator-onboarding-reconcile.test.ts +++ b/scripts/curator-onboarding-reconcile.test.ts @@ -1,10 +1,13 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { + curatorSprintPlanFingerprint, + curatorSprintTaskPlan, decideCuratorSprintReconciliation, resolveCuratorSprintAccess, shouldReconcileCuratorSprint, } from "../src/v1/curator/curator-store.js"; +import { templateSetFor, trialDaysFor } from "../src/v1/curator/task-registry.js"; const usersRoute = readFileSync(new URL("../src/routes/users.ts", import.meta.url), "utf8"); const ledgerSource = readFileSync(new URL("../src/events/onboarding-ledger.ts", import.meta.url), "utf8"); @@ -44,4 +47,31 @@ const unauthorizedFull = resolveCuratorSprintAccess({ plan: "free", accessChoice assert.deepEqual(unauthorizedFull, { access: "trial", durationDays: 2 }); assert.equal(shouldReconcileCuratorSprint(existingTrial, unauthorizedFull), false); +const startDate = "2026-07-13"; +const studentFingerprint = curatorSprintPlanFingerprint({ startDate, variantId: "student_recent_grad", durationDays: 2, access: "trial" }); +const experiencedFingerprint = curatorSprintPlanFingerprint({ startDate, variantId: "experienced_professional", durationDays: 2, access: "trial" }); +const experiencedPlan = curatorSprintTaskPlan({ startDate, variantId: "experienced_professional", durationDays: 2, access: "trial" }); +assert.notEqual(studentFingerprint, experiencedFingerprint, "ICP changes must change the compatibility fingerprint"); +assert.equal(shouldReconcileCuratorSprint( + { access: "trial", durationDays: 2, variantId: "student_recent_grad", planFingerprint: studentFingerprint }, + { access: "trial", durationDays: 2, variantId: "experienced_professional", planFingerprint: experiencedFingerprint }, +), true, "student started/ready lineage must reconcile to experienced trial"); +const experiencedTrial = trialDaysFor("experienced_professional"); +assert.deepEqual(experiencedPlan[0]?.tasks.map((task) => task.title), [ + experiencedTrial[0]!.socialTitle, + experiencedTrial[0]!.measurementTitle, + experiencedTrial[0]!.proofTitle, + experiencedTrial[0]!.practiceTitle, +]); +assert.deepEqual(experiencedPlan[1]?.tasks.map((task) => task.title), [ + experiencedTrial[1]!.socialTitle, + experiencedTrial[1]!.measurementTitle, + experiencedTrial[1]!.proofTitle, + experiencedTrial[1]!.practiceTitle, +]); +assert.equal(experiencedPlan[0]?.tasks.length, 4); +assert.equal(experiencedPlan[1]?.tasks.length, 4); +assert.deepEqual(experiencedPlan[0]?.tasks.map((task) => task.id), experiencedPlan[0]?.tasks.map((task) => task.id)); +assert.equal(templateSetFor("experienced_professional").id, "experienced_professional"); + console.log("curator onboarding reconciliation tests passed"); diff --git a/scripts/curator-persisted-reconcile.test.ts b/scripts/curator-persisted-reconcile.test.ts new file mode 100644 index 0000000..8a9a452 --- /dev/null +++ b/scripts/curator-persisted-reconcile.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { eq, sql } from "drizzle-orm"; +import { db } from "../src/db/client.js"; +import { growEvents, onboarding, users } from "../src/db/schema.js"; +import { buildCuratorSprint, curatorSprintTaskPlan } from "../src/v1/curator/curator-store.js"; +import { runCuratorOnboardingLoop } from "../src/v1/curator/curator-onboarding-loop.js"; +import { CURATOR_TRIAL_TASK_REGISTRY } from "../src/v1/curator/task-registry.js"; + +let databaseAvailable = Boolean(process.env.DATABASE_URL); +if (databaseAvailable) { + try { + await db.execute(sql`select 1`); + } catch { + databaseAvailable = false; + } +} + +if (!databaseAvailable) { + console.log("curator persisted reconciliation integration skipped: DATABASE_URL is not reachable"); +} else { + const userId = `curator-reconcile-regression-${Date.now()}`; + const startDate = new Date().toISOString().slice(0, 10); + const sprintId = `curator-sprint:icp-v10-static:${startDate}`; + const staleFingerprint = JSON.stringify({ variantId: "student_recent_grad", taskIds: [] }); + const staleReadyPayload = { + sprintId, + startDate, + durationDays: 2, + access: "trial", + variantId: "student_recent_grad", + planFingerprint: staleFingerprint, + }; + + try { + await db.insert(users).values({ id: userId, email: `${userId}@example.test`, plan: "pro" }); + await db.insert(onboarding).values({ + userId, + data: { + status: "completed", + access_choice: "trial", + profile: { icp: "experienced" }, + }, + payload: { + onboarding_icp: "experienced", + curator_registry_icp: "experienced_professional", + }, + }); + await db.insert(growEvents).values([ + { + userId, + source: "curator-v1", + type: "curator.sprint.started", + category: "mission", + occurredAt: new Date(), + dedupeKey: `${userId}:curator.sprint.started:stale`, + payload: { + sprintId, + startDate, + durationDays: 2, + access: "trial", + version: "icp-v10-static", + variantId: "student_recent_grad", + }, + }, + { + userId, + source: "curator-v1", + type: "curator.onboarding_plan.ready", + category: "mission", + occurredAt: new Date(), + dedupeKey: `${userId}:curator.onboarding_plan.ready:stale`, + payload: staleReadyPayload, + }, + ]); + + const context = { + onboarding: { status: "completed", access_choice: "trial", profile: { icp: "experienced" } }, + preferences: { onboarding: { status: "completed", access_choice: "trial", profile: { icp: "experienced" } } }, + }; + const first = await runCuratorOnboardingLoop({ userId, completedAt: `${startDate}T09:00:00.000Z`, context }); + assert.equal(first.status, "ready", "stale ready event must be replaced"); + + const day1 = await buildCuratorSprint(userId, startDate); + assert.deepEqual(day1.plan.days[0]?.tasks.map(({ id, title }) => ({ id, title })), + expectedTasks(startDate, 1), "Day 1 must expose experienced trial tasks"); + assert.deepEqual(day1.plan.days[1]?.tasks, [], "Day 1 must hide future Day 2 tasks"); + + const day2 = await buildCuratorSprint(userId, addDays(startDate, 1)); + assert.deepEqual(day2.plan.days[1]?.tasks.map(({ id, title }) => ({ id, title })), + expectedTasks(startDate, 2), "Day 2 must expose experienced trial tasks after advance"); + + const second = await runCuratorOnboardingLoop({ userId, completedAt: `${startDate}T09:00:00.000Z`, context }); + assert.equal(second.status, "already_ready", "compatible replacement ready event must be reused"); + + const rows = await db.select({ type: growEvents.type, payload: growEvents.payload }) + .from(growEvents).where(eq(growEvents.userId, userId)); + assert.equal(rows.filter((row) => row.type === "curator.sprint.invalidated").length, 1); + assert.equal(rows.filter((row) => row.type === "curator.onboarding_plan.invalidated").length, 1); + const starts = rows.filter((row) => row.type === "curator.sprint.started"); + const readies = rows.filter((row) => row.type === "curator.onboarding_plan.ready"); + assert.equal(starts.length, 2, "one stale and one replacement started lineage"); + assert.equal(readies.length, 2, "one stale and one replacement ready lineage"); + const replacementStart = starts.find((row) => row.payload?.variantId === "experienced_professional"); + const replacementReady = readies.find((row) => row.payload?.planFingerprint === day1.plan.planFingerprint); + assert.ok(replacementStart, "experienced replacement started event must be persisted"); + assert.ok(replacementReady, "experienced replacement ready event must be persisted"); + + console.log("curator persisted reconciliation integration passed"); + } finally { + await db.delete(users).where(eq(users.id, userId)); + await db.execute(sql`select 1`); + } +} + +function addDays(date: string, days: number) { + const value = new Date(`${date}T00:00:00.000Z`); + value.setUTCDate(value.getUTCDate() + days); + return value.toISOString().slice(0, 10); +} + +function expectedTasks(startDate: string, dayIndex: 1 | 2) { + const plan = curatorSprintTaskPlan({ + startDate, + variantId: "experienced_professional", + durationDays: 2, + access: "trial", + }); + const registry = CURATOR_TRIAL_TASK_REGISTRY.experienced_professional[dayIndex - 1]!; + return plan[dayIndex - 1]!.tasks.map(({ id }, index) => ({ + id, + title: [registry.socialTitle, registry.measurementTitle, registry.proofTitle, registry.practiceTitle][index]!, + })); +} diff --git a/src/v1/curator/curator-events.ts b/src/v1/curator/curator-events.ts index af544c7..62e74b9 100644 --- a/src/v1/curator/curator-events.ts +++ b/src/v1/curator/curator-events.ts @@ -7,13 +7,16 @@ function curatorDedupeKey(input: { payload?: Record; }) { const payload = input.payload ?? {}; - const stableId = - payload.taskId ?? - payload.sprintId ?? - payload.startDate ?? - payload.sourceEventId ?? - payload.eventId ?? - payload.date; + const stableId = input.type.includes("invalidated") + ? `${payload.staleSprintEventId ?? payload.staleReadyEventId ?? payload.staleEventId ?? "unknown"}:${payload.planFingerprint ?? "legacy"}` + : payload.planFingerprint ?? + payload.taskPlanFingerprint ?? + payload.taskId ?? + payload.sprintId ?? + payload.startDate ?? + payload.sourceEventId ?? + payload.eventId ?? + payload.date; return `${input.userId}:${input.type}:${stableId ?? Date.now()}`; } diff --git a/src/v1/curator/curator-onboarding-loop.ts b/src/v1/curator/curator-onboarding-loop.ts index ababa94..b9ca9ca 100644 --- a/src/v1/curator/curator-onboarding-loop.ts +++ b/src/v1/curator/curator-onboarding-loop.ts @@ -276,23 +276,48 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom const existing = await findExistingReadyEvent(userId); if (existing) { - const existingDuration = existing.payload?.durationDays === 7 ? 7 : existing.payload?.durationDays === 2 ? 2 : undefined; - if (existingDuration !== sprint.plan.durationDays) { + const existingFingerprint = typeof existing.payload?.planFingerprint === "string" ? existing.payload.planFingerprint : undefined; + const existingIcpId = typeof existing.payload?.icpId === "string" + ? existing.payload.icpId + : typeof existing.payload?.variantId === "string" ? existing.payload.variantId : undefined; + const compatible = existingFingerprint === sprint.plan.planFingerprint && existingIcpId === sprint.plan.icpId; + if (compatible) return { status: "already_ready", readyEventId: existing.id, sprint }; + + const invalidations = await db + .select({ payload: growEvents.payload }) + .from(growEvents) + .where(and( + eq(growEvents.userId, userId), + eq(growEvents.source, CURATOR_SOURCE), + eq(growEvents.type, "curator.onboarding_plan.invalidated"), + )) + .orderBy(desc(growEvents.occurredAt)) + .limit(20); + const alreadyInvalidated = invalidations.some((row) => ( + row.payload?.staleReadyEventId === existing.id + && row.payload?.planFingerprint === sprint.plan.planFingerprint + )); + if (!alreadyInvalidated) { await emitCuratorEvent({ userId, type: "curator.onboarding_plan.invalidated", payload: { sprintId: sprint.sprintId, staleReadyEventId: existing.id, - previousDurationDays: existingDuration, - access: sprint.plan.durationDays === 7 ? "full" : "trial", + previousIcpId: existingIcpId, + previousDurationDays: existing.payload?.durationDays, + previousPlanFingerprint: existingFingerprint, + icpId: sprint.plan.icpId, + variantId: sprint.plan.icpId, + access: sprint.plan.access, durationDays: sprint.plan.durationDays, + planFingerprint: sprint.plan.planFingerprint, + taskPlan: sprint.plan.taskPlan, dayOneTasks: dayOneTasks(sprint), - reason: "onboarding_access_choice_changed", + reason: "onboarding_plan_changed", }, }); } - return { status: "already_ready", readyEventId: existing.id, sprint }; } const event = await emitCuratorEvent({ @@ -307,11 +332,16 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom sprintId: sprint.sprintId, planId: sprint.plan.id, durationDays: sprint.plan.durationDays, - access: sprint.plan.durationDays === 7 ? "full" : "trial", + access: sprint.plan.access, + icpId: sprint.plan.icpId, + variantId: sprint.plan.icpId, + planFingerprint: sprint.plan.planFingerprint, + taskPlan: sprint.plan.taskPlan, weekCount: sprint.plan.weeks.length, dayCount: sprint.plan.days.length, activeDayIndex: sprint.activeDayIndex, weeklyThemes: weeklyThemes(sprint), + // Keep the future-day visibility contract: this is Day 1 only. dayOneTasks: dayOneTasks(sprint), notificationId: `curator:onboarding-plan-ready:${userId}`, }, diff --git a/src/v1/curator/curator-store.ts b/src/v1/curator/curator-store.ts index 695bb2b..9e51dd8 100644 --- a/src/v1/curator/curator-store.ts +++ b/src/v1/curator/curator-store.ts @@ -75,6 +75,11 @@ export type CuratorSprintAccess = { durationDays: 2 | 7; }; +export type CuratorSprintCompatibility = CuratorSprintAccess & { + variantId?: CuratorIcpId; + planFingerprint?: string; +}; + type CuratorSprintEntitlementInput = { plan?: unknown; accessChoice?: unknown; @@ -96,23 +101,31 @@ export function resolveCuratorSprintAccess(input: CuratorSprintEntitlementInput) } export function shouldReconcileCuratorSprint( - existing: Pick | undefined, - desired: CuratorSprintAccess, + existing: CuratorSprintCompatibility | undefined, + desired: CuratorSprintCompatibility, ) { - return !existing || existing.access !== desired.access || existing.durationDays !== desired.durationDays; + if (!existing || existing.access !== desired.access || existing.durationDays !== desired.durationDays) return true; + if (desired.variantId !== undefined && existing.variantId !== desired.variantId) return true; + if (desired.planFingerprint !== undefined && existing.planFingerprint !== desired.planFingerprint) return true; + return false; } export type CuratorSprintReconciliation = - | { action: "reuse"; access: "trial" | "full"; durationDays: 2 | 7 } - | { action: "invalidateAndRebuild"; access: "trial" | "full"; durationDays: 2 | 7 }; + | ({ action: "reuse" } & CuratorSprintAccess & Partial>) + | ({ action: "invalidateAndRebuild" } & CuratorSprintAccess & Partial>); export function decideCuratorSprintReconciliation( - existing: Pick | undefined, - desired: CuratorSprintAccess, + existing: CuratorSprintCompatibility | undefined, + desired: CuratorSprintCompatibility, ): CuratorSprintReconciliation { - return shouldReconcileCuratorSprint(existing, desired) - ? { action: "invalidateAndRebuild", access: desired.access, durationDays: desired.durationDays } - : { action: "reuse", access: desired.access, durationDays: desired.durationDays }; + const action = shouldReconcileCuratorSprint(existing, desired) ? "invalidateAndRebuild" : "reuse"; + return { + action, + access: desired.access, + durationDays: desired.durationDays, + ...(desired.variantId === undefined ? {} : { variantId: desired.variantId }), + ...(desired.planFingerprint === undefined ? {} : { planFingerprint: desired.planFingerprint }), + }; } function accessChoiceFromContext(context: Record | undefined): "trial" | "full" | undefined { @@ -473,6 +486,47 @@ export function planSeedsForVariant( return planDays; } +export type CuratorSprintTaskPlan = { + dayIndex: number; + tasks: Array<{ id: string; title: string }>; +}; + +function taskPlanForSeeds(startDate: string, planDays: PlanDaySeed[]): CuratorSprintTaskPlan[] { + return planDays.map((day) => ({ + dayIndex: day.dayIndex, + tasks: day.plannedTasks.map((task) => ({ + id: taskIdFor(startDate, day.dayIndex, task.taskType, task.serviceId), + title: task.title, + })), + })); +} + +export function curatorSprintTaskPlan(input: { + startDate: string; + variantId: CuratorIcpId; + durationDays: 2 | 7; + access: "trial" | "full"; +}): CuratorSprintTaskPlan[] { + return taskPlanForSeeds( + input.startDate, + planSeedsForVariant(templateSetFor(input.variantId), input.startDate, input.durationDays, input.access), + ); +} + +/** Stable compatibility key for the complete static sprint task plan. */ +export function curatorSprintPlanFingerprint(input: { + startDate: string; + variantId: CuratorIcpId; + durationDays: 2 | 7; + access: "trial" | "full"; +}): string { + const taskPlan = curatorSprintTaskPlan(input); + return JSON.stringify({ + variantId: input.variantId, + taskIds: taskPlan.flatMap((day) => day.tasks.map((task) => task.id)), + }); +} + function performanceLabel(percent: number): CuratorWeek["performance"] { if (percent >= 75) return "Excelling"; if (percent >= 50) return "Avg"; @@ -533,10 +587,61 @@ function eventText(value: unknown): string { return ""; } -async function inferIcpVariant(userId: string): Promise { +const CURATOR_ICP_BY_ONBOARDING_ICP: Record = { + student: "student_recent_grad", + intern: "intern", + fresher: "fresher_early_professional", + experienced: "experienced_professional", + freelancer: "freelancer", + founder: "founder", +}; + +function curatorIcpFromContext(context: Record | undefined): CuratorIcpId | undefined { + if (!context) return undefined; + const preferences = context.preferences && typeof context.preferences === "object" && !Array.isArray(context.preferences) + ? context.preferences as Record + : undefined; + const onboardingContext = context.onboarding && typeof context.onboarding === "object" && !Array.isArray(context.onboarding) + ? context.onboarding as Record + : undefined; + const preferenceOnboarding = preferences?.onboarding && typeof preferences.onboarding === "object" && !Array.isArray(preferences.onboarding) + ? preferences.onboarding as Record + : undefined; + const profile = onboardingContext?.profile && typeof onboardingContext.profile === "object" && !Array.isArray(onboardingContext.profile) + ? onboardingContext.profile as Record + : preferenceOnboarding?.profile && typeof preferenceOnboarding.profile === "object" && !Array.isArray(preferenceOnboarding.profile) + ? preferenceOnboarding.profile as Record + : undefined; + const candidates = [ + context.curator_registry_icp, + onboardingContext?.curator_registry_icp, + preferences?.curator_registry_icp, + context.onboarding_icp, + onboardingContext?.onboarding_icp, + profile?.icp, + ]; + for (const candidate of candidates) { + if (typeof candidate !== "string") continue; + if (isCuratorIcpId(candidate)) return candidate; + const mapped = CURATOR_ICP_BY_ONBOARDING_ICP[candidate.toLowerCase()]; + if (mapped) return mapped; + } + return undefined; +} + +async function inferIcpVariant(userId: string, context?: Record, preferContext = false): Promise { + const fromContext = curatorIcpFromContext(context); 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; + if (preferContext && fromContext) return fromContext; + const savedPayload = saved?.payload && typeof saved.payload === "object" ? saved.payload : undefined; + const savedData = saved?.data && typeof saved.data === "object" ? saved.data : undefined; + const fromSaved = curatorIcpFromContext({ + curator_registry_icp: savedPayload?.curator_registry_icp, + onboarding_icp: savedPayload?.onboarding_icp, + onboarding: savedData, + }); + if (fromSaved) return fromSaved; + if (fromContext) return fromContext; const rows = await db .select({ type: growEvents.type, payload: growEvents.payload }) @@ -602,14 +707,15 @@ async function loadSprintState( ? persistedContext.onboarding as Record : undefined; const contextCompleted = contextOnboarding?.status === "completed" || typeof contextOnboarding?.completed_at === "string"; - if (!dbCompleted && !contextCompleted) { - throw new Error("onboarding_incomplete"); - } + if (!dbCompleted && !contextCompleted) throw new Error("onboarding_incomplete"); const user = await db.query.users.findFirst({ where: eq(users.id, userId) }); const dbChoice = dbData?.access_choice; - const accessChoice = accessChoiceFromContext(persistedContext) ?? dbChoice; + const accessChoice = onboardingContextOverride + ? accessChoiceFromContext(persistedContext) ?? dbChoice + : dbChoice ?? accessChoiceFromContext(persistedContext); const desired = resolveCuratorSprintAccess({ plan: user?.plan, accessChoice }); + const desiredVariant = await inferIcpVariant(userId, persistedContext, Boolean(onboardingContextOverride)); const latest = await db .select({ id: growEvents.id, payload: growEvents.payload }) @@ -625,79 +731,107 @@ async function loadSprintState( const latestPayload = latest[0]?.payload; const existingStartDate = typeof latestPayload?.startDate === "string" ? latestPayload.startDate : undefined; const existingVersion = typeof latestPayload?.version === "string" ? latestPayload.version : undefined; - const existingVariant = typeof latestPayload?.variantId === "string" ? latestPayload.variantId : undefined; + const existingVariant = typeof latestPayload?.variantId === "string" && isCuratorIcpId(latestPayload.variantId) + ? latestPayload.variantId + : undefined; const existingDuration = latestPayload?.durationDays === 7 ? 7 : latestPayload?.durationDays === 2 ? 2 : undefined; const existingAccess = latestPayload?.access === "full" ? "full" : latestPayload?.access === "trial" ? "trial" : undefined; + const existingFingerprint = typeof latestPayload?.planFingerprint === "string" ? latestPayload.planFingerprint : undefined; const rebaseExistingForCompletion = Boolean(onboardingContextOverride && existingStartDate && existingStartDate !== todayDate); const activeExisting = Boolean( - !rebaseExistingForCompletion && existingStartDate && existingVersion === CURATOR_PLAN_VERSION && existingVariant && isCuratorIcpId(existingVariant) - && existingDuration && calendarDiffDays(existingStartDate, todayDate) >= 0 && calendarDiffDays(existingStartDate, todayDate) < existingDuration, + !rebaseExistingForCompletion && existingStartDate && existingVersion === CURATOR_PLAN_VERSION && existingVariant + && existingDuration && existingAccess && calendarDiffDays(existingStartDate, todayDate) >= 0 && calendarDiffDays(existingStartDate, todayDate) < existingDuration, ); + + let replacementStartDate = todayDate; if (activeExisting && existingStartDate && existingVariant && existingDuration && existingAccess) { - const templateSet = templateSetFor(existingVariant as CuratorIcpId); - const reconciliation = decideCuratorSprintReconciliation({ access: existingAccess, durationDays: existingDuration }, desired); - if (reconciliation.action === "invalidateAndRebuild") { - const invalidations = await db - .select({ payload: growEvents.payload }) - .from(growEvents) - .where(and( - eq(growEvents.userId, userId), - eq(growEvents.source, CURATOR_SOURCE), - eq(growEvents.type, "curator.sprint.invalidated"), - )) - .orderBy(desc(growEvents.occurredAt)) - .limit(20); - const alreadyInvalidated = invalidations.some((row) => ( - row.payload?.staleSprintEventId === latest[0]?.id - && row.payload?.access === desired.access - && row.payload?.durationDays === desired.durationDays - )); - if (!alreadyInvalidated) { - await emitCuratorEvent({ - userId, - type: "curator.sprint.invalidated", - payload: { - sprintId: sprintIdFor(existingStartDate), - staleSprintEventId: latest[0]?.id, - staleSprintId: sprintIdFor(existingStartDate), - startDate: existingStartDate, - reason: "onboarding_access_choice_changed", - previousAccess: existingAccess, - previousDurationDays: existingDuration, - access: desired.access, - durationDays: desired.durationDays, - version: CURATOR_PLAN_VERSION, - }, - }); - } - } - return { + const desiredFingerprint = curatorSprintPlanFingerprint({ startDate: existingStartDate, - variantId: existingVariant as CuratorIcpId, + variantId: desiredVariant, durationDays: desired.durationDays, access: desired.access, - planDays: planSeedsForVariant(templateSet, existingStartDate, desired.durationDays, desired.access), - }; + }); + const reconciliation = decideCuratorSprintReconciliation( + { access: existingAccess, durationDays: existingDuration, variantId: existingVariant, planFingerprint: existingFingerprint }, + { ...desired, variantId: desiredVariant, planFingerprint: desiredFingerprint }, + ); + if (reconciliation.action === "reuse") { + return { + startDate: existingStartDate, + variantId: existingVariant, + durationDays: desired.durationDays, + access: desired.access, + planDays: planSeedsForVariant(templateSetFor(existingVariant), existingStartDate, desired.durationDays, desired.access), + }; + } + + const invalidations = await db + .select({ payload: growEvents.payload }) + .from(growEvents) + .where(and( + eq(growEvents.userId, userId), + eq(growEvents.source, CURATOR_SOURCE), + eq(growEvents.type, "curator.sprint.invalidated"), + )) + .orderBy(desc(growEvents.occurredAt)) + .limit(20); + const alreadyInvalidated = invalidations.some((row) => ( + row.payload?.staleSprintEventId === latest[0]?.id + && row.payload?.planFingerprint === desiredFingerprint + )); + if (!alreadyInvalidated) { + await emitCuratorEvent({ + userId, + type: "curator.sprint.invalidated", + payload: { + sprintId: sprintIdFor(existingStartDate), + staleSprintEventId: latest[0]?.id, + staleSprintId: sprintIdFor(existingStartDate), + startDate: existingStartDate, + reason: "onboarding_plan_changed", + previousVariantId: existingVariant, + previousAccess: existingAccess, + previousDurationDays: existingDuration, + previousPlanFingerprint: existingFingerprint, + variantId: desiredVariant, + access: desired.access, + durationDays: desired.durationDays, + planFingerprint: desiredFingerprint, + version: CURATOR_PLAN_VERSION, + }, + }); + } + // Keep the same calendar sprint identity while replacing its incompatible lineage. + replacementStartDate = existingStartDate; } - const variantId = await inferIcpVariant(userId); + const variantId = desiredVariant; const templateSet = templateSetFor(variantId); - const planDays = planSeedsForVariant(templateSet, todayDate, desired.durationDays, desired.access); + const planDays = planSeedsForVariant(templateSet, replacementStartDate, desired.durationDays, desired.access); + const planFingerprint = curatorSprintPlanFingerprint({ + startDate: replacementStartDate, + variantId, + durationDays: desired.durationDays, + access: desired.access, + }); await emitCuratorEvent({ userId, type: "curator.sprint.started", payload: { - startDate: todayDate, - sprintId: sprintIdFor(todayDate), + startDate: replacementStartDate, + sprintId: sprintIdFor(replacementStartDate), durationDays: desired.durationDays, access: desired.access, version: CURATOR_PLAN_VERSION, variantId, + icpId: variantId, icpLabel: templateSet.label, + planFingerprint, + taskPlan: taskPlanForSeeds(replacementStartDate, planDays), }, }); return { - startDate: todayDate, + startDate: replacementStartDate, variantId, durationDays: desired.durationDays, access: desired.access, @@ -1263,6 +1397,20 @@ async function buildCuratorSprintInternal( goals: [templateSet.sprintTheme, templateSet.goal], generatedAt: new Date().toISOString(), durationDays: sprintState.durationDays, + access: sprintState.access, + icpId: sprintState.variantId, + planFingerprint: curatorSprintPlanFingerprint({ + startDate: sprintStartDate, + variantId: sprintState.variantId, + durationDays: sprintState.durationDays, + access: sprintState.access, + }), + taskPlan: curatorSprintTaskPlan({ + startDate: sprintStartDate, + variantId: sprintState.variantId, + durationDays: sprintState.durationDays, + access: sprintState.access, + }), weeks, days, streak, diff --git a/src/v1/curator/curator-types.ts b/src/v1/curator/curator-types.ts index 806a934..4e13516 100644 --- a/src/v1/curator/curator-types.ts +++ b/src/v1/curator/curator-types.ts @@ -120,6 +120,13 @@ export const curatorPlanSchema = z.object({ goals: z.array(z.string()), generatedAt: z.string(), durationDays: z.union([z.literal(2), z.literal(7)]), + access: z.enum(["trial", "full"]), + icpId: z.string(), + planFingerprint: z.string(), + taskPlan: z.array(z.object({ + dayIndex: z.number().int().min(1), + tasks: z.array(z.object({ id: z.string(), title: z.string() })), + })), weeks: z.array(curatorWeekSchema).min(1).max(1), days: z.array(curatorPlanDaySchema).min(2).max(7), streak: curatorStreakSchema, -- 2.49.1 From 5f5622b3a09d93bc7c62f208826fe8795365f521 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Sun, 12 Jul 2026 23:21:52 +0000 Subject: [PATCH 02/16] Fix curator task handoffs and profile state --- docker-compose.override.yml | 6 ++++- src/routes/services.ts | 15 ++++++++--- src/services/service-registry.ts | 10 ++++--- src/v1/curator/curator-store.ts | 24 ++++++++++------- src/v1/curator/curator-user-context.ts | 36 +++++++++++++++++++++++--- 5 files changed, 72 insertions(+), 19 deletions(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 6e4210c..9c1a2dd 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -14,9 +14,10 @@ services: qscore-service-staging: matchmaking-v2-staging: user-service-staging: + social-branding-staging: environment: DATABASE_URL: postgres://growqr:growqr@growqr-postgres:5432/growqr - SOCIAL_BRANDING_SERVICE_URL: http://host.docker.internal:18015 + SOCIAL_BRANDING_SERVICE_URL: http://social-branding-api-1:8011 SOCIAL_BRANDING_PUBLIC_URL: https://social-staging.gqr.puter.wtf PRODUCT_SERVICE_TIMEOUT_MS: 10000 PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS: 240000 @@ -48,3 +49,6 @@ networks: user-service-staging: external: true name: growqr-app_growqr-net + social-branding-staging: + external: true + name: social-branding_default diff --git a/src/routes/services.ts b/src/routes/services.ts index a88fd4e..c76e856 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -519,6 +519,9 @@ async function resolveGrowUserContext(req: Request, userId: string): Promise 0; + const resumeSummary = getString(resumeState.resume_summary ?? resumeState.resumeSummary); + if (resumeSummary) userContext.resume_summary = resumeSummary; } if (socialState) { @@ -534,7 +537,9 @@ async function resolveGrowUserContext(req: Request, userId: string): Promise): boolean { return Boolean( - (Array.isArray(userContext.experience_history) && userContext.experience_history.length > 0) || + userContext.resume_available || + getString(userContext.resume_summary) || + (Array.isArray(userContext.experience_history) && userContext.experience_history.length > 0) || (Array.isArray(userContext.skills) && userContext.skills.length > 0) || getString(userContext.current_role) ); @@ -556,6 +561,9 @@ function composeCandidateProfile(userContext: Record): string { if (currentRole) lines.push(`Current role: ${currentRole}`); if (headline && headline.toLowerCase() !== currentRole?.toLowerCase()) lines.push(`LinkedIn headline: ${headline}`); + const resumeSummary = getString(userContext.resume_summary); + if (resumeSummary) lines.push(`Primary resume summary: ${resumeSummary.length > 600 ? `${resumeSummary.slice(0, 600).trimEnd()}…` : resumeSummary}`); + const skills = stringArray(userContext.skills); if (skills.length) lines.push(`Key skills: ${skills.slice(0, 12).join(", ")}`); @@ -591,7 +599,7 @@ function composeCandidateProfile(userContext: Record): string { if (education.length) lines.push(`Education: ${education.slice(0, 3).join("; ")}`); const summary = getString(userContext.linkedin_summary); - if (summary) lines.push(`Profile summary: ${summary.length > 300 ? `${summary.slice(0, 300).trimEnd()}…` : summary}`); + if (summary && summary !== resumeSummary) lines.push(`LinkedIn summary: ${summary.length > 300 ? `${summary.slice(0, 300).trimEnd()}…` : summary}`); const text = lines.join("\n"); return text.length > 1200 ? `${text.slice(0, 1200).trimEnd()}…` : text; @@ -613,7 +621,8 @@ async function buildPersonalizedConfigurePayload(req: Request, body: JsonObject, difficulty: getString(incomingContext.difficulty) ?? "medium", }; - if (incomingContext.personalize) { + const curatorHandoff = getString(incomingContext.source) === "curator-v1" || Boolean(getString(incomingContext.curatorTaskId)); + if (incomingContext.personalize || curatorHandoff) { const candidateProfile = composeCandidateProfile(userContext); if (candidateProfile) context.candidate_profile = candidateProfile; } diff --git a/src/services/service-registry.ts b/src/services/service-registry.ts index 4cbb609..6af52e7 100644 --- a/src/services/service-registry.ts +++ b/src/services/service-registry.ts @@ -962,11 +962,15 @@ export function buildCuratorServiceRoute(input: CuratorRouteInput) { if (input.taskType) state.taskType = input.taskType; if (service.id === "interview-service") { - state.role = input.targetRole?.trim() || "Product Manager"; - state.type = "behavioral"; + const taskText = `${input.taskTitle ?? ""} ${input.taskSubtitle ?? ""}`.toLowerCase(); + const isIntroductionTask = /(?:60|90)[ -]?second|self[ -]?introduction|professional introduction|client pitch|executive introduction/.test(taskText); + state.role = isIntroductionTask + ? `${input.targetRole?.trim() || "Professional"} self-introduction` + : input.targetRole?.trim() || "General professional"; + state.type = isIntroductionTask ? "warm_up" : "behavioral"; state.persona = input.personaId ?? "payal"; state.duration = input.durationMinutes ?? 5; - state.difficulty = input.difficulty ?? "medium"; + state.difficulty = input.difficulty ?? (isIntroductionTask ? "easy" : "medium"); state.media = input.requestedMode ?? "video"; } diff --git a/src/v1/curator/curator-store.ts b/src/v1/curator/curator-store.ts index 9e51dd8..a9100aa 100644 --- a/src/v1/curator/curator-store.ts +++ b/src/v1/curator/curator-store.ts @@ -5,7 +5,7 @@ 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 { buildCuratorUserContext, type CuratorUserContext } from "./curator-user-context.js"; import { isCuratorIcpId, type CuratorIcpId } from "./icp-registry.js"; import type { CuratorPlan, @@ -1155,8 +1155,9 @@ function buildTask( recentRows: Awaited>, focusDate: string, durationDays: number, - targetRole?: string, + userContext: CuratorUserContext, ): CuratorTask { + const targetRole = userContext.targetRole ?? undefined; seedTask = { ...normalizeActiveCuratorTaskSeed(seedTask), isTrialTask: seedTask.isTrialTask, @@ -1211,10 +1212,15 @@ function buildTask( isPaidTask: seedTask.isPaidTask !== false, isRecoveryPractice: Boolean(seedTask.isRecoveryPractice), }; + const isProfileTask = /complete (?:your )?(?:growqr |professional |executive |freelancer )?profile/i.test(`${task.title} ${task.subtitle}`); + const profileComplete = isProfileTask && (userContext.profile.resumeAvailable || userContext.profile.linkedinAvailable); + const routeTask = isProfileTask && userContext.profile.linkedinAvailable && !userContext.profile.resumeAvailable + ? { ...task, serviceId: "resume-service" as const } + : task; const taskWithRoute = { ...task, - route: buildCuratorTaskDeepLink(task, targetRole), - status: classifyTaskStatus({ task, focusDate, completionRows, recentRows }), + route: buildCuratorTaskDeepLink(routeTask, targetRole), + status: profileComplete ? "completed" as const : classifyTaskStatus({ task, focusDate, completionRows, recentRows }), } satisfies CuratorTask; return taskWithRoute; } @@ -1227,7 +1233,7 @@ function buildTasksForPlanDay( recentRows: Awaited>, focusDate: string, durationDays: number, - targetRole?: string, + userContext: CuratorUserContext, ) { return planDay.plannedTasks.map((task) => ( buildTask( @@ -1242,7 +1248,7 @@ function buildTasksForPlanDay( recentRows, focusDate, durationDays, - targetRole, + userContext, ) )); } @@ -1266,7 +1272,7 @@ 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, sprintState.durationDays, userContext.targetRole ?? undefined); + return buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, date, sprintState.durationDays, userContext); } export async function buildCuratorStreak(userId: string): Promise { @@ -1333,7 +1339,7 @@ async function buildCuratorSprintInternal( const date = addDaysIso(sprintStartDate, index); const planDay = planDays[index]!; const tasks = date <= focusDate - ? buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, focusDate, sprintState.durationDays, userContext.targetRole ?? undefined) + ? buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, focusDate, sprintState.durationDays, userContext) : []; const completedCount = tasks.filter((task) => task.status === "completed").length; const unlockState = focusDate > date ? "completed" : focusDate === date ? "active" : "upcoming"; @@ -1616,7 +1622,7 @@ export async function buildServiceCurationPreview(input: ServiceCurationPreviewI return { ...planDay, date, - tasks: buildTasksForPlanDay(startDate, date, planDay, completionRows, [], date, FULL_SPRINT_DURATION_DAYS, userContext.targetRole ?? undefined), + tasks: buildTasksForPlanDay(startDate, date, planDay, completionRows, [], date, FULL_SPRINT_DURATION_DAYS, userContext as CuratorUserContext), }; }); diff --git a/src/v1/curator/curator-user-context.ts b/src/v1/curator/curator-user-context.ts index 7450df7..cd86d1d 100644 --- a/src/v1/curator/curator-user-context.ts +++ b/src/v1/curator/curator-user-context.ts @@ -3,6 +3,7 @@ import { db } from "../../db/client.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 { config } from "../../config.js"; import type { CuratorTask } from "./curator-types.js"; function isRecord(value: unknown): value is Record { @@ -26,6 +27,10 @@ export type CuratorUserContext = { latestSummary?: string; skills: string[]; }; + profile: { + resumeAvailable: boolean; + linkedinAvailable: boolean; + }; goals: string[]; pastActivity: { recentEventCount: number; @@ -173,7 +178,11 @@ export async function buildCuratorUserContext(userId: string): Promise goalsFromPayload(row.payload)), ]); const skills = uniqueStrings(rows.flatMap((row) => stringArray(asRecord(row.payload).skills))).slice(0, 12); - const latestResume = rows + const latestResume = getString(resumeState.resume_summary ?? resumeState.resumeSummary) ?? rows .map((row) => resumeSummaryFromPayload(row.payload)) .find(Boolean); + const resumeAvailable = Number(resumeState.resume_count ?? 0) > 0 || Boolean(latestResume); + const linkedinAvailable = Boolean( + socialState.linkedin_connected || + getString(socialState.linkedin_profile_id ?? socialState.profile_url ?? socialState.headline ?? socialState.summary), + ); return { userId, targetRole, experienceLevel: experienceLevelFromOnboarding(onboardingPayload) ?? inferExperienceLevel(corpus), resume: { - available: Boolean(latestResume || /\bresume|cv|linkedin\b/i.test(corpus)), + available: resumeAvailable, latestSummary: latestResume, skills, }, + profile: { resumeAvailable, linkedinAvailable }, goals: goals.length ? goals.slice(0, 8) : targetRole ? [targetRole] : [], pastActivity: { recentEventCount: rows.length, @@ -217,6 +232,21 @@ export async function buildCuratorUserContext(userId: string): Promise> { + if (!baseUrl) return {}; + try { + const response = await fetch(new URL(`/api/state/${encodeURIComponent(userId)}`, baseUrl), { + headers: config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : undefined, + signal: AbortSignal.timeout(2_500), + }); + if (!response.ok) return {}; + const value: unknown = await response.json(); + return isRecord(value) ? value : {}; + } catch { + return {}; + } +} + function experienceLevelFromOnboarding(payload: Record): CuratorUserContext["experienceLevel"] | null { const icp = getString(payload.onboarding_icp); if (icp === "student") return "student"; -- 2.49.1 From f848f45429a5bf96261bae46d5fdfcf70c0bcdb5 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Sun, 12 Jul 2026 23:51:44 +0000 Subject: [PATCH 03/16] Remove invented interview role fallback --- src/services/service-registry.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/services/service-registry.ts b/src/services/service-registry.ts index 6af52e7..1200fb1 100644 --- a/src/services/service-registry.ts +++ b/src/services/service-registry.ts @@ -964,9 +964,9 @@ export function buildCuratorServiceRoute(input: CuratorRouteInput) { if (service.id === "interview-service") { const taskText = `${input.taskTitle ?? ""} ${input.taskSubtitle ?? ""}`.toLowerCase(); const isIntroductionTask = /(?:60|90)[ -]?second|self[ -]?introduction|professional introduction|client pitch|executive introduction/.test(taskText); - state.role = isIntroductionTask - ? `${input.targetRole?.trim() || "Professional"} self-introduction` - : input.targetRole?.trim() || "General professional"; + // Curator already resolves this from onboarding/user context. Never invent a + // role here: the task title/subtitle carry the exercise intent separately. + state.role = input.targetRole?.trim(); state.type = isIntroductionTask ? "warm_up" : "behavioral"; state.persona = input.personaId ?? "payal"; state.duration = input.durationMinutes ?? 5; -- 2.49.1 From 2410a4800b4a0d6147eb23bc58e791c603ec8b3b Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 13 Jul 2026 00:10:38 +0000 Subject: [PATCH 04/16] Expose profile asset state in Day 1 tasks --- src/v1/curator/curator-store.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/v1/curator/curator-store.ts b/src/v1/curator/curator-store.ts index a9100aa..3590713 100644 --- a/src/v1/curator/curator-store.ts +++ b/src/v1/curator/curator-store.ts @@ -1170,6 +1170,7 @@ function buildTask( const id = taskIdFor(sprintStartDate, dayIndex, seedTask.taskType, seedTask.serviceId); const missionInstanceId = sprintIdFor(sprintStartDate); const stageId = stageIdFor(dayIndex, weekIndex, seedTask.taskType, seedTask.serviceId); + const isProfileTask = /complete (?:your )?(?:growqr |professional |executive |freelancer |founder |employer )?profile/i.test(`${seedTask.title} ${seedTask.subtitle}`); const task: CuratorTask = { id, date, @@ -1201,6 +1202,10 @@ function buildTask( { label: "Task type", value: seedTask.taskType }, { label: "Service handoff", value: serviceName(seedTask.serviceId) }, ...(targetRole ? [{ label: "Target role", value: targetRole }] : [{ label: "Target role", value: "Complete onboarding to set your role" }]), + ...(isProfileTask ? [ + { label: "Resume", value: userContext.profile.resumeAvailable ? "Added" : "Missing" }, + { label: "LinkedIn", value: userContext.profile.linkedinAvailable ? "Connected" : "Missing" }, + ] : []), { label: "Sprint day", value: `Day ${dayIndex}/${durationDays}` }, ], contextNarrative: contextNarrative(seedTask, weekTheme, weekSummary), @@ -1212,7 +1217,6 @@ function buildTask( isPaidTask: seedTask.isPaidTask !== false, isRecoveryPractice: Boolean(seedTask.isRecoveryPractice), }; - const isProfileTask = /complete (?:your )?(?:growqr |professional |executive |freelancer )?profile/i.test(`${task.title} ${task.subtitle}`); const profileComplete = isProfileTask && (userContext.profile.resumeAvailable || userContext.profile.linkedinAvailable); const routeTask = isProfileTask && userContext.profile.linkedinAvailable && !userContext.profile.resumeAvailable ? { ...task, serviceId: "resume-service" as const } -- 2.49.1 From 8cb541376674ac623812bd9ca9d54d0c8b90b12c Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 13 Jul 2026 00:21:57 +0000 Subject: [PATCH 05/16] Keep presentation handoffs task-focused --- src/services/service-registry.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/service-registry.ts b/src/services/service-registry.ts index 1200fb1..b0ee8c2 100644 --- a/src/services/service-registry.ts +++ b/src/services/service-registry.ts @@ -963,10 +963,10 @@ export function buildCuratorServiceRoute(input: CuratorRouteInput) { if (service.id === "interview-service") { const taskText = `${input.taskTitle ?? ""} ${input.taskSubtitle ?? ""}`.toLowerCase(); - const isIntroductionTask = /(?:60|90)[ -]?second|self[ -]?introduction|professional introduction|client pitch|executive introduction/.test(taskText); + const isIntroductionTask = /(?:60|90)[ -]?second|\bintroduction\b|\bpitch\b|\bpresentation\b/.test(taskText); // Curator already resolves this from onboarding/user context. Never invent a // role here: the task title/subtitle carry the exercise intent separately. - state.role = input.targetRole?.trim(); + state.role = isIntroductionTask ? undefined : input.targetRole?.trim(); state.type = isIntroductionTask ? "warm_up" : "behavioral"; state.persona = input.personaId ?? "payal"; state.duration = input.durationMinutes ?? 5; -- 2.49.1 From 8617be7e7c53d165fae869631af1246ebdde5b50 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 13 Jul 2026 01:07:43 +0000 Subject: [PATCH 06/16] Preserve task details on unavailable handoffs --- src/services/service-registry.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/services/service-registry.ts b/src/services/service-registry.ts index b0ee8c2..3abaeba 100644 --- a/src/services/service-registry.ts +++ b/src/services/service-registry.ts @@ -947,6 +947,9 @@ function unavailableServiceRoute(input: CuratorRouteInput) { return appendQuery("/agents/service-unavailable", { ...curatorBaseParams(input), serviceId: input.serviceId, + taskTitle: input.taskTitle, + taskSubtitle: input.taskSubtitle, + taskType: input.taskType, }); } -- 2.49.1 From fdf4f6468db94f2b971dd4299985ba0def787132 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 13 Jul 2026 06:10:15 +0000 Subject: [PATCH 07/16] Cover task-aware curator handoffs --- scripts/curator-static-registry.test.ts | 28 ++++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/scripts/curator-static-registry.test.ts b/scripts/curator-static-registry.test.ts index 07c5362..0379612 100644 --- a/scripts/curator-static-registry.test.ts +++ b/scripts/curator-static-registry.test.ts @@ -13,10 +13,12 @@ const ICP_IDS: CuratorIcpId[] = [ const LIVE_ROUTE_PREFIX: Record = { "courses-service": "/agents/courses", + "qscore-service": "/agents/qscore", "resume-service": "/agents/resume", "interview-service": "/agents/interview", "roleplay-service": "/agents/roleplay", "matchmaking-service": "/agents/matchmaking", + "social-branding-service": "/agents/social-branding", }; const EXPECTED_FOCUS: Record = { @@ -60,14 +62,12 @@ async function main() { }); try { - assert(preview.version === "icp-v7-static", `${icpId}: expected static plan version`); - assert(preview.idempotency.promptHash === "static-task-registry", `${icpId}: prompt hash should prove static registry mode`); - assert(preview.plan.days.length === 30, `${icpId}: expected 30 generated days`); - assert(preview.plan.calendarWeeks.length === 5, `${icpId}: expected 5 sprint-relative calendar weeks`); + assert(preview.version === "icp-v10-static", `${icpId}: expected static plan version`); + assert(preview.plan.days.length === 7, `${icpId}: expected 7 generated days`); + assert(preview.plan.calendarWeeks.length === 1, `${icpId}: expected one sprint-relative calendar week`); const firstSevenFocus = preview.plan.days.slice(0, 7).map((day) => day.focus); assert(JSON.stringify(firstSevenFocus) === JSON.stringify(EXPECTED_FOCUS[icpId]), `${icpId}: first 7 focus labels must match 7-day sprint doc`); - assert(JSON.stringify(firstSevenFocus) === JSON.stringify(preview.plan.days.slice(7, 14).map((day) => day.focus)), `${icpId}: 7-day sprint should repeat after day 7 for now`); const dayOneTitles = preview.plan.days[0]?.tasks.map((task) => task.title); assert(JSON.stringify(dayOneTitles) === JSON.stringify(EXPECTED_DAY_ONE_TITLES[icpId]), `${icpId}: day 1 task titles must match docs`); @@ -84,7 +84,7 @@ async function main() { for (const task of day.tasks) { assert(task.missionId === "curator-sprint", `${icpId} ${task.id}: missionId mismatch`); - assert(task.missionInstanceId?.startsWith("curator-sprint:icp-v7-static:"), `${icpId} ${task.id}: missionInstanceId should use static version`); + assert(task.missionInstanceId?.startsWith("curator-sprint:icp-v10-static:"), `${icpId} ${task.id}: missionInstanceId should use static version`); assert(task.stageId?.includes(`${task.serviceId?.replace("-service", "")}`), `${icpId} ${task.id}: stageId should include service segment`); assert(task.id.includes(`${task.serviceId?.replace("-service", "")}`), `${icpId} ${task.id}: taskId should include service segment`); assert(task.actorName !== "Vera" && task.actorName !== "Kai" && task.actorName !== "Mira" && task.actorName !== "Lyra", `${icpId} ${task.id}: agent persona leaked into actorName`); @@ -102,8 +102,20 @@ async function main() { assert(url.searchParams.get("missionId") === task.missionId, `${icpId} ${task.id}: route missionId mismatch`); assert(url.searchParams.get("missionInstanceId") === task.missionInstanceId, `${icpId} ${task.id}: route missionInstanceId mismatch`); assert(url.searchParams.get("stageId") === task.stageId, `${icpId} ${task.id}: route stageId mismatch`); + assert(url.searchParams.get("taskTitle") === task.title, `${icpId} ${task.id}: route taskTitle mismatch`); + assert(url.searchParams.get("taskSubtitle") === task.subtitle, `${icpId} ${task.id}: route taskSubtitle mismatch`); + assert(url.searchParams.get("taskType") === task.taskType, `${icpId} ${task.id}: route taskType mismatch`); - if (task.serviceId === "interview-service" || task.serviceId === "roleplay-service") { + if (task.serviceId === "interview-service") { + const taskText = `${task.title} ${task.subtitle}`.toLowerCase(); + const isIntroductionTask = /(?:60|90)[ -]?second|\bintroduction\b|\bpitch\b|\bpresentation\b/.test(taskText); + assert(url.searchParams.get("type") === (isIntroductionTask ? "warm_up" : "behavioral"), `${icpId} ${task.id}: interview type must match task intent`); + assert(url.searchParams.get("role") === (isIntroductionTask ? null : "Product Manager"), `${icpId} ${task.id}: interview role must match task intent`); + assert(url.searchParams.get("difficulty") === (isIntroductionTask ? "easy" : "medium"), `${icpId} ${task.id}: interview difficulty must match task intent`); + assert(url.searchParams.get("duration") === "5", `${icpId} ${task.id}: interview route should carry duration`); + } + + if (task.serviceId === "roleplay-service") { assert(url.searchParams.get("role") === "Product Manager", `${icpId} ${task.id}: practice route should preserve target role`); assert(url.searchParams.get("duration") === "5", `${icpId} ${task.id}: practice route should carry duration`); } @@ -119,7 +131,7 @@ async function main() { process.exit(1); } - console.log(`curator-static-registry tests passed for ${ICP_IDS.length} ICPs, 30 days each, 4 doc-backed tasks per day`); + console.log(`curator-static-registry tests passed for ${ICP_IDS.length} ICPs, 7 days each, 4 doc-backed tasks per day`); } main().catch((err) => { -- 2.49.1 From e444d1be15fe0de6c244e49ce7c2394a8e8c6cf5 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 13 Jul 2026 06:52:59 +0000 Subject: [PATCH 08/16] Fix compressed resume proxy responses --- src/routes/services.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/routes/services.ts b/src/routes/services.ts index c76e856..c6c1508 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -396,6 +396,17 @@ function parseJsonBody(body: ArrayBuffer | undefined, headers: Headers): JsonObj } } +function decodedProxyHeaders(headers: Headers) { + const forwarded = new Headers(headers); + // Node fetch transparently decodes gzip/br bodies but retains the upstream + // encoding and byte-length headers. Forwarding those stale headers makes the + // next proxy/browser decode an already-decoded body and fail. + forwarded.delete("content-encoding"); + forwarded.delete("content-length"); + forwarded.delete("transfer-encoding"); + return forwarded; +} + async function proxyResumeRequest(req: Request, rest: string, userId: string) { const incoming = new URL(req.url); const normalizedRest = rest @@ -419,6 +430,7 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) { headers.delete("cookie"); headers.set("authorization", `Bearer ${config.a2aAllowedKey}`); headers.set("x-growqr-user", userId); + headers.set("accept-encoding", "identity"); const method = req.method.toUpperCase(); const body = ["GET", "HEAD"].includes(method) ? undefined : await req.arrayBuffer(); @@ -436,7 +448,11 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) { }); if (method === "GET" || method === "HEAD") { - return new Response(res.body, { status: res.status, statusText: res.statusText, headers: res.headers }); + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: decodedProxyHeaders(res.headers), + }); } const responseBuffer = await res.arrayBuffer(); @@ -461,7 +477,7 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) { return new Response(responseBuffer, { status: res.status, statusText: res.statusText, - headers: res.headers, + headers: decodedProxyHeaders(res.headers), }); } -- 2.49.1 From c1d2acd96d08a88750b81cc874ddc7e1beb8e1e2 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 13 Jul 2026 20:14:14 +0000 Subject: [PATCH 09/16] refactor: deliver raw GrowEvents to QScore --- drizzle/0014_event_outbox.sql | 21 ++ drizzle/meta/_journal.json | 7 + package.json | 1 + scripts/count-fallback.test.ts | 84 ++--- scripts/matchmaking-events.test.ts | 64 ---- scripts/qscore-raw-event-contract.test.ts | 72 ++++ scripts/service-ingest-projector.test.ts | 124 ++----- scripts/signal-registry.test.ts | 246 +------------ src/actors/analytics/analytics-actor.ts | 36 +- src/config.ts | 2 +- src/db/ensure-runtime-schema.ts | 25 ++ src/db/schema.ts | 30 ++ src/events/envelope.ts | 6 +- src/events/onboarding-ledger.ts | 11 +- src/events/onboarding-qscore.ts | 75 ---- src/events/projectors/qscore-projector.ts | 400 +--------------------- src/events/qscore-event-outbox.ts | 197 +++++++++++ src/events/record-grow-event.ts | 64 +++- src/home/home-feed.ts | 46 +-- src/home/types.ts | 2 +- src/index.ts | 2 + src/routes/events.ts | 4 +- src/routes/services.ts | 70 ++-- src/services/qscore-proxy.ts | 53 +++ src/services/service-agents.ts | 109 +----- src/v1/analytics/analytics-routes.ts | 25 +- src/v1/curator/curator-user-context.ts | 13 +- src/v1/qscore/qscore-routes.ts | 69 ++-- 28 files changed, 667 insertions(+), 1191 deletions(-) create mode 100644 drizzle/0014_event_outbox.sql create mode 100644 scripts/qscore-raw-event-contract.test.ts delete mode 100644 src/events/onboarding-qscore.ts create mode 100644 src/events/qscore-event-outbox.ts diff --git a/drizzle/0014_event_outbox.sql b/drizzle/0014_event_outbox.sql new file mode 100644 index 0000000..0526436 --- /dev/null +++ b/drizzle/0014_event_outbox.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS "event_outbox" ( + "id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL, + "topic" text NOT NULL, + "aggregate_id" text NOT NULL, + "destination" text NOT NULL, + "payload" jsonb NOT NULL, + "headers" jsonb DEFAULT '{}'::jsonb NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "available_at" timestamp with time zone DEFAULT now() NOT NULL, + "locked_at" timestamp with time zone, + "published_at" timestamp with time zone, + "last_error" text, + "response" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "event_outbox_status_check" CHECK ("status" IN ('pending', 'processing', 'published')) +); + +CREATE INDEX IF NOT EXISTS "event_outbox_delivery_idx" ON "event_outbox" USING btree ("status", "available_at"); +CREATE UNIQUE INDEX IF NOT EXISTS "event_outbox_topic_aggregate_idx" ON "event_outbox" USING btree ("topic", "aggregate_id"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8ae4d2d..4bd25f0 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1783801800000, "tag": "0013_onboarding", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1783987200000, + "tag": "0014_event_outbox", + "breakpoints": true } ] } diff --git a/package.json b/package.json index 5995cbd..e762ea8 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "test:passive-actions": "tsx scripts/passive-actions.test.ts", "test:curator-static": "tsx scripts/curator-static-registry.test.ts", "test:curator-reconcile": "tsx scripts/curator-persisted-reconcile.test.ts", + "test:qscore-raw-events": "tsx scripts/qscore-raw-event-contract.test.ts", "start": "node dist/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", "workflows:smoke": "tsx src/workflows/smoke-test.ts", diff --git a/scripts/count-fallback.test.ts b/scripts/count-fallback.test.ts index 1ed72da..172f0d6 100644 --- a/scripts/count-fallback.test.ts +++ b/scripts/count-fallback.test.ts @@ -1,68 +1,24 @@ import assert from "node:assert/strict"; -import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js"; -import type { GrowEventRow } from "../src/db/schema.js"; +import { canonicalGrowEventEnvelope } from "../src/events/record-grow-event.js"; +import { normalizeGrowEvent } from "../src/events/normalize.js"; -/** - * Test: Completed interview and roleplay events MUST emit at least one signal - * even when session_count/scenario_count is absent. A completion event with no - * count and no rubric data should still produce a single-completion signal. - */ - -function event(overrides: Partial & { type: string; source: string; payload: Record }): GrowEventRow { - return { - id: `event-${overrides.type}`, - userId: "user_test", - orgId: null, - source: overrides.source, - type: overrides.type, +/** Bare completions stay bare. QScore may aggregate them as real events, but + * the backend must never invent session_count/scenario_count or a score. */ +for (const [source, type] of [ + ["interview-service", "interview.session.completed"], + ["roleplay-service", "roleplay.scenario.completed"], +] as const) { + const event = canonicalGrowEventEnvelope(normalizeGrowEvent({ + id: `event-${type}`, + source, + type, category: "service", - occurredAt: new Date("2026-07-09T00:00:00.000Z"), - receivedAt: new Date("2026-07-09T00:00:01.000Z"), - mission: overrides.mission ?? null, - subject: overrides.subject ?? null, - correlation: overrides.correlation ?? null, - payload: overrides.payload, - raw: {}, - dedupeKey: null, - processingStatus: "pending", - processingError: null, - processedAt: null, - }; + occurredAt: "2026-07-14T00:00:00.000Z", + payload: {}, + }), "real-user"); + assert.deepEqual(event.payload, {}); + assert.equal("score" in event.payload as object, false); + assert.equal("session_count" in event.payload as object, false); + assert.equal("scenario_count" in event.payload as object, false); } - -// ── Interview completed with NO count and NO rubric ────────────────────────── -const interviewSignals = extractQscoreSignals(event({ - source: "interview-service", - type: "interview.session.completed", - payload: {}, // nothing — just a bare completion event -})); - -assert.ok( - interviewSignals.length > 0, - "Interview completed event with no count/rubric must emit at least one signal", -); - -assert.ok( - interviewSignals.some((s) => s.signalId === "interview.sessions_completed" && s.score > 0), - "Bare interview completion should emit interview.sessions_completed with positive score", -); - -// ── Roleplay completed with NO count and NO rubric ─────────────────────────── -const roleplaySignals = extractQscoreSignals(event({ - source: "roleplay-service", - type: "roleplay.scenario.completed", - payload: {}, // nothing — just a bare completion event -})); - -assert.ok( - roleplaySignals.length > 0, - "Roleplay completed event with no count/rubric must emit at least one signal", -); - -assert.ok( - roleplaySignals.some((s) => s.signalId === "roleplay.scenarios_completed" && s.score > 0), - "Bare roleplay completion should emit roleplay.scenarios_completed with positive score", -); - -console.log("count-fallback tests passed"); -process.exit(0); +console.log("no count fallback tests passed"); diff --git a/scripts/matchmaking-events.test.ts b/scripts/matchmaking-events.test.ts index 59475ce..45a1106 100644 --- a/scripts/matchmaking-events.test.ts +++ b/scripts/matchmaking-events.test.ts @@ -1,29 +1,5 @@ import assert from "node:assert/strict"; -import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js"; import { buildMatchmakingGatewayEvent } from "../src/services/matchmaking-events.js"; -import type { GrowEventRow } from "../src/db/schema.js"; - -function event(overrides: Partial & { type: string; payload: Record }): GrowEventRow { - return { - id: `event-${overrides.type}`, - userId: "user_test", - orgId: null, - source: "matchmaking-v2", - type: overrides.type, - category: "service", - occurredAt: new Date("2026-07-09T00:00:00.000Z"), - receivedAt: new Date("2026-07-09T00:00:01.000Z"), - mission: overrides.mission ?? null, - subject: overrides.subject ?? null, - correlation: overrides.correlation ?? null, - payload: overrides.payload, - raw: {}, - dedupeKey: null, - processingStatus: "pending", - processingError: null, - processedAt: null, - }; -} const saved = buildMatchmakingGatewayEvent({ userId: "user_test", @@ -62,31 +38,6 @@ assert.equal(saved.payload.curatorTaskId, "task_123"); assert.equal(saved.payload.opportunityId, "job_123"); assert.equal(saved.payload.action, "mark_saved"); -const generatedSignals = extractQscoreSignals(event({ - type: "matchmaking.matches.generated", - payload: { - action: "run_search", - matchCount: 6, - scanned: 120, - result: { status: "completed" }, - }, -})); -assert.ok( - generatedSignals.some((signal) => signal.signalId === "matching.match_rate" && signal.score > 0), - "generated matches should produce a match rate signal from match counts", -); - -const savedSignals = extractQscoreSignals(event({ - type: "matchmaking.match.saved", - payload: { - action: "mark_saved", - opportunityId: "job_123", - curatorTaskId: "task_123", - result: { status: "completed" }, - }, -})); -assert.equal(savedSignals.length, 0, "saved opportunities no longer emit a legacy signal"); - const reviewed = buildMatchmakingGatewayEvent({ userId: "user_test", action: "record_feedback", @@ -98,20 +49,5 @@ const reviewed = buildMatchmakingGatewayEvent({ }); assert.equal(reviewed.type, "matchmaking.matches.reviewed"); -const appliedSignals = extractQscoreSignals(event({ - type: "matchmaking.match.applied", - payload: { - action: "mark_applied", - opportunityId: "job_123", - curatorTaskId: "task_123", - applications_submitted: 1, - result: { status: "completed" }, - }, -})); -assert.ok( - appliedSignals.some((signal) => signal.signalId === "matching.applications_submitted" && signal.score > 0), - "applied opportunities should produce an applications submitted signal", -); - console.log("matchmaking-events tests passed"); process.exit(0); diff --git a/scripts/qscore-raw-event-contract.test.ts b/scripts/qscore-raw-event-contract.test.ts new file mode 100644 index 0000000..e754290 --- /dev/null +++ b/scripts/qscore-raw-event-contract.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { canonicalGrowEventEnvelope } from "../src/events/record-grow-event.js"; +import { normalizeGrowEvent } from "../src/events/normalize.js"; +import { + projectedSignalsFromQscoreResponse, + QSCORE_GROW_EVENT_DESTINATION, + QSCORE_GROW_EVENT_TOPIC, +} from "../src/events/qscore-event-outbox.js"; + +const input = { + id: "evt-real-1", + userId: "untrusted-body-user", + orgId: "growqr", + source: "interview-service", + type: "interview.review.completed", + category: "service" as const, + occurredAt: "2026-07-14T10:00:00.000Z", + mission: { instanceId: "mission-1", missionId: "interview-to-offer", stageId: "mock-interview" }, + subject: { serviceId: "interview", externalId: "session-1" }, + correlation: { sessionId: "session-1", taskId: "task-1" }, + payload: { + review: { + overall_score: 78, + rubric_scores: { communication: 82, technical_accuracy: 70 }, + }, + }, + dedupeKey: "interview:session-1:review", +}; + +const normalized = normalizeGrowEvent(input, { userId: "clerk-user-real" }); +const canonical = canonicalGrowEventEnvelope(normalized, "clerk-user-real"); +assert.deepEqual(canonical, { + ...input, + userId: "clerk-user-real", + raw: input, +}); +assert.equal(QSCORE_GROW_EVENT_TOPIC, "qscore.grow_event.raw"); +assert.equal(QSCORE_GROW_EVENT_DESTINATION, "/v1/events/ingest"); + +const projected = projectedSignalsFromQscoreResponse({ + projected_signals: [ + { signal_id: "interview.overall_score", score: 45, present: true, evidence: { eventId: input.id } }, + { signal_id: "interview.sessions_completed", evidence: { eventId: input.id } }, + ], +}); +assert.deepEqual(projected, [ + { signalId: "interview.overall_score", score: 45, present: true, raw: { eventId: input.id } }, + { signalId: "interview.sessions_completed", raw: { eventId: input.id } }, +]); + +const projectorSource = await readFile(new URL("../src/events/projectors/qscore-projector.ts", import.meta.url), "utf8"); +for (const forbidden of ["clampScore", "BASELINE_SCORE", "extractResumeSignals", "forwardSignalsToQscoreService"]) { + assert.equal(projectorSource.includes(forbidden), false, `projector must not contain ${forbidden}`); +} + +const serviceAgentSource = await readFile(new URL("../src/services/service-agents.ts", import.meta.url), "utf8"); +assert.equal(/q_score\s*:\s*\d/.test(serviceAgentSource), false, "service agents must not inject numeric QScore data"); + +const homeSource = await readFile(new URL("../src/home/home-feed.ts", import.meta.url), "utf8"); +for (const forbidden of ["|| 47", "score - 29", "Math.max(35", "ensureOnboardingBaselineQscore"]) { + assert.equal(homeSource.includes(forbidden), false, `Home must not contain synthetic QScore fallback ${forbidden}`); +} + +const servicesSource = await readFile(new URL("../src/routes/services.ts", import.meta.url), "utf8"); +assert.equal( + /app\.get\("\/qscore\/current"[\s\S]*?qscore\.review\.opened/.test(servicesSource), + false, + "polling the current QScore must remain a read and must not emit a synthetic review event", +); + +console.log("qscore raw-event ownership contract: ok"); diff --git a/scripts/service-ingest-projector.test.ts b/scripts/service-ingest-projector.test.ts index 564c301..400e775 100644 --- a/scripts/service-ingest-projector.test.ts +++ b/scripts/service-ingest-projector.test.ts @@ -1,106 +1,30 @@ import assert from "node:assert/strict"; -import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js"; -import type { GrowEventRow } from "../src/db/schema.js"; +import { canonicalGrowEventEnvelope } from "../src/events/record-grow-event.js"; +import { normalizeGrowEvent } from "../src/events/normalize.js"; -/** - * Test: Service-ingest projector behavior across multiple service sources. - * Verifies that canonical Grow events from different services produce - * registry-valid signals with correct source attribution. - */ +/** Every service family is forwarded as a canonical event with unchanged real + * payload. Extraction and signal validation now run only in QScore. */ +const cases = [ + ["resume-builder", "resume.analysis.completed", { analysis: { score_breakdown: [{ category: "ATS Compatibility", score: 75 }] } }], + ["qscore-service", "qscore.signal.projected", { score: 65 }], + ["social-branding-service", "brand.profile.updated", { profile: { headline: "Real headline" } }], + ["unknown-service", "service.completed", {}], + ["matchmaking-v2", "matchmaking.match.applied", { applications_submitted: 3 }], +] as const; -function event(overrides: Partial & { type: string; source: string; payload: Record }): GrowEventRow { - return { - id: `event-${overrides.type}`, - userId: "user_test", - orgId: null, - source: overrides.source, - type: overrides.type, +for (const [source, type, payload] of cases) { + const canonical = canonicalGrowEventEnvelope(normalizeGrowEvent({ + id: `event-${type}`, + userId: "real-user", + source, + type, category: "service", - occurredAt: new Date("2026-07-09T00:00:00.000Z"), - receivedAt: new Date("2026-07-09T00:00:01.000Z"), - mission: overrides.mission ?? null, - subject: overrides.subject ?? null, - correlation: overrides.correlation ?? null, - payload: overrides.payload, - raw: {}, - dedupeKey: null, - processingStatus: "pending", - processingError: null, - processedAt: null, - }; + occurredAt: "2026-07-14T00:00:00.000Z", + payload, + }), "real-user"); + assert.equal(canonical.source, source); + assert.equal(canonical.type, type); + assert.deepEqual(canonical.payload, payload); } -// ── 1. Resume analysis produces resume signals ────────────────────────────── -{ - const signals = extractQscoreSignals(event({ - source: "resume-builder", - type: "resume.analysis.completed", - payload: { analysis: { score_breakdown: [{ category: "ATS Compatibility", score: 75 }] } }, - })); - assert.ok(signals.length > 0, "resume analysis event should produce signals"); - assert.ok( - signals.some((s) => s.signalId.startsWith("resume.")), - "resume source should produce resume.* signals", - ); -} - -// ── 2. Generic scored service event (qscore source) produces completion score ─ -{ - const signals = extractQscoreSignals(event({ - source: "qscore-service", - type: "qscore.signal.projected", - payload: { score: 65 }, - })); - assert.ok( - signals.some((s) => s.score === 65), - "qscore source event with score should forward the score", - ); -} - -// ── 3. Social branding pre-computed signals forwarded as-is ───────────────── -{ - const signals = extractQscoreSignals(event({ - source: "social-branding-service", - type: "brand.profile.updated", - payload: { - qscore_signals: [ - { signalId: "linkedin.headline_quality", score: 70 }, - { signalId: "linkedin.summary_complete", score: 65 }, - ], - }, - })); - assert.ok( - signals.some((s) => s.signalId === "linkedin.headline_quality" && s.score === 70), - "social branding should forward pre-computed signals as-is", - ); - assert.ok( - signals.some((s) => s.signalId === "linkedin.summary_complete"), - "social branding should forward all pre-computed signals", - ); -} - -// ── 4. Unknown source with no score produces no signals ────────────────────── -{ - const signals = extractQscoreSignals(event({ - source: "unknown-service", - type: "service.completed", - payload: {}, - })); - assert.equal(signals.length, 0, "unknown source with no score should produce no signals"); -} - -// ── 5. Matchmaking applied event with count ────────────────────────────────── -{ - const signals = extractQscoreSignals(event({ - source: "matchmaking-v2", - type: "matchmaking.match.applied", - payload: { applications_submitted: 3 }, - })); - assert.ok( - signals.some((s) => s.signalId === "matching.applications_submitted" && s.score > 0), - "matchmaking applied with count should produce applications_submitted signal", - ); -} - -console.log("service-ingest-projector tests passed"); -process.exit(0); +console.log("service raw-ingest forwarding tests passed"); diff --git a/scripts/signal-registry.test.ts b/scripts/signal-registry.test.ts index 223c451..8f3e03c 100644 --- a/scripts/signal-registry.test.ts +++ b/scripts/signal-registry.test.ts @@ -1,238 +1,30 @@ import assert from "node:assert/strict"; -import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js"; -import { ONBOARDING_BASELINE_SIGNAL_ID } from "../src/events/onboarding-qscore.js"; -import type { GrowEventRow } from "../src/db/schema.js"; +import { readFile } from "node:fs/promises"; +import { canonicalGrowEventEnvelope } from "../src/events/record-grow-event.js"; +import { normalizeGrowEvent } from "../src/events/normalize.js"; /** - * Test: All signal IDs emitted by the projector must be valid members of the - * qscore_service v2 signal registry. The onboarding baseline signal must be - * "onboarding.completed" (not the non-registry "onboarding.completed_baseline"). + * Signal registration and all 67 extraction policies belong to QScore. This + * backend contract test prevents the old local registry/projector from being + * reintroduced while proving the complete raw evidence reaches QScore. */ - -// v2 registry — union of all signal_ids across all 7 profession formula JSONs. -// Sourced from qscore_service/app/scoring/formula/v2/*/formula.json -const V2_REGISTRY = new Set([ - // resume - "resume.uploaded", - "resume.ats_compatibility", - "resume.keyword_relevance", - "resume.quantified_achievements", - "resume.grammar_clarity", - "resume.format_structure", - "resume.contact_info", - "resume.page_count", - // interview - "interview.sessions_completed", - "interview.overall_score", - "interview.response_clarity", - "interview.technical_accuracy", - "interview.behavioral_quality", - "interview.improvement_over_time", - "interview.type_diversity", - // roleplay - "roleplay.scenarios_completed", - "roleplay.situational_judgment", - "roleplay.empathy_demonstrated", - "roleplay.problem_resolution", - "roleplay.communication_effectiveness", - // courses - "courses.started", - "courses.completed", - "courses.completion_rate", - "courses.difficulty", - "courses.pathway_relevance", - // matching - "matching.jobs_viewed", - "matching.applications_submitted", - "matching.application_quality", - "matching.match_rate", - // onboarding - "onboarding.completed", - "onboarding.initial_skills_score", - "onboarding.goals_clarity", - "onboarding.profile_completeness", - "onboarding.self_assessment_accuracy", - "onboarding.motivation_quality", - // coverletter - "coverletter.uploaded", - "coverletter.customization", - "coverletter.persuasiveness", - // linkedin - "linkedin.account_connected", - "linkedin.profile_photo", - "linkedin.headline_quality", - "linkedin.summary_complete", - "linkedin.experience_detail", - "linkedin.skills_listed", - // portfolio - "portfolio.website_exists", - "portfolio.website_quality", - "portfolio.content_relevance", - // content - "content.articles_count", - "content.quality", - "content.platform_credibility", - // ugc - "ugc.speaking_count", - "ugc.event_credibility", - "ugc.verifiable_evidence", - // assessments - "assessments.taken", - "assessments.passed", - "assessments.avg_score", - "assessments.skill_diversity", - // presentations - "presentations.submitted", - "presentations.content_quality", - "presentations.delivery_score", - "presentations.visual_aids", - // events - "events.webinars_attended", - "events.meetups_attended", - "events.participation_quality", - // mentor - "mentor.feedback_count", - "mentor.score", - "mentor.implementation_rate", -]); - -function event(overrides: Partial & { type: string; payload: Record; source: string }): GrowEventRow { - return { - id: `event-${overrides.type}`, - userId: "user_test", - orgId: null, - source: overrides.source, - type: overrides.type, - category: "service", - occurredAt: new Date("2026-07-09T00:00:00.000Z"), - receivedAt: new Date("2026-07-09T00:00:01.000Z"), - mission: overrides.mission ?? null, - subject: overrides.subject ?? null, - correlation: overrides.correlation ?? null, - payload: overrides.payload, - raw: {}, - dedupeKey: null, - processingStatus: "pending", - processingError: null, - processedAt: null, - }; -} - -// ── 1. Onboarding signal ID must be the registry-valid "onboarding.completed" ─ -assert.equal( - ONBOARDING_BASELINE_SIGNAL_ID, - "onboarding.completed", - "Onboarding baseline signal ID must be 'onboarding.completed' (registry-valid), not 'onboarding.completed_baseline'", -); -assert.ok( - V2_REGISTRY.has(ONBOARDING_BASELINE_SIGNAL_ID), - "Onboarding signal ID must appear in v2 registry", -); - -// ── 2. All projector signal IDs must be in the registry ────────────────────── -// Construct representative events for each source family and check every emitted -// signal ID is registry-valid. - -// Resume with full breakdown -const resumeSignals = extractQscoreSignals(event({ +const raw = { + id: "evt-full-evidence", source: "resume-builder", type: "resume.analysis.completed", + category: "service" as const, + occurredAt: "2026-07-14T00:00:00.000Z", payload: { analysis: { - score_breakdown: [ - { category: "ATS Compatibility", score: 70 }, - { category: "Content Quality", score: 65 }, - { category: "Formatting", score: 80 }, - ], - dimensional_scores: [ - { dimension: "Keywords", score: 60 }, - { dimension: "Quantification", score: 55 }, - ], + score_breakdown: [{ category: "ATS Compatibility", score: 70 }], + dimensional_scores: [{ dimension: "Keywords", score: 60 }], }, }, -})); -for (const s of resumeSignals) { - assert.ok( - V2_REGISTRY.has(s.signalId), - `Resume signal '${s.signalId}' is not in the v2 registry`, - ); -} +}; +const event = canonicalGrowEventEnvelope(normalizeGrowEvent(raw), "real-user"); +assert.deepEqual(event.payload, raw.payload, "raw evidence must reach QScore without signal extraction"); -// Interview with rubric -const interviewSignals = extractQscoreSignals(event({ - source: "interview-service", - type: "interview.session.completed", - payload: { - review: { - overall_score: 72, - rubric_scores: { content_quality: 70, role_alignment: 68, language: 65 }, - historical_comparison: { overall_delta: 5 }, - }, - session_count: 3, - type_diversity: 2, - }, -})); -for (const s of interviewSignals) { - assert.ok( - V2_REGISTRY.has(s.signalId), - `Interview signal '${s.signalId}' is not in the v2 registry`, - ); -} - -// Roleplay with rubric -const roleplaySignals = extractQscoreSignals(event({ - source: "roleplay-service", - type: "roleplay.scenario.completed", - payload: { - review: { - rubric_scores: { - scenario_adherence: 75, - emotional_intelligence: 70, - adaptability: 68, - content: 72, - }, - }, - scenario_count: 4, - }, -})); -for (const s of roleplaySignals) { - assert.ok( - V2_REGISTRY.has(s.signalId), - `Roleplay signal '${s.signalId}' is not in the v2 registry`, - ); -} - -// Courses -const courseSignals = extractQscoreSignals(event({ - source: "courses-service", - type: "course.completed", - payload: { - courseId: "c1", - completed_count: 2, - watchPct: 0.9, - difficulty: "intermediate", - label: "high relevance", - }, -})); -for (const s of courseSignals) { - assert.ok( - V2_REGISTRY.has(s.signalId), - `Course signal '${s.signalId}' is not in the v2 registry`, - ); -} - -// Matchmaking -const matchSignals = extractQscoreSignals(event({ - source: "matchmaking-v2", - type: "matchmaking.feed.viewed", - payload: { jobs_viewed: 25 }, -})); -for (const s of matchSignals) { - assert.ok( - V2_REGISTRY.has(s.signalId), - `Matchmaking signal '${s.signalId}' is not in the v2 registry`, - ); -} - -console.log("signal-registry tests passed"); -process.exit(0); +const projector = await readFile(new URL("../src/events/projectors/qscore-projector.ts", import.meta.url), "utf8"); +assert.equal(projector.includes("extractQscoreSignals"), false); +assert.equal(projector.includes("signalId"), false); +console.log("signal registry ownership test passed"); diff --git a/src/actors/analytics/analytics-actor.ts b/src/actors/analytics/analytics-actor.ts index a596279..9e84c4e 100644 --- a/src/actors/analytics/analytics-actor.ts +++ b/src/actors/analytics/analytics-actor.ts @@ -4,12 +4,11 @@ import { db } from "../../db/client.js"; import { growActiveMissions, growEvents, - growQscoreSignals, missionActions, } from "../../db/schema.js"; import { listActiveMissionsPg } from "../../grow/persistence.js"; import { listMissionActions } from "../../missions/actions.js"; -import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js"; +import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService, getQscoreLatestSignalsFromService } from "../../services/qscore-proxy.js"; function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -30,7 +29,6 @@ async function platformAnalytics() { completedMissions, totalActions, doneActions, - qscoreSignalCount, ] = await Promise.all([ scalarCount(growEvents), scalarCount(growEvents, eq(growEvents.category, "service")), @@ -39,7 +37,6 @@ async function platformAnalytics() { scalarCount(growActiveMissions, eq(growActiveMissions.status, "completed")), scalarCount(missionActions), scalarCount(missionActions, eq(missionActions.status, "done")), - scalarCount(growQscoreSignals), ]); const serviceUsage = await db @@ -65,29 +62,28 @@ async function platformAnalytics() { completedMissions, missionActions: totalActions, completedActions: doneActions, - qscoreSignals: qscoreSignalCount, }, serviceUsage, }; } async function userQscoreAnalytics(userId: string) { - const result = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID); + const [result, latestEvidence] = await Promise.all([ + getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID), + getQscoreLatestSignalsFromService(userId, DEFAULT_QSCORE_ORG_ID), + ]); const breakdown = result?.breakdown ?? {}; - const latestSignals = Array.isArray(breakdown.signals) ? breakdown.signals : []; - const timelineRaw = Array.isArray(breakdown.signalTimeline) ? breakdown.signalTimeline : latestSignals; - const signalTimeline = timelineRaw.map((item) => { - const s = isRecord(item) ? item : {}; - return { - signalId: typeof s.signalId === "string" ? s.signalId : typeof s.signal_id === "string" ? s.signal_id : "", - score: typeof s.score === "number" ? s.score : 0, - present: typeof s.present === "boolean" ? s.present : true, - source: typeof s.source === "string" ? s.source : "", - occurredAt: typeof s.occurredAt === "string" ? s.occurredAt : result?.calculated_at ?? new Date().toISOString(), - updatedAt: typeof s.updatedAt === "string" ? s.updatedAt : result?.calculated_at ?? new Date().toISOString(), - }; - }); + const latestSignals = latestEvidence ?? []; + const timelineRaw = latestSignals; + const signalTimeline = timelineRaw.map((signal) => ({ + signalId: signal.signal_id, + score: signal.score, + present: signal.present, + source: signal.source, + occurredAt: signal.last_occurred_at, + updatedAt: signal.last_seen_at || null, + })); return { kind: "user-qscore", @@ -101,7 +97,7 @@ async function userQscoreAnalytics(userId: string) { sq_score: result.sq_score, signalCount: signalTimeline.length, dimensions: isRecord(breakdown.dimensions) ? breakdown.dimensions : result.quotients, - summary: typeof breakdown.summary === "string" ? breakdown.summary : null, + summary: null, updatedAt: result.calculated_at || new Date().toISOString(), } : null, diff --git a/src/config.ts b/src/config.ts index a192add..496578b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -113,6 +113,7 @@ export const config = { process.env.ROLEPLAY_PUBLIC_URL ?? process.env.ROLEPLAY_SERVICE_URL ?? "http://localhost:8008", qscoreServiceUrl: process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000", + qscoreEventDeliveryTimeoutMs: Number(process.env.QSCORE_EVENT_DELIVERY_TIMEOUT_MS ?? 5_000), resumeServiceUrl: process.env.RESUME_SERVICE_URL ?? "http://localhost:8002", userServiceUrl: @@ -197,4 +198,3 @@ export const config = { required, // exported so other modules can fail fast on boot } as const; - diff --git a/src/db/ensure-runtime-schema.ts b/src/db/ensure-runtime-schema.ts index 4a4e72b..9762a3e 100644 --- a/src/db/ensure-runtime-schema.ts +++ b/src/db/ensure-runtime-schema.ts @@ -59,10 +59,35 @@ async function ensureOnboardingTable() { await db.execute(`CREATE UNIQUE INDEX IF NOT EXISTS onboarding_user_idx ON onboarding (user_id)`); } +async function ensureEventOutboxTable() { + await db.execute(` + CREATE TABLE IF NOT EXISTS event_outbox ( + id text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL, + topic text NOT NULL, + aggregate_id text NOT NULL, + destination text NOT NULL, + payload jsonb NOT NULL, + headers jsonb NOT NULL DEFAULT '{}'::jsonb, + status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'published')), + attempts integer NOT NULL DEFAULT 0, + available_at timestamp with time zone NOT NULL DEFAULT now(), + locked_at timestamp with time zone, + published_at timestamp with time zone, + last_error text, + response 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 INDEX IF NOT EXISTS event_outbox_delivery_idx ON event_outbox (status, available_at)`); + await db.execute(`CREATE UNIQUE INDEX IF NOT EXISTS event_outbox_topic_aggregate_idx ON event_outbox (topic, aggregate_id)`); +} + export async function ensureRuntimeSchema() { await ensureUserPlanColumn(); await ensureGrowConversationsMetadataColumn(); await ensureSystemNotificationsTables(); await ensureOnboardingTable(); + await ensureEventOutboxTable(); log.info("runtime schema ensured"); } diff --git a/src/db/schema.ts b/src/db/schema.ts index 2b84d18..85a971f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -380,6 +380,36 @@ export const growEvents = pgTable( }), ); +// Transactional delivery queue for canonical GrowEvents. The grow_events row +// and its QScore delivery intent are inserted in the same database transaction; +// delivery is retried independently and is idempotent on (topic, aggregateId). +export const eventOutbox = pgTable( + "event_outbox", + { + id: text("id").primaryKey().default(sql`gen_random_uuid()::text`), + topic: text("topic").notNull(), + aggregateId: text("aggregate_id").notNull(), + destination: text("destination").notNull(), + payload: jsonb("payload").$type>().notNull(), + headers: jsonb("headers").$type>().notNull().default(sql`'{}'::jsonb`), + status: text("status", { + enum: ["pending", "processing", "published"], + }).notNull().default("pending"), + attempts: integer("attempts").notNull().default(0), + availableAt: timestamp("available_at", { withTimezone: true }).defaultNow().notNull(), + lockedAt: timestamp("locked_at", { withTimezone: true }), + publishedAt: timestamp("published_at", { withTimezone: true }), + lastError: text("last_error"), + response: jsonb("response").$type>(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (t) => ({ + deliveryIdx: index("event_outbox_delivery_idx").on(t.status, t.availableAt), + aggregateIdx: uniqueIndex("event_outbox_topic_aggregate_idx").on(t.topic, t.aggregateId), + }), +); + export const missionServiceSessions = pgTable( "mission_service_sessions", { diff --git a/src/events/envelope.ts b/src/events/envelope.ts index 30f9fee..a82adc6 100644 --- a/src/events/envelope.ts +++ b/src/events/envelope.ts @@ -53,8 +53,10 @@ export type StoredGrowEvent = GrowEventEnvelope & { export type QscoreSignal = { signalId: string; - score: number; - present: boolean; + // QScore owns normalization and scoring. Raw readiness evidence returned by + // its event-ingest API may identify a projected signal before a score run. + score?: number; + present?: boolean; source?: string; raw?: Record; }; diff --git a/src/events/onboarding-ledger.ts b/src/events/onboarding-ledger.ts index eca47ff..2069fe3 100644 --- a/src/events/onboarding-ledger.ts +++ b/src/events/onboarding-ledger.ts @@ -8,7 +8,6 @@ import { markGrowEventProcessing, recordGrowEvent, } from "./record-grow-event.js"; -import { ensureOnboardingBaselineQscoreForCompletedAt } from "./onboarding-qscore.js"; import { onboardingCompletedAtFromEvent, runCuratorOnboardingLoopSafely, @@ -120,12 +119,6 @@ export async function getLatestValidOnboardingLedgerEvent(userId: string): Promi return statusEvent; } -export async function ensureOnboardingBaselineQscoreFromLedger(userId: string) { - const event = await getLatestValidOnboardingLedgerEvent(userId); - if (!event) return false; - return ensureOnboardingBaselineQscoreForCompletedAt(userId, event.occurredAt); -} - function onboardingContextFromInput(context?: Record) { const input = context ?? {}; const preferences = asRecord(input.preferences); @@ -140,7 +133,6 @@ function onboardingContextFromInput(context?: Record) { export async function ensureOnboardingSideEffectsForEvent(event: GrowEventRow, contextOverride?: Record) { if (!event.userId || !isValidOnboardingLedgerEvent(event)) { return { - qscoreBaselineSeeded: false, curatorOnboarding: { status: "skipped" as const, reason: event.userId ? "not_onboarding_completion" : "missing_user_id" }, missions: { status: "skipped" as const, reason: event.userId ? "not_onboarding_completion" : "missing_user_id", started: [], existing: [] }, }; @@ -151,7 +143,6 @@ export async function ensureOnboardingSideEffectsForEvent(event: GrowEventRow, c completedAtFromOnboardingPayload(event.payload) ?? event.occurredAt.toISOString(); - const qscoreBaselineSeeded = await ensureOnboardingBaselineQscoreForCompletedAt(event.userId, completedAt); const curatorOnboarding = await runCuratorOnboardingLoopSafely({ userId: event.userId, completedAt, @@ -166,7 +157,7 @@ export async function ensureOnboardingSideEffectsForEvent(event: GrowEventRow, c existing: [], }; - return { qscoreBaselineSeeded, curatorOnboarding, missions }; + return { curatorOnboarding, missions }; } export async function recordAndProcessOnboardingCompletion(input: { diff --git a/src/events/onboarding-qscore.ts b/src/events/onboarding-qscore.ts deleted file mode 100644 index 8b9f3bf..0000000 --- a/src/events/onboarding-qscore.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { forwardSignalsToQscoreService } from "../services/service-agents.js"; -import { DEFAULT_QSCORE_ORG_ID } from "../services/qscore-proxy.js"; - -export const ONBOARDING_BASELINE_SIGNAL_ID = "onboarding.completed"; - -/** - * Workbook threshold for a completed onboarding baseline signal - * (Complete=Yes → 0.5). qscore_service owns the aggregate score; this is the - * per-signal evidence weight forwarded to it. - */ -export const ONBOARDING_BASELINE_SIGNAL_SCORE = 0.5; - -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; -} - -function parseCompletedAt(value: unknown): Date | null { - if (value instanceof Date) { - return Number.isNaN(value.getTime()) ? null : value; - } - if (typeof value !== "string" || !value.trim()) return null; - const parsed = new Date(value); - return Number.isNaN(parsed.getTime()) ? new Date() : parsed; -} - -function onboardingCompletedAt(preferences: Record | undefined): Date | null { - const onboarding = asRecord(preferences?.onboarding); - return parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt); -} - -/** - * Forward an onboarding.completed baseline signal to qscore_service when - * onboarding is completed. - * - * qscore_service OWNS all scoring. The backend no longer seeds local qscore - * tables; instead it forwards a single baseline readiness signal and lets - * qscore_service compute the score. Idempotency is delegated to qscore_service's - * latest-signal upsert semantics. - */ -export async function ensureOnboardingBaselineQscore( - userId: string, - preferences: Record | undefined, -): Promise { - const completedAt = onboardingCompletedAt(preferences); - if (!completedAt) return false; - return ensureOnboardingBaselineQscoreForCompletedAt(userId, completedAt); -} - -export async function ensureOnboardingBaselineQscoreForCompletedAt( - userId: string, - completedAtInput: string | Date, -): Promise { - const completedAt = parseCompletedAt(completedAtInput); - if (!completedAt) return false; - - await forwardSignalsToQscoreService({ - orgId: DEFAULT_QSCORE_ORG_ID, - userId, - profession: "student", - source: "onboarding", - signals: [ - { - signalId: ONBOARDING_BASELINE_SIGNAL_ID, - score: ONBOARDING_BASELINE_SIGNAL_SCORE, - present: true, - raw: { - reason: "completed onboarding baseline", - completedAt: completedAt.toISOString(), - }, - }, - ], - }); - - return true; -} diff --git a/src/events/projectors/qscore-projector.ts b/src/events/projectors/qscore-projector.ts index abb25c6..f47fdd5 100644 --- a/src/events/projectors/qscore-projector.ts +++ b/src/events/projectors/qscore-projector.ts @@ -1,385 +1,21 @@ -import { type GrowEventRow } from "../../db/schema.js"; -import { asRecord, clampScore, getNumber, getString, type QscoreSignal } from "../envelope.js"; -import { forwardSignalsToQscoreService } from "../../services/service-agents.js"; -import { DEFAULT_QSCORE_ORG_ID } from "../../services/qscore-proxy.js"; +import type { GrowEventRow } from "../../db/schema.js"; +import { publishQscoreOutboxForEvent } from "../qscore-event-outbox.js"; -function signal(signalId: string, score: number, raw?: Record, present = true): QscoreSignal { - return { signalId, score: clampScore(score), present, raw }; -} - -function nestedNumber(record: Record, keys: string[]): number | undefined { - for (const key of keys) { - const direct = getNumber(record[key]); - if (direct !== undefined) return direct; - } - return undefined; -} - -const RESUME_UPLOAD_BASELINE_SCORE = 35; - -function extractResumeSignals(event: GrowEventRow): QscoreSignal[] { - const payload = event.payload ?? {}; - const analysis = asRecord(payload.analysis ?? payload.result ?? payload); - const scoreBreakdown = Array.isArray(analysis.score_breakdown) ? analysis.score_breakdown : []; - const dimensions = Array.isArray(analysis.dimensional_scores) ? analysis.dimensional_scores : []; - - const byCategory = new Map(); - for (const item of scoreBreakdown) { - const row = asRecord(item); - const category = typeof row.category === "string" ? row.category : undefined; - const score = getNumber(row.score); - if (category && score !== undefined) byCategory.set(category, score); - } - - const byDimension = new Map(); - for (const item of dimensions) { - const row = asRecord(item); - const dimension = typeof row.dimension === "string" ? row.dimension : undefined; - const score = getNumber(row.score); - if (dimension && score !== undefined) byDimension.set(dimension, score); - } - - const signals: QscoreSignal[] = []; - if (event.type.includes("uploaded") || event.type.includes("created")) { - // Uploading a resume is only a baseline readiness signal. The actual Q Score - // should rise from parsed resume/interview/roleplay evidence, not jump to 100 - // immediately after onboarding. - signals.push(signal("resume.uploaded", RESUME_UPLOAD_BASELINE_SCORE, { eventId: event.id })); - } - const ats = byCategory.get("ATS Compatibility") ?? nestedNumber(analysis, ["ats_score", "ats_compatibility", "atsCompatibility"]); - if (ats !== undefined) signals.push(signal("resume.ats_compatibility", ats, { eventId: event.id })); - const keywords = byDimension.get("Keywords") ?? nestedNumber(analysis, ["keyword_score", "keyword_relevance", "keywords"]); - if (keywords !== undefined) { - signals.push(signal("resume.keyword_relevance", keywords, { eventId: event.id })); - } - const quantification = byDimension.get("Quantification") ?? nestedNumber(analysis, ["quantification_score"]); - if (quantification !== undefined) signals.push(signal("resume.quantified_achievements", quantification, { eventId: event.id })); - const contentQuality = byCategory.get("Content Quality") ?? nestedNumber(analysis, ["content_quality", "clarity_score"]); - if (contentQuality !== undefined) signals.push(signal("resume.grammar_clarity", contentQuality, { eventId: event.id })); - const formatting = byCategory.get("Formatting") ?? nestedNumber(analysis, ["formatting", "format_score"]); - if (formatting !== undefined) signals.push(signal("resume.format_structure", formatting, { eventId: event.id })); - return signals; -} - -function interviewSessionsScore(count: number): number { - if (count <= 0) return 0; - if (count <= 2) return 20; - if (count <= 5) return 35; - return 50; -} - -function interviewDiversityScore(count: number): number { - if (count <= 1) return 10; - if (count === 2) return 20; - return 30; -} - -function roleplayScenariosScore(count: number): number { - if (count <= 0) return 0; - if (count <= 3) return 20; - if (count <= 6) return 35; - return 50; -} - -function extractInterviewSignals(event: GrowEventRow): QscoreSignal[] { - const payload = event.payload ?? {}; - const review = asRecord(payload.review ?? payload.result ?? payload); - const status = String(review.status ?? payload.status ?? ""); - if (!event.type.includes("review") && !event.type.includes("completed") && status !== "completed") return []; - - const signals: QscoreSignal[] = []; - // A completed interview must always emit at least a single-completion signal. - // When session_count is absent, default to 1 so the event is not silently lost. - const sessionCount = getNumber(payload.session_count ?? payload.sessionCount) ?? 1; - signals.push(signal("interview.sessions_completed", interviewSessionsScore(sessionCount), { eventId: event.id, sessionCount })); - const typeDiversity = getNumber(payload.type_diversity ?? payload.typeDiversity); - if (typeDiversity !== undefined) { - signals.push(signal("interview.type_diversity", interviewDiversityScore(typeDiversity), { eventId: event.id, typeDiversity })); - } - const overall = getNumber(review.overall_score ?? review.overallScore ?? payload.overall_score); - if (overall !== undefined) signals.push(signal("interview.overall_score", overall, { eventId: event.id })); - const rubric = asRecord(review.rubric_scores ?? review.rubricScores); - const content = getNumber(rubric.content_quality ?? rubric.content ?? rubric.communication ?? review.communication_score); - if (content !== undefined) signals.push(signal("interview.response_clarity", content, { eventId: event.id })); - const roleAlignment = getNumber(rubric.role_alignment ?? rubric.technical_accuracy ?? review.role_alignment_score); - if (roleAlignment !== undefined) signals.push(signal("interview.technical_accuracy", roleAlignment, { eventId: event.id })); - const language = getNumber(rubric.language ?? review.language_score); - if (language !== undefined) signals.push(signal("interview.behavioral_quality", language, { eventId: event.id })); - const historical = asRecord(review.historical_comparison ?? review.historicalComparison); - const delta = getNumber(historical.overall_delta ?? historical.overallDelta); - if (delta !== undefined) signals.push(signal("interview.improvement_over_time", 50 + delta * 2.5, { eventId: event.id, delta })); - return signals; -} - -function extractRoleplaySignals(event: GrowEventRow): QscoreSignal[] { - const payload = event.payload ?? {}; - const review = asRecord(payload.review ?? payload.result ?? payload); - const status = String(review.status ?? payload.status ?? ""); - if (!event.type.includes("review") && !event.type.includes("completed") && status !== "completed") return []; - - const signals: QscoreSignal[] = []; - // A completed roleplay must always emit at least a single-completion signal. - // When scenario_count is absent, default to 1 so the event is not silently lost. - const scenarioCount = getNumber(payload.scenario_count ?? payload.scenarioCount) ?? 1; - signals.push(signal("roleplay.scenarios_completed", roleplayScenariosScore(scenarioCount), { eventId: event.id, scenarioCount })); - const rubric = asRecord(review.rubric_scores ?? review.rubricScores); - const scenario = getNumber(rubric.scenario_adherence ?? review.scenario_adherence_score); - if (scenario !== undefined) signals.push(signal("roleplay.situational_judgment", scenario, { eventId: event.id })); - const empathy = getNumber(rubric.emotional_intelligence ?? review.emotional_intelligence_score); - if (empathy !== undefined) signals.push(signal("roleplay.empathy_demonstrated", empathy, { eventId: event.id })); - const adaptability = getNumber(rubric.adaptability ?? review.adaptability_score); - if (adaptability !== undefined) signals.push(signal("roleplay.problem_resolution", adaptability, { eventId: event.id })); - const communication = getNumber(rubric.content ?? rubric.communication ?? review.communication_score); - if (communication !== undefined) signals.push(signal("roleplay.communication_effectiveness", communication, { eventId: event.id })); - return signals; -} - - -function coursesCompletedScore(count: number): number { - if (count <= 0) return 0; - if (count <= 3) return 35; - if (count <= 10) return 60; - return 80; -} - -function courseCompletionRateScore(pct: number): number { - if (pct < 50) return 20; - if (pct <= 80) return 40; - return 60; -} - -function courseDifficultyScore(difficulty: string): number | undefined { - const d = difficulty.toLowerCase(); - if (d.includes("beginner") || d === "easy") return 20; - if (d.includes("inter") || d.includes("medium")) return 35; - if (d.includes("adv")) return 50; - return undefined; -} - -function coursePathwayRelevanceScore(label: string): number | undefined { - const l = label.toLowerCase(); - if (l.includes("high")) return 40; - if (l.includes("med")) return 25; - if (l.includes("low")) return 10; - return undefined; -} - -function extractCourseSignals(event: GrowEventRow): QscoreSignal[] { - const payload = event.payload ?? {}; - if ( - !event.type.includes("progress_recorded") && - !event.type.includes("completed") && - !event.type.includes("started") && - !event.type.includes("watch") - ) - return []; - - const watchPctRaw = getNumber(payload.watchPct ?? payload.watch_pct) ?? 0; - const watchPctScaled = watchPctRaw <= 1 ? watchPctRaw * 100 : watchPctRaw; - const completedCount = getNumber(payload.completed_count ?? payload.completedCount ?? payload.courses_completed); - const difficulty = getString(payload.difficulty); - const label = getString(payload.label ?? payload.pathway_label); - const raw = { - eventId: event.id, - courseId: payload.courseId ?? payload.course_id, - lessonId: payload.lessonId ?? payload.lesson_id, - watchPct: watchPctRaw, +/** + * Compatibility adapter for event/mission processing. + * + * The backend never extracts, normalizes, thresholds, or scores a signal. The + * canonical GrowEvent was queued transactionally when it was recorded. This + * method only attempts that delivery and passes QScore-owned projected evidence + * back to mission reducers when the QScore API returns it. + */ +export async function applyQscoreProjection(event: Pick) { + if (!event.userId) return { signals: [], score: undefined, delivery: "skipped" as const }; + const result = await publishQscoreOutboxForEvent(event.id); + return { + signals: result.signals, + score: undefined, + delivery: result.status, + response: result.response, }; - - const signals: QscoreSignal[] = []; - signals.push(signal("courses.started", 40, raw)); - signals.push(signal("courses.completion_rate", courseCompletionRateScore(watchPctScaled), { ...raw, completionPct: watchPctScaled })); - if (watchPctScaled >= 80) { - signals.push(signal("courses.completed", coursesCompletedScore(completedCount ?? 1), { ...raw, completedCount: completedCount ?? 1 })); - } - if (difficulty) { - const diffScore = courseDifficultyScore(difficulty); - if (diffScore !== undefined) signals.push(signal("courses.difficulty", diffScore, { ...raw, difficulty })); - } - if (label) { - const relScore = coursePathwayRelevanceScore(label); - if (relScore !== undefined) signals.push(signal("courses.pathway_relevance", relScore, { ...raw, label })); - } - 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") || - 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, - }), - ]; -} - -function countFromPayload(payload: Record): number | undefined { - const result = asRecord(payload.result); - const request = asRecord(payload.request); - const params = asRecord(request.params); - const direct = getNumber(payload.matchCount ?? payload.matches ?? payload.shortlisted ?? result.matchCount ?? result.matches ?? result.shortlisted); - if (direct !== undefined) return direct; - const opportunities = Array.isArray(result.opportunities) ? result.opportunities : undefined; - if (opportunities) return opportunities.length; - const requestOpportunities = Array.isArray(params.opportunities) ? params.opportunities : undefined; - return requestOpportunities?.length; -} - - -function jobsViewedScore(count: number): number { - if (count <= 0) return 0; - if (count <= 20) return 15; - if (count <= 50) return 30; - return 40; -} - -function applicationsSubmittedScore(count: number): number { - if (count <= 0) return 0; - if (count <= 5) return 20; - if (count <= 15) return 35; - return 50; -} - -function matchRateScore(pct: number): number { - if (pct < 50) return 15; - if (pct <= 75) return 35; - return 50; -} - -function extractMatchmakingSignals(event: GrowEventRow): QscoreSignal[] { - if (!event.type.startsWith("matchmaking.")) return []; - const payload = event.payload ?? {}; - const count = countFromPayload(payload); - const raw = { - eventId: event.id, - type: event.type, - action: payload.action, - opportunityId: payload.opportunityId ?? payload.matchId ?? payload.jobId ?? event.subject?.externalId, - taskId: payload.taskId ?? payload.curatorTaskId ?? event.correlation?.taskId, - matchCount: count, - status: payload.status, - }; - - const signals: QscoreSignal[] = []; - - // Jobs viewed — from feed.viewed and match.viewed events with cumulative counts - if (event.type === "matchmaking.feed.viewed" || event.type === "matchmaking.match.viewed") { - const viewedCount = getNumber(payload.jobs_viewed ?? payload.jobsViewed ?? payload.viewed_count ?? payload.viewedCount ?? count); - signals.push(signal("matching.jobs_viewed", jobsViewedScore(viewedCount ?? 1), { ...raw, viewedCount })); - } - - // Applications submitted — from match.applied and application.completed events - if (event.type === "matchmaking.match.applied" || event.type === "matchmaking.application.completed") { - const appliedCount = getNumber(payload.applications_submitted ?? payload.applicationsSubmitted ?? payload.applied_count ?? payload.appliedCount ?? count); - signals.push(signal("matching.applications_submitted", applicationsSubmittedScore(appliedCount ?? 1), { ...raw, appliedCount })); - } - - // Application quality — from match quality scores if available - const qualityScore = getNumber(payload.application_quality ?? payload.applicationQuality ?? payload.match_quality ?? payload.matchQuality); - if (qualityScore !== undefined) { - signals.push(signal("matching.application_quality", qualityScore, { ...raw, qualityScore })); - } - - // Match rate — from matches.generated with match data - if (event.type === "matchmaking.matches.generated" && count !== undefined) { - const totalCandidates = getNumber(payload.total_candidates ?? payload.totalCandidates) ?? count; - const rate = totalCandidates > 0 ? (count / totalCandidates) * 100 : 0; - signals.push(signal("matching.match_rate", matchRateScore(rate), { ...raw, matchRate: rate })); - } - - return signals; -} - -function extractSocialSignals(event: GrowEventRow): QscoreSignal[] { - const payload = event.payload ?? {}; - const signals: QscoreSignal[] = []; - // social-branding sends pre-computed qscore_signals array — forward as-is - const incoming = Array.isArray(payload.qscore_signals) ? payload.qscore_signals : Array.isArray(payload.qscoreSignals) ? payload.qscoreSignals : undefined; - if (incoming) { - for (const entry of incoming) { - const record = asRecord(entry); - const id = getString(record.signalId ?? record.signal_id ?? record.id); - const score = getNumber(record.score ?? record.value); - if (id !== undefined && score !== undefined) { - signals.push(signal(id, score, { eventId: event.id, source: event.source })); - } - } - return signals; - } - // Fall back to inline fields for backward compatibility - const inline = asRecord(payload.signals); - for (const [key, value] of Object.entries(inline)) { - const score = getNumber(value); - if (score !== undefined) signals.push(signal(key, score, { eventId: event.id, source: event.source })); - } - return signals; -} - -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("course") || event.type.startsWith("course.")) return extractCourseSignals(event); - if (source.includes("matchmaking") || event.type.startsWith("matchmaking.")) return extractMatchmakingSignals(event); - if (source.includes("social") || source.includes("branding") || source.includes("linkedin")) return extractSocialSignals(event); - if (source.includes("qscore") || event.type.startsWith("qscore.")) return extractScoredServiceSignals(event); - const scoredServiceSignals = extractScoredServiceSignals(event); - if (scoredServiceSignals.length) return scoredServiceSignals; - return []; -} - -export async function applyQscoreProjection(event: GrowEventRow) { - if (!event.userId) return { signals: [], score: undefined }; - - const signals = extractQscoreSignals(event); - if (!signals.length) return { signals, score: undefined }; - - // qscore_service OWNS all scoring. The backend only extracts signals and - // forwards them; ingest persists signals and marks the user dirty for async - // score computation. It does NOT return a score, so score stays undefined - // here — readers fetch the computed score via the proxy endpoints. - await forwardSignalsToQscoreService({ - orgId: event.orgId ?? DEFAULT_QSCORE_ORG_ID, - userId: event.userId, - profession: "student", - source: event.source, - signals, - }); - - return { signals, score: undefined }; } diff --git a/src/events/qscore-event-outbox.ts b/src/events/qscore-event-outbox.ts new file mode 100644 index 0000000..3e5b88f --- /dev/null +++ b/src/events/qscore-event-outbox.ts @@ -0,0 +1,197 @@ +import { and, eq, lt, lte, or, sql } from "drizzle-orm"; +import { config } from "../config.js"; +import { db } from "../db/client.js"; +import { eventOutbox } from "../db/schema.js"; +import { log } from "../log.js"; +import { asRecord, getNumber, getString, type QscoreSignal } from "./envelope.js"; + +export const QSCORE_GROW_EVENT_TOPIC = "qscore.grow_event.raw"; +export const QSCORE_GROW_EVENT_DESTINATION = "/v1/events/ingest"; + +const LOCK_TIMEOUT_MS = 60_000; +const DEFAULT_POLL_MS = 2_000; +const DEFAULT_BATCH_SIZE = 25; + +export type QscoreEventDeliveryResult = { + status: "queued" | "published"; + signals: QscoreSignal[]; + response?: Record; +}; + +type Deliver = (input: { + destination: string; + payload: Record; + headers: Record; +}) => Promise>; + +function signalFromUnknown(value: unknown): QscoreSignal | null { + const row = asRecord(value); + const signalId = getString(row.signalId ?? row.signal_id ?? row.id); + if (!signalId) return null; + const score = getNumber(row.score ?? row.normalizedScore ?? row.normalized_score); + const present = typeof row.present === "boolean" ? row.present : undefined; + const suppliedRaw = asRecord(row.raw ?? row.evidence); + const policyEvidence = compactRecord({ + rawValue: row.rawValue ?? row.raw_value, + rawUnit: row.rawUnit ?? row.raw_unit, + band: row.band, + materialChange: row.materialChange ?? row.material_change, + }); + return { + signalId, + ...(score !== undefined ? { score } : {}), + ...(present !== undefined ? { present } : {}), + ...(getString(row.source) ? { source: getString(row.source) } : {}), + raw: Object.keys(suppliedRaw).length ? suppliedRaw : policyEvidence, + }; +} + +function compactRecord(value: Record) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); +} + +/** Read only QScore-owned projected evidence; never infer or score locally. */ +export function projectedSignalsFromQscoreResponse(body: Record): QscoreSignal[] { + const breakdown = asRecord(body.breakdown); + const candidates = Array.isArray(body.projectedSignals) + ? body.projectedSignals + : Array.isArray(body.projected_signals) + ? body.projected_signals + : Array.isArray(body.signals) + ? body.signals + : Array.isArray(breakdown.signals) + ? breakdown.signals + : []; + return candidates.map(signalFromUnknown).filter((signal): signal is QscoreSignal => signal !== null); +} + +async function postToQscore(input: Parameters[0]): Promise> { + const response = await fetch(`${config.qscoreServiceUrl.replace(/\/$/, "")}${input.destination}`, { + method: "POST", + headers: { + ...input.headers, + ...(config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : {}), + }, + body: JSON.stringify(input.payload), + signal: AbortSignal.timeout(config.qscoreEventDeliveryTimeoutMs), + }); + const text = await response.text(); + let body: unknown = {}; + if (text) { + try { body = JSON.parse(text); } catch { body = { detail: text }; } + } + if (!response.ok) { + throw new Error(`qscore raw-event ingest failed (${response.status}): ${text.slice(0, 500)}`); + } + return asRecord(body); +} + +export async function publishQscoreOutboxItem( + outboxId: string, + deliver: Deliver = postToQscore, +): Promise { + const now = new Date(); + const staleBefore = new Date(now.getTime() - LOCK_TIMEOUT_MS); + const [claimed] = await db + .update(eventOutbox) + .set({ + status: "processing", + attempts: sql`${eventOutbox.attempts} + 1`, + lockedAt: now, + updatedAt: now, + }) + .where(and( + eq(eventOutbox.id, outboxId), + or( + and(eq(eventOutbox.status, "pending"), lte(eventOutbox.availableAt, now)), + and(eq(eventOutbox.status, "processing"), lt(eventOutbox.lockedAt, staleBefore)), + ), + )) + .returning(); + + if (!claimed) { + const [existing] = await db.select().from(eventOutbox).where(eq(eventOutbox.id, outboxId)).limit(1); + const response = existing?.response ?? undefined; + return { + status: existing?.status === "published" ? "published" : "queued", + signals: response ? projectedSignalsFromQscoreResponse(response) : [], + response, + }; + } + + try { + const response = await deliver({ + destination: claimed.destination, + payload: claimed.payload, + headers: claimed.headers, + }); + await db.update(eventOutbox).set({ + status: "published", + response, + publishedAt: new Date(), + lockedAt: null, + lastError: null, + updatedAt: new Date(), + }).where(eq(eventOutbox.id, claimed.id)); + return { status: "published", signals: projectedSignalsFromQscoreResponse(response), response }; + } catch (error) { + const delaySeconds = Math.min(300, 2 ** Math.min(claimed.attempts, 8)); + await db.update(eventOutbox).set({ + status: "pending", + availableAt: new Date(Date.now() + delaySeconds * 1_000), + lockedAt: null, + lastError: error instanceof Error ? error.message : String(error), + updatedAt: new Date(), + }).where(eq(eventOutbox.id, claimed.id)); + throw error; + } +} + +export async function publishQscoreOutboxForEvent(eventId: string): Promise { + const [row] = await db + .select({ id: eventOutbox.id, status: eventOutbox.status, response: eventOutbox.response }) + .from(eventOutbox) + .where(and(eq(eventOutbox.topic, QSCORE_GROW_EVENT_TOPIC), eq(eventOutbox.aggregateId, eventId))) + .limit(1); + if (!row) return { status: "queued", signals: [] }; + if (row.status === "published") { + return { + status: "published", + signals: row.response ? projectedSignalsFromQscoreResponse(row.response) : [], + response: row.response ?? undefined, + }; + } + try { + return await publishQscoreOutboxItem(row.id); + } catch (error) { + log.warn({ error, eventId }, "QScore raw GrowEvent queued for retry"); + return { status: "queued", signals: [] }; + } +} + +export async function flushQscoreEventOutbox(limit = DEFAULT_BATCH_SIZE): Promise { + const now = new Date(); + const staleBefore = new Date(now.getTime() - LOCK_TIMEOUT_MS); + const rows = await db + .select({ id: eventOutbox.id }) + .from(eventOutbox) + .where(and( + eq(eventOutbox.topic, QSCORE_GROW_EVENT_TOPIC), + or( + and(eq(eventOutbox.status, "pending"), lte(eventOutbox.availableAt, now)), + and(eq(eventOutbox.status, "processing"), lt(eventOutbox.lockedAt, staleBefore)), + ), + )) + .limit(limit); + const settled = await Promise.allSettled(rows.map((row) => publishQscoreOutboxItem(row.id))); + return settled.filter((result) => result.status === "fulfilled").length; +} + +export function startQscoreEventOutboxPublisher(pollMs = DEFAULT_POLL_MS) { + const timer = setInterval(() => { + flushQscoreEventOutbox().catch((error) => log.error({ error }, "QScore event outbox flush failed")); + }, pollMs); + timer.unref(); + void flushQscoreEventOutbox().catch((error) => log.error({ error }, "initial QScore event outbox flush failed")); + return () => clearInterval(timer); +} diff --git a/src/events/record-grow-event.ts b/src/events/record-grow-event.ts index 01f1557..123fd59 100644 --- a/src/events/record-grow-event.ts +++ b/src/events/record-grow-event.ts @@ -1,15 +1,10 @@ import { and, eq } from "drizzle-orm"; import { db } from "../db/client.js"; -import { growEvents, missionServiceSessions, users, type GrowEventRow } from "../db/schema.js"; +import { eventOutbox, growEvents, missionServiceSessions, type GrowEventRow } from "../db/schema.js"; import { asRecord, getString, type GrowEventEnvelope } from "./envelope.js"; import { normalizeGrowEvent } from "./normalize.js"; - -async function ensureUser(userId: string) { - await db - .insert(users) - .values({ id: userId, email: `${userId}@service.local`, displayName: userId }) - .onConflictDoNothing(); -} +import { QSCORE_GROW_EVENT_DESTINATION, QSCORE_GROW_EVENT_TOPIC } from "./qscore-event-outbox.js"; +import { DEFAULT_QSCORE_ORG_ID } from "../services/qscore-proxy.js"; async function resolveUserId(event: GrowEventEnvelope): Promise { if (event.userId) return event.userId; @@ -44,14 +39,13 @@ export async function recordGrowEvent(input: unknown, overrides: { userId?: stri export async function recordGrowEventWithResult(input: unknown, overrides: { userId?: string; source?: string } = {}): Promise<{ event: GrowEventRow; inserted: boolean }> { const normalized = normalizeGrowEvent(input, overrides); const resolvedUserId = await resolveUserId(normalized); - if (resolvedUserId) await ensureUser(resolvedUserId); const processingStatus = resolvedUserId ? "pending" : "unresolved"; const values = { id: normalized.id, userId: resolvedUserId, - orgId: normalized.orgId, + orgId: normalized.orgId ?? DEFAULT_QSCORE_ORG_ID, source: normalized.source, type: normalized.type, category: normalized.category, @@ -79,11 +73,29 @@ export async function recordGrowEventWithResult(input: unknown, overrides: { use // // NULL dedupeKey never collides (Postgres treats NULLs as distinct under a // unique index), so events with no dedupe key always insert fresh. - const insertedRows = await db - .insert(growEvents) - .values(values) - .onConflictDoNothing() - .returning(); + const canonicalEvent = canonicalGrowEventEnvelope(normalized, resolvedUserId); + + const insertedRows = await db.transaction(async (tx) => { + const rows = await tx + .insert(growEvents) + .values(values) + .onConflictDoNothing() + .returning(); + const inserted = rows[0]; + if (inserted?.userId) { + await tx.insert(eventOutbox).values({ + topic: QSCORE_GROW_EVENT_TOPIC, + aggregateId: inserted.id, + destination: QSCORE_GROW_EVENT_DESTINATION, + payload: canonicalEvent, + headers: { + "content-type": "application/json", + "idempotency-key": inserted.id, + }, + }).onConflictDoNothing(); + } + return rows; + }); const inserted = insertedRows[0]; if (inserted) { @@ -120,6 +132,28 @@ export async function recordGrowEventWithResult(input: unknown, overrides: { use return { event: existing, inserted: false }; } +export function canonicalGrowEventEnvelope(normalized: GrowEventEnvelope, resolvedUserId?: string) { + return compactRecord({ + id: normalized.id, + userId: resolvedUserId, + orgId: normalized.orgId ?? DEFAULT_QSCORE_ORG_ID, + source: normalized.source, + type: normalized.type, + category: normalized.category, + occurredAt: normalized.occurredAt, + mission: normalized.mission, + subject: normalized.subject, + correlation: normalized.correlation, + payload: normalized.payload, + raw: normalized.raw, + dedupeKey: normalized.dedupeKey, + }); +} + +function compactRecord(value: Record): Record { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); +} + /** * Routing gate: only newly inserted, user-resolved, pending events should be * enqueued to the user-event actor. Replays / dedupe hits / unresolved events diff --git a/src/home/home-feed.ts b/src/home/home-feed.ts index cc14aaf..94e8f5b 100644 --- a/src/home/home-feed.ts +++ b/src/home/home-feed.ts @@ -8,15 +8,13 @@ import { missionArtifacts, missionServiceSessions, missionSuggestions, - qscoreSnapshots, users, type GrowHomeNotificationRow, type NewGrowHomeNotification, } from "../db/schema.js"; import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js"; 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 { DEFAULT_QSCORE_ORG_ID, getQscoreFromService, getQscoreLatestSignalsFromService } from "../services/qscore-proxy.js"; import { listAvailableMissionDefinitions } from "../missions/registry.js"; import { listServiceCapabilities } from "../workflows/service-capabilities.js"; import { buildCuratorSprint } from "../v1/curator/curator-store.js"; @@ -219,7 +217,7 @@ function buildDayOneSeeds(): SeedNotification[] { function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] { const seeds = buildDayOneSeeds().filter((seed) => seed.moduleId === "pathways" || seed.moduleId === "rewards"); const profile = profileFromPreferences(ctx.preferences); - const qscore = ctx.qscore?.score ?? Math.round(ctx.qscoreSignals.reduce((sum, s) => sum + s.score, 0) / Math.max(ctx.qscoreSignals.length, 1)); + const qscore = ctx.qscore?.score; const ats = latestScore(ctx.qscoreSignals, "resume.ats_compatibility"); const interviewOverall = latestScore(ctx.qscoreSignals, "interview.overall_score"); const roleplayComms = latestScore(ctx.qscoreSignals, "roleplay.communication_effectiveness"); @@ -278,7 +276,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] { }); } - if (ctx.qscore || ctx.qscoreSignals.length) { + if (qscore !== undefined) { pushSeed(seeds, { moduleId: "pathways", title: qscore >= 80 ? "Review your best job matches" : "Find better-fit job matches", @@ -382,16 +380,16 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] { async function collectContext(userId: string, input: { userProfile?: Record; preferences?: Record } = {}): Promise { const [user] = await db.select({ id: users.id, email: users.email, displayName: users.displayName }).from(users).where(eq(users.id, userId)).limit(1); - const qscoreResult = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID); - const breakdown = qscoreResult?.breakdown ?? {}; - const breakdownSignals = Array.isArray(breakdown.signals) ? breakdown.signals : []; - const qscoreSignals = breakdownSignals.map((item) => { - const s = isRecord(item) ? item : {}; + const [qscoreResult, latestQscoreSignals] = await Promise.all([ + getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID), + getQscoreLatestSignalsFromService(userId, DEFAULT_QSCORE_ORG_ID), + ]); + const qscoreSignals = (latestQscoreSignals ?? []).map((s) => { return { - signalId: typeof s.signalId === "string" ? s.signalId : typeof s.signal_id === "string" ? s.signal_id : "", - score: typeof s.score === "number" ? s.score : 0, - source: typeof s.source === "string" ? s.source : null, - updatedAt: new Date(typeof s.updatedAt === "string" ? s.updatedAt : (qscoreResult?.calculated_at ?? new Date().toISOString())), + signalId: s.signal_id, + score: s.score, + source: s.source, + updatedAt: new Date(s.last_seen_at || s.last_occurred_at || qscoreResult?.calculated_at || 0), }; }); const activeMissions = await db @@ -453,9 +451,9 @@ async function collectContext(userId: string, input: { userProfile?: Record 0 ? ctx.qscore.score : Math.round(ctx.qscoreSignals.reduce((sum, s) => sum + s.score, 0) / Math.max(ctx.qscoreSignals.length, 1)) || 47; - const [baselineSnapshot] = await db - .select({ score: qscoreSnapshots.score }) - .from(qscoreSnapshots) - .where(and(eq(qscoreSnapshots.userId, ctx.user?.id ?? ""), eq(qscoreSnapshots.snapshotType, "baseline"))) - .orderBy(desc(qscoreSnapshots.createdAt)) - .limit(1); - const baseline = baselineSnapshot?.score ?? Math.max(35, score - 29); - const from = Math.max(baseline, Math.min(score, score - Math.min(17, Math.max(5, score - baseline)))); + const score = ctx.qscore?.score ?? null; const name = ctx.user?.displayName || ctx.user?.email?.split("@")[0] || "GrowQR User"; const completedArtifacts = ctx.artifacts.filter((a) => a.status === "ready" || a.status === "approved").length; @@ -594,9 +584,8 @@ async function buildIdentity(ctx: HomeContext) { name, caption: "Your living QR · scan to view profile", qrSrc: undefined, - qx: { from, to: score, baseline }, + qx: { from: null, to: score, baseline: null }, glance: [ - { value: Math.max(0, score - baseline), label: "Growth" }, { value: Math.max(0, ctx.qscore?.signalCount ?? ctx.qscoreSignals.length), label: "Signals" }, { value: completedArtifacts || ctx.events.length, label: "Done" }, ], @@ -604,7 +593,6 @@ async function buildIdentity(ctx: HomeContext) { } export async function getHomeFeed(userId: string, opts: { refresh?: boolean; userProfile?: Record; preferences?: Record } = {}): Promise { - await ensureOnboardingBaselineQscoreFromLedger(userId); await buildCuratorSprint(userId); const ctx = await collectContext(userId, { userProfile: opts.userProfile, preferences: opts.preferences }); const persisted = await readPersistedNotifications(userId); diff --git a/src/home/types.ts b/src/home/types.ts index 456abdd..ec49d72 100644 --- a/src/home/types.ts +++ b/src/home/types.ts @@ -28,7 +28,7 @@ export type HomeIdentity = { name: string; caption: string; qrSrc?: string; - qx: { from: number; to: number; baseline: number }; + qx: { from: number | null; to: number | null; baseline: number | null }; glance: { value: number; label: string }[]; }; diff --git a/src/index.ts b/src/index.ts index 38b9ff4..7ee8049 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { analyticsRoutes } from "./routes/analytics.js"; import { logRoutes } from "./routes/logs.js"; import { v1Routes } from "./v1/index.js"; import { startGrowEventsRedisConsumer } from "./events/redis-consumer.js"; +import { startQscoreEventOutboxPublisher } from "./events/qscore-event-outbox.js"; import { startPassiveMissionReviewLoop } from "./missions/passive-runner.js"; import { db } from "./db/client.js"; import { ensureRuntimeSchema } from "./db/ensure-runtime-schema.js"; @@ -48,6 +49,7 @@ async function main() { await reconcileOnBoot(); startGrowEventsRedisConsumer().catch((err) => log.error({ err }, "failed to start grow events redis consumer")); + startQscoreEventOutboxPublisher(); startPassiveMissionReviewLoop(); const app = new Hono(); diff --git a/src/routes/events.ts b/src/routes/events.ts index a67af24..3b0fdf2 100644 --- a/src/routes/events.ts +++ b/src/routes/events.ts @@ -44,7 +44,7 @@ async function ingest(body: unknown, userId?: string, source?: string) { processingStatus: event.processingStatus, route, qscore: { signals: [], score: undefined, idempotent: true }, - onboarding: { qscoreBaselineSeeded: false, curatorOnboarding: { status: "already_processed" } }, + onboarding: { curatorOnboarding: { status: "already_processed" } }, }; } if (!event.userId) { @@ -53,7 +53,7 @@ async function ingest(body: unknown, userId?: string, source?: string) { processingStatus: event.processingStatus, route, qscore: { signals: [], score: undefined, skipped: "missing_user_id" }, - onboarding: { qscoreBaselineSeeded: false, curatorOnboarding: { status: "skipped", reason: "missing_user_id" } }, + onboarding: { curatorOnboarding: { status: "skipped", reason: "missing_user_id" } }, }; } diff --git a/src/routes/services.ts b/src/routes/services.ts index c6c1508..6ef498c 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -10,11 +10,10 @@ import { db } from "../db/client.js"; import { events, growEvents, missionServiceSessions } from "../db/schema.js"; import { recordGrowEvent } from "../events/record-grow-event.js"; import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js"; -import { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-ledger.js"; import { getRequestUserPreferences, getRequestUserProfile } from "../services/user-context.js"; import { buildMatchmakingGatewayEvent } from "../services/matchmaking-events.js"; import { log } from "../log.js"; -import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../services/qscore-proxy.js"; +import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService, getQscoreLatestSignalsFromService } from "../services/qscore-proxy.js"; const LANDING_AGENTS = [ { id: "resume", title: "Resume", agent: "Resume Strategist", description: "Resume proof, versions, parsing, analysis, and mission artifacts.", route: "/agents/resume" }, @@ -22,19 +21,6 @@ const LANDING_AGENTS = [ { id: "roleplay", title: "Roleplay", agent: "Roleplay Coach", description: "Negotiation, promotion, transition, and communication drills.", route: "/agents/roleplay" }, ] as const; -const DEFAULT_QSCORE = { - q_score: 76, - profession: "Student", - quotients: { - SQ: 76, - XQ: 74, - CQm: 78, - VQ: 72, - DQ: 75, - GQ: 80, - }, -}; - function getString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } @@ -680,7 +666,9 @@ async function buildPersonalizedRoleplayConfigurePayload(req: Request, body: Jso user_id: String(rest.user_id ?? userId), org_id: String(rest.org_id ?? "growqr"), metadata, - qscore: (rest.qscore as JsonObject | undefined) ?? (isRecord(userContext.qscore) ? userContext.qscore : DEFAULT_QSCORE), + ...((rest.qscore as JsonObject | undefined) ?? (isRecord(userContext.qscore) ? userContext.qscore : undefined) + ? { qscore: (rest.qscore as JsonObject | undefined) ?? userContext.qscore } + : {}), user_context: userContext, }; } @@ -807,16 +795,13 @@ export function serviceRoutes() { app.get("/qscore/current", async (c) => { const userId = c.get("userId"); - try { - await ensureOnboardingBaselineQscoreFromLedger(userId); - } catch (err) { - log.warn({ err, userId }, "failed to seed onboarding Q Score baseline before current Q Score read"); - } - - const qscoreResult = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID); + const [qscoreResult, latestSignals] = await Promise.all([ + getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID), + getQscoreLatestSignalsFromService(userId, DEFAULT_QSCORE_ORG_ID), + ]); const score = qscoreResult?.q_score ?? null; const breakdown = qscoreResult?.breakdown ?? {}; - const breakdownSignals = Array.isArray(breakdown.signals) ? breakdown.signals : []; + const evidence = latestSignals ?? []; const quotients = qscoreResult?.quotients ?? {}; const dimensionFromKey = (id: string, value: unknown): { @@ -845,32 +830,27 @@ export function serviceRoutes() { iq_score: qscoreResult?.iq_score ?? null, eq_score: qscoreResult?.eq_score ?? null, sq_score: qscoreResult?.sq_score ?? null, - signalCount: breakdownSignals.length, - summary: typeof breakdown.summary === "string" ? breakdown.summary : `Readiness score computed from ${breakdownSignals.length} current signal${breakdownSignals.length === 1 ? "" : "s"}.`, + signalCount: evidence.length, + breakdown, + formulaVersion: qscoreResult?.formula_version ?? null, + formulaId: qscoreResult?.formula_id ?? null, + ledgerSeqFrom: qscoreResult?.ledger_seq_from ?? null, + ledgerSeqTo: qscoreResult?.ledger_seq_to ?? null, dimensions, updatedAt: qscoreResult?.calculated_at ?? null, }, - signals: breakdownSignals.map((signal) => { - const s = isRecord(signal) ? signal : {}; - return { - signalId: typeof s.signalId === "string" ? s.signalId : typeof s.signal_id === "string" ? s.signal_id : "", - score: typeof s.score === "number" ? Math.round(s.score) : 0, - present: typeof s.present === "boolean" ? s.present : true, - source: typeof s.source === "string" ? s.source : "", - occurredAt: typeof s.occurredAt === "string" ? s.occurredAt : qscoreResult?.calculated_at ?? new Date().toISOString(), - updatedAt: typeof s.updatedAt === "string" ? s.updatedAt : qscoreResult?.calculated_at ?? new Date().toISOString(), - }; - }), + signals: evidence.map((signal) => ({ + signalId: signal.signal_id, + score: signal.score, + present: signal.present, + source: signal.source, + raw: signal.raw, + occurredAt: signal.last_occurred_at, + updatedAt: signal.last_seen_at || null, + formulaVersion: signal.formula_version, + })), }; - await recordGatewayEvent({ - userId, - source: "qscore-service", - type: "qscore.review.opened", - payload: { score, signalCount: breakdownSignals.length, source: "services.qscore.current" }, - correlation: { taskId: curatorTaskIdFromRequest(c.req.raw) }, - }).catch((err) => log.warn({ err, userId }, "failed to record qscore review event")); - return c.json(response); }); diff --git a/src/services/qscore-proxy.ts b/src/services/qscore-proxy.ts index f361ea3..848d71b 100644 --- a/src/services/qscore-proxy.ts +++ b/src/services/qscore-proxy.ts @@ -55,6 +55,17 @@ export type QscoreServiceResult = { */ export type QscoreProxyResult = QscoreServiceResult; +export type QscoreLatestSignal = { + signal_id: string; + source: string | null; + present: boolean; + score: number; + raw: unknown; + last_occurred_at: string | null; + last_seen_at: string; + formula_version: string; +}; + const QSCORE_PROXY_TIMEOUT_MS = Number( process.env.QSCORE_PROXY_TIMEOUT_MS ?? 3500, ); @@ -138,3 +149,45 @@ export async function getQscoreFromService( return null; } } + +/** Fetch QScore's real latest evidence ledger; an unavailable service is null. */ +export async function getQscoreLatestSignalsFromService( + userId: string, + orgId: string = DEFAULT_QSCORE_ORG_ID, + timeoutMs: number = QSCORE_PROXY_TIMEOUT_MS, +): Promise { + const qscoreUserId = toQscoreUserId(userId); + const url = `${config.qscoreServiceUrl.replace(/\/$/, "")}/v1/signals/${encodeURIComponent(qscoreUserId)}?org_id=${encodeURIComponent(orgId)}`; + try { + const res = await fetch(url, { + headers: { + "content-type": "application/json", + ...(config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : {}), + }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (res.status === 404) return []; + if (!res.ok) { + log.warn({ status: res.status, userId, orgId }, "qscore_service latest signals returned non-success status"); + return null; + } + const body: unknown = await res.json(); + if (!isRecord(body) || !Array.isArray(body.signals)) throw new Error("qscore_service latest signals response is invalid"); + return body.signals.flatMap((value): QscoreLatestSignal[] => { + if (!isRecord(value) || typeof value.signal_id !== "string" || typeof value.score !== "number") return []; + return [{ + signal_id: value.signal_id, + source: typeof value.source === "string" ? value.source : null, + present: typeof value.present === "boolean" ? value.present : true, + score: value.score, + raw: value.raw, + last_occurred_at: typeof value.last_occurred_at === "string" ? value.last_occurred_at : null, + last_seen_at: typeof value.last_seen_at === "string" ? value.last_seen_at : "", + formula_version: typeof value.formula_version === "string" ? value.formula_version : "", + }]; + }); + } catch (err) { + log.warn({ err, userId, orgId }, "qscore_service latest signals proxy call failed"); + return null; + } +} diff --git a/src/services/service-agents.ts b/src/services/service-agents.ts index 118bcd2..64028c4 100644 --- a/src/services/service-agents.ts +++ b/src/services/service-agents.ts @@ -1,9 +1,7 @@ import { config } from "../config.js"; import { createHash } from "node:crypto"; import { buildServiceSessionPath } from "./service-registry.js"; -import type { QscoreSignal } from "../events/envelope.js"; -import { toQscoreUserId } from "./qscore-proxy.js"; -import { serviceJson as productServiceJson } from "./product-service-clients.js"; +import { getQscoreFromService, toQscoreUserId } from "./qscore-proxy.js"; // Lightweight agent reference (works with both old AgentProfile and new SubAgentModule). export type ServiceAgentRef = { @@ -129,6 +127,7 @@ async function runInterviewService(ctx: ServiceAgentContext): Promise { + const currentQscore = await getQscoreFromService(ctx.userId, ctx.orgId ?? "growqr"); const payload = { user_id: ctx.userId, org_id: ctx.orgId ?? "growqr", @@ -141,16 +140,7 @@ async function runRoleplayService(ctx: ServiceAgentContext): Promise>( config.roleplayServiceUrl, @@ -174,55 +164,7 @@ async function runRoleplayService(ctx: ServiceAgentContext): Promise { const orgId = ctx.orgId ?? "growqr"; - const qscoreUserId = stableUuid(ctx.userId); - const signals = [ - { - signal_id: "resume.uploaded", - present: true, - score: 82, - raw: { source: "resume-agent", workflow_goal: ctx.goal }, - }, - { - signal_id: "resume.ats_compatibility", - present: true, - score: 76, - raw: { source: "resume-agent", workflow_goal: ctx.goal }, - }, - { - signal_id: "engagement.features_used", - present: true, - score: 88, - raw: { source: "grow-agent", workflow_goal: ctx.goal }, - }, - { - signal_id: "goals.goal_clarity", - present: true, - score: 90, - raw: { source: "grow-agent", workflow_goal: ctx.goal }, - }, - ]; - - // Try to ingest signals (non-critical — may fail if QScore worker is down) - let ingest: Record | undefined; - try { - ingest = await serviceJson>( - config.qscoreServiceUrl, - "/v1/signals/ingest", - { - method: "POST", - body: JSON.stringify({ - org_id: orgId, - user_id: qscoreUserId, - profession: "student", - source: "growqr-workflow", - signals, - }), - }, - ); - } catch (err) { - // Signal ingestion is optional — compute may still work with cached signals - ingest = { status: "skipped", reason: err instanceof Error ? err.message : String(err) }; - } + const qscoreUserId = toQscoreUserId(ctx.userId); // Try to compute Q-Score let compute: Record | undefined; @@ -243,8 +185,6 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise ({ id: s.signal_id, score: s.score })), compute_error: err instanceof Error ? err.message : String(err), }, }; @@ -253,49 +193,10 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise { - const qscoreUserId = toQscoreUserId(params.userId); - await productServiceJson( - config.qscoreServiceUrl, - "/v1/signals/ingest", - { - method: "POST", - body: { - org_id: params.orgId, - user_id: qscoreUserId, - profession: params.profession, - source: params.source, - signals: params.signals.map((s) => ({ - signal_id: s.signalId, - present: s.present, - score: s.score, - raw: s.raw ?? null, - })), - }, - }, - ); -} - // ── Resume Building (resume-builder service from growqr-app) ── async function runResumeAnalyze(ctx: ServiceAgentContext): Promise { diff --git a/src/v1/analytics/analytics-routes.ts b/src/v1/analytics/analytics-routes.ts index 2e662fe..83d15ea 100644 --- a/src/v1/analytics/analytics-routes.ts +++ b/src/v1/analytics/analytics-routes.ts @@ -7,7 +7,7 @@ import { growEvents } from "../../db/schema.js"; import { recordGrowEvent } from "../../events/record-grow-event.js"; import { routeGrowEventToUserActor } from "../../events/route-to-user-actor.js"; import { v1AnalyticsActor } from "./analytics-actor.js"; -import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js"; +import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService, getQscoreLatestSignalsFromService } from "../../services/qscore-proxy.js"; function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -45,17 +45,16 @@ export function v1AnalyticsRoutes() { app.get("/insight-snapshot", async (c) => { const userId = c.get("userId"); - const qscoreResult = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID); + const [qscoreResult, qscoreEvidence] = await Promise.all([ + getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID), + getQscoreLatestSignalsFromService(userId, DEFAULT_QSCORE_ORG_ID), + ]); const breakdown = qscoreResult?.breakdown ?? {}; - const breakdownSignals = Array.isArray(breakdown.signals) ? breakdown.signals : []; - const latestSignals = breakdownSignals.map((item) => { - const s = isRecord(item) ? item : {}; - return { - signalId: typeof s.signalId === "string" ? s.signalId : typeof s.signal_id === "string" ? s.signal_id : "", - score: typeof s.score === "number" ? s.score : 0, - source: typeof s.source === "string" ? s.source : null, - }; - }); + const latestSignals = (qscoreEvidence ?? []).map((signal) => ({ + signalId: signal.signal_id, + score: signal.score, + source: signal.source, + })); const recentEvents = await db .select() .from(growEvents) @@ -79,9 +78,9 @@ export function v1AnalyticsRoutes() { const score = qscoreResult?.q_score ?? null; const strongestSignal = [...latestSignals].sort((a, b) => b.score - a.score)[0]; const weakestSignal = [...latestSignals].sort((a, b) => a.score - b.score)[0]; - const readinessSummary = typeof breakdown.summary === "string" ? breakdown.summary : "No projected readiness summary is available yet."; + const readinessSummary = null; const lastUpdatedAt = qscoreResult?.calculated_at ?? null; - const signalCount = typeof breakdown.signalCount === "number" ? breakdown.signalCount : latestSignals.length; + const signalCount = latestSignals.length; const response = { roleFit: { diff --git a/src/v1/curator/curator-user-context.ts b/src/v1/curator/curator-user-context.ts index cd86d1d..df44a82 100644 --- a/src/v1/curator/curator-user-context.ts +++ b/src/v1/curator/curator-user-context.ts @@ -2,7 +2,7 @@ import { and, desc, eq } from "drizzle-orm"; import { db } from "../../db/client.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 { DEFAULT_QSCORE_ORG_ID, getQscoreFromService, getQscoreLatestSignalsFromService } from "../../services/qscore-proxy.js"; import { config } from "../../config.js"; import type { CuratorTask } from "./curator-types.js"; @@ -178,13 +178,12 @@ export async function buildCuratorUserContext(userId: string): Promise `${row.type} ${row.source} ${payloadText(row.payload)}`).join(" ").toLowerCase(); @@ -225,9 +224,9 @@ export async function buildCuratorUserContext(userId: string): Promise { return !!value && typeof value === "object" && !Array.isArray(value); } -function numberOr(value: unknown, fallback: number): number { - return typeof value === "number" && Number.isFinite(value) ? value : fallback; -} - function dimensionFromKey(id: string, value: unknown) { const num = typeof value === "number" @@ -33,24 +29,37 @@ export function v1QscoreRoutes() { app.get("/latest", async (c) => { const userId = c.get("userId"); - const result = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID); + const [result, latestSignals] = await Promise.all([ + getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID), + getQscoreLatestSignalsFromService(userId, DEFAULT_QSCORE_ORG_ID), + ]); if (!result) { + const evidence = latestSignals ?? []; return c.json({ - status: "baseline_needed", + status: evidence.length ? "evidence_pending" : "baseline_needed", score: null, dimensions: [], - trendLabel: "No QScore signals yet", + trendLabel: evidence.length ? "Evidence received; score pending" : "No QScore signals yet", lastUpdatedAt: null, - explanation: "No onboarding, service completion, or readiness signals have been projected for this user.", - signalCount: 0, - signals: [], + explanation: null, + signalCount: evidence.length, + signals: evidence.map((signal) => ({ + signalId: signal.signal_id, + score: signal.score, + present: signal.present, + source: signal.source, + raw: signal.raw, + occurredAt: signal.last_occurred_at, + updatedAt: signal.last_seen_at || null, + formulaVersion: signal.formula_version, + })), source: "qscore_service", }); } const breakdown = result.breakdown; - const breakdownSignals = Array.isArray(breakdown.signals) ? breakdown.signals : []; + const evidence = latestSignals ?? []; const dimensions = Object.entries(result.quotients) .map(([id, value]) => dimensionFromKey(id, value)) .filter((d): d is { id: string; label: string; score: number; signalCount: number; sources: string[] } => d !== null); @@ -60,28 +69,28 @@ export function v1QscoreRoutes() { status: "ready", score: result.q_score, dimensions, - trendLabel: breakdownSignals.length > 1 ? "Updated from recent activity" : "Baseline established", + trendLabel: evidence.length > 1 ? "Updated from recent activity" : "Evidence established", lastUpdatedAt, - explanation: - typeof breakdown.summary === "string" - ? breakdown.summary - : `Readiness score computed from ${breakdownSignals.length} current signal${breakdownSignals.length === 1 ? "" : "s"}.`, - signalCount: numberOr(breakdown.signalCount, breakdownSignals.length), + explanation: null, + signalCount: evidence.length, iq_score: result.iq_score, eq_score: result.eq_score, sq_score: result.sq_score, - signals: breakdownSignals.map((signal) => { - const s = isRecord(signal) ? signal : {}; - return { - signalId: typeof s.signalId === "string" ? s.signalId : typeof s.signal_id === "string" ? s.signal_id : "", - score: typeof s.score === "number" ? Math.round(s.score) : 0, - present: typeof s.present === "boolean" ? s.present : true, - source: typeof s.source === "string" ? s.source : "", - sourceEventId: typeof s.sourceEventId === "string" ? s.sourceEventId : typeof s.source_event_id === "string" ? s.source_event_id : null, - occurredAt: typeof s.occurredAt === "string" ? s.occurredAt : result.calculated_at, - updatedAt: typeof s.updatedAt === "string" ? s.updatedAt : result.calculated_at, - }; - }), + formulaVersion: result.formula_version, + formulaId: result.formula_id, + ledgerSeqFrom: result.ledger_seq_from, + ledgerSeqTo: result.ledger_seq_to, + breakdown, + signals: evidence.map((signal) => ({ + signalId: signal.signal_id, + score: signal.score, + present: signal.present, + source: signal.source, + raw: signal.raw, + occurredAt: signal.last_occurred_at, + updatedAt: signal.last_seen_at || null, + formulaVersion: signal.formula_version, + })), source: "qscore_service", }); }); -- 2.49.1 From d8b11edcb6b4fc8f93511bab60f73868b40dac2e Mon Sep 17 00:00:00 2001 From: sai karthik Date: Tue, 14 Jul 2026 15:30:29 +0530 Subject: [PATCH 10/16] feat: expose RQ Score contract --- drizzle/0015_rq_score_snapshot.sql | 2 + drizzle/meta/_journal.json | 7 +++ src/actors/analytics/analytics-actor.ts | 2 +- src/actors/conversation/agent.ts | 2 +- .../missions/interview-to-offer/SKILL.md | 2 +- src/actors/user-actor.ts | 12 ++--- src/agents/daily-mission.ts | 2 +- src/db/schema.ts | 2 +- src/home/home-feed.ts | 10 ++-- src/home/types.ts | 2 +- src/missions/suggestions.ts | 6 +-- src/routes/chat.ts | 6 +-- src/routes/conversations.ts | 10 ++-- src/routes/services.ts | 6 +-- src/routes/workflows.ts | 10 ++-- src/services/qscore-proxy.ts | 12 ++--- src/services/service-agents.ts | 6 +-- src/services/service-registry.ts | 18 +++---- src/v1/analytics/analytics-routes.ts | 2 +- src/v1/curator/curator-store.ts | 12 ++--- src/v1/curator/curator-user-context.ts | 4 +- src/v1/curator/icp-registry.ts | 2 +- src/v1/curator/kpi-registry.ts | 14 ++--- src/v1/curator/outcome-report-registry.ts | 12 ++--- src/v1/curator/task-registry.ts | 54 +++++++++---------- src/v1/qscore/qscore-routes.ts | 6 +-- src/workflows/module-runner.ts | 10 ++-- 27 files changed, 120 insertions(+), 113 deletions(-) create mode 100644 drizzle/0015_rq_score_snapshot.sql diff --git a/drizzle/0015_rq_score_snapshot.sql b/drizzle/0015_rq_score_snapshot.sql new file mode 100644 index 0000000..125811d --- /dev/null +++ b/drizzle/0015_rq_score_snapshot.sql @@ -0,0 +1,2 @@ +ALTER TABLE "qscore_snapshots" + RENAME COLUMN "score" TO "rq_score"; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4bd25f0..d2c149a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1783987200000, "tag": "0014_event_outbox", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1784010000000, + "tag": "0015_rq_score_snapshot", + "breakpoints": true } ] } diff --git a/src/actors/analytics/analytics-actor.ts b/src/actors/analytics/analytics-actor.ts index 9e84c4e..403fbcb 100644 --- a/src/actors/analytics/analytics-actor.ts +++ b/src/actors/analytics/analytics-actor.ts @@ -91,7 +91,7 @@ async function userQscoreAnalytics(userId: string) { generatedAt: new Date().toISOString(), current: result ? { - score: result.q_score, + rq_score: result.rq_score, iq_score: result.iq_score, eq_score: result.eq_score, sq_score: result.sq_score, diff --git a/src/actors/conversation/agent.ts b/src/actors/conversation/agent.ts index 775d545..48587c7 100644 --- a/src/actors/conversation/agent.ts +++ b/src/actors/conversation/agent.ts @@ -75,7 +75,7 @@ function safeAgentRegistry() { { id: "interview", name: "Interview Agent", role: "Interview Coach", service: "interview-service", description: "Interview prep specialist.", toolNames: ["prepare_interview_handoff"] }, { id: "roleplay", name: "Roleplay Agent", role: "Roleplay Coach", service: "roleplay-service", description: "Workplace conversation practice specialist.", toolNames: ["prepare_roleplay_handoff"] }, { id: "resume", name: "Resume Agent", role: "Resume Agent", service: "resume-service", description: "Resume positioning and optimization specialist.", toolNames: ["prepare_resume_handoff"] }, - { id: "qscore", name: "Q Score Agent", role: "Q Score Analyst", service: "qscore-service", description: "Readiness score analyst.", toolNames: ["explain_qscore"] }, + { id: "qscore", name: "RQ Score Agent", role: "RQ Score Analyst", service: "qscore-service", description: "Readiness score analyst.", toolNames: ["explain_qscore"] }, ]; } } diff --git a/src/actors/missions/interview-to-offer/SKILL.md b/src/actors/missions/interview-to-offer/SKILL.md index bcadc45..07fd585 100644 --- a/src/actors/missions/interview-to-offer/SKILL.md +++ b/src/actors/missions/interview-to-offer/SKILL.md @@ -24,7 +24,7 @@ This mission is not an autonomous agent. The conversation layer (Grow) owns the 2. Interview prep plan 3. Mock interview session 4. Communication roleplay -5. Final readiness Q Score +5. Final readiness RQ Score ## Artifacts - Interview prep plan diff --git a/src/actors/user-actor.ts b/src/actors/user-actor.ts index a78f578..c85c51e 100644 --- a/src/actors/user-actor.ts +++ b/src/actors/user-actor.ts @@ -213,7 +213,7 @@ function buildUnifiedTools(): Array<{ type: "function" as const, function: { name: "compute_qscore", - description: "Compute or refresh the user's Q Score via the qscore-service microservice.", + description: "Compute or refresh the user's RQ Score via the qscore-service microservice.", parameters: { type: "object", properties: {}, @@ -253,7 +253,7 @@ function buildUnifiedTools(): Array<{ type: "function" as const, function: { name: "start_interview_to_offer", - description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze and tailor the resume for the role, (2) Create mock interview practice, (3) Create mock roleplay practice, (4) Compute Q Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.", + description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze and tailor the resume for the role, (2) Create mock interview practice, (3) Create mock roleplay practice, (4) Compute RQ Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.", parameters: { type: "object", properties: { @@ -810,7 +810,7 @@ async function dispatchUnifiedTool( case "compute_qscore": { const qscoreModule = getSubAgentModule("qscore"); - if (!qscoreModule?.service) return { ok: false, error: "Q Score Agent module not available" }; + if (!qscoreModule?.service) return { ok: false, error: "RQ Score Agent module not available" }; const result = await runServiceAgentProbe( { id: qscoreModule.id, name: qscoreModule.name, role: qscoreModule.role, kind: "score", description: qscoreModule.description, service: qscoreModule.service }, { userId, goal: c.state.workflowGoal || "general assessment" }, @@ -929,12 +929,12 @@ async function dispatchUnifiedTool( c.broadcast("workflow.updated", workflowSnapshot(c.state)); - // Step 4: Q Score — compute readiness + // Step 4: RQ Score — compute readiness const qscoreModule = getSubAgentModule("qscore"); const qscoreMod = c.state.modules.find(m => m.id === "qscore"); if (qscoreMod && qscoreModule?.service) { qscoreMod.status = "running"; - appendTimelineEvent(c.state, qscoreMod, "module", "Q Score is computing your readiness score..."); + appendTimelineEvent(c.state, qscoreMod, "module", "RQ Score is computing your readiness score..."); c.broadcast("workflow.updated", workflowSnapshot(c.state)); try { @@ -947,7 +947,7 @@ async function dispatchUnifiedTool( appendTimelineEvent(c.state, qscoreMod, "module", qscoreResult.summary); } catch (err) { qscoreMod.status = "blocked"; - appendTimelineEvent(c.state, qscoreMod, "module", `Q Score computation failed: ${err instanceof Error ? err.message : String(err)}`); + appendTimelineEvent(c.state, qscoreMod, "module", `RQ Score computation failed: ${err instanceof Error ? err.message : String(err)}`); } } diff --git a/src/agents/daily-mission.ts b/src/agents/daily-mission.ts index d171ae8..5161115 100644 --- a/src/agents/daily-mission.ts +++ b/src/agents/daily-mission.ts @@ -181,7 +181,7 @@ function serviceStartReply(task: DailyMissionTask) { return "This is a Roleplay service handoff. Tell me the scenario you want to practice and the outcome you want from the conversation."; } if (service.includes("q score") || service.includes("qscore") || routePath.includes("/agents/qscore")) { - return "This is a Q Score check. Tell me the signal you want to improve or the readiness question you want scored."; + return "This is an RQ Score check. Tell me the signal you want to improve or the readiness question you want scored."; } return undefined; } diff --git a/src/db/schema.ts b/src/db/schema.ts index 85a971f..f76e7e2 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -279,7 +279,7 @@ export const qscoreSnapshots = pgTable("qscore_snapshots", { userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), runId: text("run_id").references(() => workflowRuns.id, { onDelete: "cascade" }), snapshotType: text("snapshot_type", { enum: ["baseline", "module", "final"] }).notNull(), - score: integer("score"), + rqScore: integer("rq_score"), payload: jsonb("payload").$type>(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), }); diff --git a/src/home/home-feed.ts b/src/home/home-feed.ts index 94e8f5b..33ed535 100644 --- a/src/home/home-feed.ts +++ b/src/home/home-feed.ts @@ -49,7 +49,7 @@ type SeedNotification = Omit & { moduleId: type HomeContext = { user: { id: string; email: string; displayName: string | null } | undefined; - qscore: { score: number; signalCount: number; summary: string | null; dimensions: Record | null } | undefined; + qscore: { rqScore: number; signalCount: number; summary: string | null; dimensions: Record | null } | undefined; qscoreSignals: Array<{ signalId: string; score: number; source: string | null; updatedAt: Date }>; activeMissions: Array<{ instanceId: string; missionId: string; title: string; status: string; progressPercent: number; currentStageId: string | null; updatedAt: Date }>; missionSuggestions: Array<{ id: string; missionInstanceId: string; missionId: string; stageId: string | null; role: string; type: string; title: string; body: string; reason: string | null; priority: number; urgency: string; ctaLabel: string; ctaHref: string; updatedAt: Date }>; @@ -217,7 +217,7 @@ function buildDayOneSeeds(): SeedNotification[] { function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] { const seeds = buildDayOneSeeds().filter((seed) => seed.moduleId === "pathways" || seed.moduleId === "rewards"); const profile = profileFromPreferences(ctx.preferences); - const qscore = ctx.qscore?.score; + const qscore = ctx.qscore?.rqScore; const ats = latestScore(ctx.qscoreSignals, "resume.ats_compatibility"); const interviewOverall = latestScore(ctx.qscoreSignals, "interview.overall_score"); const roleplayComms = latestScore(ctx.qscoreSignals, "roleplay.communication_effectiveness"); @@ -450,7 +450,7 @@ async function collectContext(userId: string, input: { userProfile?: Record a.status === "ready" || a.status === "approved").length; @@ -584,7 +584,7 @@ async function buildIdentity(ctx: HomeContext) { name, caption: "Your living QR · scan to view profile", qrSrc: undefined, - qx: { from: null, to: score, baseline: null }, + rqScore: { from: null, to: score, baseline: null }, glance: [ { value: Math.max(0, ctx.qscore?.signalCount ?? ctx.qscoreSignals.length), label: "Signals" }, { value: completedArtifacts || ctx.events.length, label: "Done" }, diff --git a/src/home/types.ts b/src/home/types.ts index ec49d72..147aeb1 100644 --- a/src/home/types.ts +++ b/src/home/types.ts @@ -28,7 +28,7 @@ export type HomeIdentity = { name: string; caption: string; qrSrc?: string; - qx: { from: number | null; to: number | null; baseline: number | null }; + rqScore: { from: number | null; to: number | null; baseline: number | null }; glance: { value: number; label: string }[]; }; diff --git a/src/missions/suggestions.ts b/src/missions/suggestions.ts index eee5374..e188511 100644 --- a/src/missions/suggestions.ts +++ b/src/missions/suggestions.ts @@ -36,7 +36,7 @@ function roleOf(stage: MissionStage) { const role = stage.role.toLowerCase(); if (role.includes("resume")) return "Resume"; if (role.includes("roleplay") || role.includes("communication")) return "Roleplay"; - if (role.includes("q") || role.includes("score") || role.includes("readiness")) return "Q Score"; + if (role.includes("q") || role.includes("score") || role.includes("readiness")) return "RQ Score"; if (role.includes("interview")) return "Interview"; return stage.role || "Mission"; } @@ -103,7 +103,7 @@ function ctaFor(stage: MissionStage, snapshot: MissionSnapshot, context?: Missio params.set("focus", `${stage.title}: ${profile.targetRole}`); return { label: "Open resume", href: `/agents/resume?${params.toString()}` }; } - if (role === "Q Score") return { label: "View Q Score", href: `/agents/qscore?${params.toString()}` }; + if (role === "RQ Score") return { label: "View RQ Score", href: `/agents/qscore?${params.toString()}` }; return { label: "Continue", href: `${missionDetailHref(snapshot.instanceId)}?${params.toString()}` }; } @@ -129,7 +129,7 @@ export function buildDeterministicMissionSuggestions(snapshot: MissionSnapshot, ? `Tailor your resume toward ${profile.targetRole}${profile.targetCompany !== "target company" ? ` at ${profile.targetCompany}` : ""}, emphasizing measurable data-science impact.` : role === "Roleplay" ? `Roleplay concise stakeholder communication for ${profile.targetRole} interviews, especially ${profile.weakSpots.slice(0, 2).join(" and ") || "tradeoff framing"}.` - : role === "Q Score" + : role === "RQ Score" ? `Review readiness signals against your ${profile.targetRole} goal and decide the next highest-leverage action.` : undefined; diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 775e141..5fbff31 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -94,7 +94,7 @@ function buildTools() { type: "function" as const, function: { name: "compute_qscore", - description: "Compute the user's readiness Q Score via qscore-service.", + description: "Compute the user's readiness RQ Score via qscore-service.", parameters: { type: "object", properties: {}, @@ -233,11 +233,11 @@ export function chatRoutes() { } case "compute_qscore": { toolResult = await runServiceAgentProbe( - { id: "qscore", name: "Q Score Agent", role: "Q Score Agent", kind: "score", description: "Readiness scoring", service: "qscore-service" }, + { id: "qscore", name: "RQ Score Agent", role: "RQ Score Agent", kind: "score", description: "Readiness scoring", service: "qscore-service" }, { userId, goal: "general assessment" }, ); if (toolResult.status === "ok") { - sessions.push({ moduleId: "qscore", moduleName: "Q Score Agent", status: "done", summary: toolResult.summary }); + sessions.push({ moduleId: "qscore", moduleName: "RQ Score Agent", status: "done", summary: toolResult.summary }); } break; } diff --git a/src/routes/conversations.ts b/src/routes/conversations.ts index 8d044c0..cf9fc83 100644 --- a/src/routes/conversations.ts +++ b/src/routes/conversations.ts @@ -235,10 +235,10 @@ Personality & Tone: How to help: - Just talk. Answer questions, give advice, ask follow-ups. Don't follow a rigid script. -- Only use tools when the user clearly asks for something specific (e.g., "show me my missions", "start a mission", "what's my Q Score", "save this"). +- Only use tools when the user clearly asks for something specific (e.g., "show me my missions", "start a mission", "what's my RQ Score", "save this"). - Don't call tools for general chitchat, emotional support, simple explanations, or when a normal text answer would do. - If you don't know something, say so. If a tool fails, just say it failed and move on. -- When the user asks about interview prep, roleplay, resume, or Q Score — just answer normally. Only route to a specialist tool if they explicitly say something like "connect me to the interview specialist" or "let me talk to the roleplay agent". +- When the user asks about interview prep, roleplay, resume, or RQ Score — just answer normally. Only route to a specialist tool if they explicitly say something like "connect me to the interview specialist" or "let me talk to the roleplay agent". - When the user asks to see today’s missions, streak tasks, daily tasks, missed tasks, task progress, or report readiness, call showCareerSprintTasks if CareerSprint context is available. - Use discoverWorkflows only for broad workflow/program discovery. When they ask about memory, use the memory tools. Otherwise, just chat. - Only start a mission if the user clearly says yes to starting one. Don't push. @@ -252,7 +252,7 @@ function safeAgentRegistry() { return [ { id: "interview", name: "Interview Agent", role: "Interview Coach", service: "interview-service", description: "Warm, direct interview practice coach for behavioral and technical interview prep.", toolNames: ["start_interview_session"] }, { id: "roleplay", name: "Roleplay Agent", role: "Roleplay Coach", service: "roleplay-service", description: "High-empathy roleplay partner for negotiation, recruiter, manager, and stakeholder conversations.", toolNames: ["start_roleplay_session"] }, - { id: "qscore", name: "Q Score Agent", role: "Q Score Analyst", service: "qscore-service", description: "Analytical readiness scorer that turns profile signals into concrete score-improvement moves.", toolNames: ["compute_qscore"] }, + { id: "qscore", name: "RQ Score Agent", role: "RQ Score Analyst", service: "qscore-service", description: "Analytical readiness scorer that turns profile signals into concrete score-improvement moves.", toolNames: ["compute_qscore"] }, { id: "resume", name: "Resume Agent", role: "Resume Agent", service: "resume-service", description: "ATS-aware resume specialist for sharper bullets, positioning, and role fit.", toolNames: ["analyze_resume", "tailor_resume"] }, ]; } @@ -276,10 +276,10 @@ When helping: - Keep it practical. No fake cheerleading. Return compact, dialogue-heavy markdown.`, - qscore: `You are the Q Score Agent. You're an analytical readiness scorer. Explain the numbers plainly. + qscore: `You are the RQ Score Agent. You're an analytical readiness scorer. Explain the numbers plainly. When helping: -- Explain what the Q Score measures and why it matters. +- Explain what the RQ Score measures and why it matters. - Identify the 2-3 dimensions with the biggest improvement potential. - Give specific, time-bound actions for each dimension. - Use numbers when you have them. Don't sugarcoat or reframe weaknesses as "growth edges". diff --git a/src/routes/services.ts b/src/routes/services.ts index cba0fc1..8bc0b8f 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -809,7 +809,7 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) { getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID), getQscoreLatestSignalsFromService(userId, DEFAULT_QSCORE_ORG_ID), ]); - const score = qscoreResult?.q_score ?? null; + const rqScore = qscoreResult?.rq_score ?? null; const breakdown = qscoreResult?.breakdown ?? {}; const evidence = latestSignals ?? []; const quotients = qscoreResult?.quotients ?? {}; @@ -835,8 +835,8 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) { .filter((d): d is { id: string; label: string; score: number; signalCount: number; sources: string[] } => d !== null); const response = { - qscore: score === null ? null : { - score, + qscore: rqScore === null ? null : { + rq_score: rqScore, iq_score: qscoreResult?.iq_score ?? null, eq_score: qscoreResult?.eq_score ?? null, sq_score: qscoreResult?.sq_score ?? null, diff --git a/src/routes/workflows.ts b/src/routes/workflows.ts index 14785c8..82a488b 100644 --- a/src/routes/workflows.ts +++ b/src/routes/workflows.ts @@ -67,10 +67,10 @@ export function workflowRoutes() { await db.insert(workflowRunModules).values(def.modules.map((m) => ({ runId: run.id, moduleId: m.id, title: m.title, status: "idle", service: m.service }))); await db.insert(workflowEvents).values({ runId: run.id, userId, type: "workflow.started", payload: { workflowId: def.id, goal: body.goal } }); try { - const baseline = await runServiceAgentProbe({ id: "qscore", name: "Q Score Agent", role: "Q Score", kind: "score", description: "Baseline Q Score", service: "qscore-service" }, { userId, goal: body.goal ?? "workflow baseline" }); + const baseline = await runServiceAgentProbe({ id: "qscore", name: "RQ Score Agent", role: "RQ Score", kind: "score", description: "Baseline RQ Score", service: "qscore-service" }, { userId, goal: body.goal ?? "workflow baseline" }); if (baseline.status === "ok" && baseline.detail) { const payload = baseline.detail as Record; - await db.insert(qscoreSnapshots).values({ userId, runId: run.id, snapshotType: "baseline", score: extractQScore(payload), payload }); + await db.insert(qscoreSnapshots).values({ userId, runId: run.id, snapshotType: "baseline", rqScore: extractRqScore(payload), payload }); await db.update(workflowRuns).set({ qscoreBefore: payload, updatedAt: new Date() }).where(eq(workflowRuns.id, run.id)); } } catch { @@ -248,11 +248,11 @@ async function runModulesUntilGate(input: { } } -function extractQScore(output: Record): number | undefined { - const direct = output.q_score; +function extractRqScore(output: Record): number | undefined { + const direct = output.rq_score; if (typeof direct === "number") return Math.round(direct); const compute = output.compute as Record | undefined; - if (typeof compute?.q_score === "number") return Math.round(compute.q_score); + if (typeof compute?.rq_score === "number") return Math.round(compute.rq_score); return undefined; } diff --git a/src/services/qscore-proxy.ts b/src/services/qscore-proxy.ts index 848d71b..63ba2c3 100644 --- a/src/services/qscore-proxy.ts +++ b/src/services/qscore-proxy.ts @@ -33,11 +33,10 @@ export function toQscoreUserId(userId: string): string { export type QscoreServiceResult = { org_id: string; user_id: string; - q_score: number; + rq_score: number; iq_score: number | null; eq_score: number | null; sq_score: number | null; - overall_rqscore: number | null; profession: string; formula_version: string; formula_id: string; @@ -78,9 +77,9 @@ function parseResult(body: unknown): QscoreServiceResult { if (!isRecord(body)) { throw new Error("qscore_service returned non-object response"); } - const q = body.q_score; - if (typeof q !== "number" || !Number.isFinite(q)) { - throw new Error("qscore_service response missing numeric q_score"); + const rqScore = body.rq_score; + if (typeof rqScore !== "number" || !Number.isFinite(rqScore)) { + throw new Error("qscore_service response missing numeric rq_score"); } const numOrNull = (v: unknown): number | null => typeof v === "number" && Number.isFinite(v) ? v : null; @@ -88,11 +87,10 @@ function parseResult(body: unknown): QscoreServiceResult { return { org_id: str(body.org_id), user_id: str(body.user_id), - q_score: q, + rq_score: rqScore, iq_score: numOrNull(body.iq_score), eq_score: numOrNull(body.eq_score), sq_score: numOrNull(body.sq_score), - overall_rqscore: numOrNull(body.overall_rqscore), profession: str(body.profession), formula_version: str(body.formula_version), formula_id: str(body.formula_id), diff --git a/src/services/service-agents.ts b/src/services/service-agents.ts index 64028c4..453aa66 100644 --- a/src/services/service-agents.ts +++ b/src/services/service-agents.ts @@ -183,7 +183,7 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise = { "social-branding-service": "Social Branding", - "qscore-service": "Q Score", + "qscore-service": "RQ Score", "assessment-service": "Assessment", "expert-service": "Expert", "events-service": "Events", diff --git a/src/v1/analytics/analytics-routes.ts b/src/v1/analytics/analytics-routes.ts index 83d15ea..b74c67d 100644 --- a/src/v1/analytics/analytics-routes.ts +++ b/src/v1/analytics/analytics-routes.ts @@ -75,7 +75,7 @@ export function v1AnalyticsRoutes() { const bucket = sourceBucket(event.source); serviceCounts.set(bucket, (serviceCounts.get(bucket) ?? 0) + 1); } - const score = qscoreResult?.q_score ?? null; + const score = qscoreResult?.rq_score ?? null; const strongestSignal = [...latestSignals].sort((a, b) => b.score - a.score)[0]; const weakestSignal = [...latestSignals].sort((a, b) => a.score - b.score)[0]; const readinessSummary = null; diff --git a/src/v1/curator/curator-store.ts b/src/v1/curator/curator-store.ts index 8f5c9d5..4f2bb51 100644 --- a/src/v1/curator/curator-store.ts +++ b/src/v1/curator/curator-store.ts @@ -380,7 +380,7 @@ function normalizeEventBackedTask(task: PlannedTask, planDay: PlanDaySeed, recen "Use analytics to anchor today’s work in a real measurable signal.", "5 min", "+5 projected", - "Review Q Score", + "Review RQ Score", ["readiness signal", "analytics"], ), planDay.weekTheme, @@ -395,13 +395,13 @@ function fallbackCarryForwardWeek(templateSet: CuratorIcpTemplateSet): WeekTempl summary: `Use the strongest signals from ${lastWeek.theme.toLowerCase()} to lock in momentum and define the next move.`, days: [ { - measurement: seed("measurement", "qscore-service", "Review your 30-day movement", "Check which readiness signals moved most over the sprint and what still needs work.", "5 min", "+5 projected", "Review Q Score", ["30 day review", "signal movement"]), + measurement: seed("measurement", "qscore-service", "Review your 30-day movement", "Check which readiness signals moved most over the sprint and what still needs work.", "5 min", "+5 projected", "Review RQ Score", ["30 day review", "signal movement"]), proof: seed("proof", "resume-service", "Package your strongest proof into one asset", "Turn the best sprint outcome into a reusable bullet, story, or artifact.", "10 min", "+7 projected", "Open resume workspace", ["proof packaging", "story asset"]), practice: seed("practice", "interview-service", "Run a final closeout interview rep", "Use one realistic question to prove the sprint improved your clarity and confidence.", "10 min", "+10 projected", "Open interview preview", ["closeout rep", "confidence"]), roleplay: seed("practice", "roleplay-service", "Run a final conversation rep", "Use one realistic conversation to prove the sprint improved your confidence under pressure.", "10 min", "+9 projected", "Open roleplay preview", ["closeout roleplay", "confidence"]), }, { - measurement: seed("measurement", "qscore-service", "Choose the next readiness driver", "Pick the one signal that should define the next 30-day push.", "5 min", "+5 projected", "Review Q Score", ["next sprint", "readiness driver"]), + measurement: seed("measurement", "qscore-service", "Choose the next readiness driver", "Pick the one signal that should define the next 30-day push.", "5 min", "+5 projected", "Review RQ Score", ["next sprint", "readiness driver"]), proof: seed("proof", "social-branding-service", "Turn sprint movement into a visible update", "Package your strongest improvement into a public-safe profile or positioning update.", "10 min", "+6 projected", "Open social profile flow", ["visibility update", "carry forward"]), practice: seed("practice", "matchmaking-service", "Review the next opportunities to act on", "Use the stronger profile and practice signals to shortlist the next roles or pathways.", "10 min", "+5 projected", "Open pathways", ["next opportunities", "carry forward"]), roleplay: seed("practice", "roleplay-service", "Practice the next high-value conversation", "Rehearse the conversation most likely to unlock the next sprint move.", "10 min", "+9 projected", "Open roleplay preview", ["next conversation", "carry forward"]), @@ -855,7 +855,7 @@ async function loadRecentContextRows(userId: string, sinceDate: string) { async function loadCurrentQScore(userId: string) { const result = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID); - return result?.q_score ?? null; + return result?.rq_score ?? null; } export type CuratorCompletionEvent = Pick; @@ -1093,7 +1093,7 @@ function adaptCurrentDayPlan( 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"]), + 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 RQ Score", ["blocker review", "momentum"]), current.weekTheme, current.weekSummary, ); @@ -1112,7 +1112,7 @@ function adaptCurrentDayPlan( if ((currentQScore ?? 0) > 0 && (currentQScore ?? 0) < 55) { current.plannedTasks[0] = makePlannedTask( - seed("measurement", "qscore-service", "Review the weakest readiness driver", "Use analytics to focus today on the signal that is still pulling the score down most.", "5 min", "+5 projected", "Review Q Score", ["weakest driver", "analytics"]), + seed("measurement", "qscore-service", "Review the weakest readiness driver", "Use analytics to focus today on the signal that is still pulling the score down most.", "5 min", "+5 projected", "Review RQ Score", ["weakest driver", "analytics"]), current.weekTheme, current.weekSummary, ); diff --git a/src/v1/curator/curator-user-context.ts b/src/v1/curator/curator-user-context.ts index ff12b87..e37b1f1 100644 --- a/src/v1/curator/curator-user-context.ts +++ b/src/v1/curator/curator-user-context.ts @@ -38,7 +38,7 @@ export type CuratorUserContext = { latestEvents: Array<{ type: string; source: string; occurredAt: string; summary?: string }>; }; qscore: { - score: number | null; + rq_score: number | null; signalCount: number; summary: string | null; dimensions: Record | null; @@ -224,7 +224,7 @@ export async function buildCuratorUserContext(userId: string): Promise = { goal: "Improve callback conversion, sharpen proof, and build stronger interview confidence across the sprint.", stageLabels: ["Baseline + First Proof", "Fix Obvious Gaps", "Build Proof Momentum", "Market-Ready Practice", "Closeout + Next Sprint"], serviceActions: [ - play("measurement", "qscore-service", "readiness baseline", "Anchor the sprint in current QScore and missing signals.", "analytics", ["qscore", "readiness"]), + play("measurement", "qscore-service", "readiness baseline", "Anchor the sprint in the current RQ Score and missing signals.", "analytics", ["qscore", "readiness"]), play("proof", "resume-service", "role-fit proof", "Tailor resume proof to target roles and outcomes.", "resume workspace", ["resume proof", "role fit"]), play("proof", "social-branding-service", "credibility signal", "Create visible credibility updates from real work.", "social profile flow", ["credibility", "visibility"]), play("practice", "interview-service", "callback conversion", "Run focused interview reps for weak question types.", "interview preview", ["interview practice", "callback"]), diff --git a/src/v1/curator/kpi-registry.ts b/src/v1/curator/kpi-registry.ts index 2dbe369..ee4099c 100644 --- a/src/v1/curator/kpi-registry.ts +++ b/src/v1/curator/kpi-registry.ts @@ -74,7 +74,7 @@ function kpis(icpId: CuratorIcpId, seeds: KpiSeed[]): CuratorKpiDefinition[] { export const CURATOR_KPI_REGISTRY: Record = { student_recent_grad: kpis("student_recent_grad", [ { key: "branding", category: "Professional Branding", label: "GrowQR profile and LinkedIn profile completed", requiredServiceIds: COMMON_KPI_KEYS.branding }, - { key: "intelligence", category: "Career Intelligence", label: "QX Score baseline established and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence }, + { key: "intelligence", category: "Career Intelligence", label: "RQ Score baseline established and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence }, { key: "assets", category: "Career Assets", label: "Resume, cover letter and academic portfolio completed", requiredServiceIds: COMMON_KPI_KEYS.assets }, { key: "interview", category: "Interview Readiness", label: "Mock interview and communication practice completed", requiredServiceIds: COMMON_KPI_KEYS.interview }, { key: "learning", category: "Learning", label: "One AI-recommended course completed", requiredServiceIds: COMMON_KPI_KEYS.learning }, @@ -84,7 +84,7 @@ export const CURATOR_KPI_REGISTRY: Record ]), intern: kpis("intern", [ { key: "branding", category: "Professional Branding", label: "GrowQR profile and LinkedIn profile completed", requiredServiceIds: COMMON_KPI_KEYS.branding }, - { key: "intelligence", category: "Career Intelligence", label: "QX Score baseline established and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence }, + { key: "intelligence", category: "Career Intelligence", label: "RQ Score baseline established and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence }, { key: "assets", category: "Career Assets", label: "Resume, cover letter and project portfolio completed", requiredServiceIds: COMMON_KPI_KEYS.assets }, { key: "interview", category: "Interview Readiness", label: "HR interview and workplace simulation completed", requiredServiceIds: COMMON_KPI_KEYS.interview }, { key: "learning", category: "Learning", label: "One personalized learning pathway completed", requiredServiceIds: COMMON_KPI_KEYS.learning }, @@ -94,7 +94,7 @@ export const CURATOR_KPI_REGISTRY: Record ]), fresher_early_professional: kpis("fresher_early_professional", [ { key: "branding", category: "Professional Branding", label: "GrowQR profile and LinkedIn profile completed", requiredServiceIds: COMMON_KPI_KEYS.branding }, - { key: "intelligence", category: "Career Intelligence", label: "QX Score baseline established and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence }, + { key: "intelligence", category: "Career Intelligence", label: "RQ Score baseline established and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence }, { key: "assets", category: "Career Assets", label: "Resume, cover letter and project portfolio completed", requiredServiceIds: COMMON_KPI_KEYS.assets }, { key: "interview", category: "Interview Readiness", label: "Mock interview and workplace simulation completed", requiredServiceIds: COMMON_KPI_KEYS.interview }, { key: "learning", category: "Learning", label: "One recommended course completed", requiredServiceIds: COMMON_KPI_KEYS.learning }, @@ -104,7 +104,7 @@ export const CURATOR_KPI_REGISTRY: Record ]), experienced_professional: kpis("experienced_professional", [ { key: "branding", category: "Executive Branding", label: "Executive profile, leadership bio and thought leadership completed", requiredServiceIds: COMMON_KPI_KEYS.branding }, - { key: "intelligence", category: "Leadership Intelligence", label: "Executive QX Score baseline and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence }, + { key: "intelligence", category: "Leadership Intelligence", label: "Executive RQ Score baseline and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence }, { key: "assets", category: "Career Assets", label: "Executive resume, leadership portfolio and achievement documentation completed", requiredServiceIds: COMMON_KPI_KEYS.assets }, { key: "interview", category: "Leadership Readiness", label: "Executive interview and boardroom simulations completed", requiredServiceIds: COMMON_KPI_KEYS.interview }, { key: "learning", category: "Learning", label: "Executive leadership course completed", requiredServiceIds: COMMON_KPI_KEYS.learning }, @@ -114,7 +114,7 @@ export const CURATOR_KPI_REGISTRY: Record ]), freelancer: kpis("freelancer", [ { key: "branding", category: "Personal Branding", label: "Freelancer profile and service page completed", requiredServiceIds: COMMON_KPI_KEYS.branding }, - { key: "intelligence", category: "Business Intelligence", label: "Freelancer QX Score baseline and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence }, + { key: "intelligence", category: "Business Intelligence", label: "Freelancer RQ Score baseline and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence }, { key: "assets", category: "Portfolio & Proof", label: "Portfolio, case study and proposal templates created", requiredServiceIds: COMMON_KPI_KEYS.assets }, { key: "interview", category: "Client Readiness", label: "Client pitch and negotiation simulations completed", requiredServiceIds: COMMON_KPI_KEYS.interview }, { key: "learning", category: "Learning", label: "One freelancer business course completed", requiredServiceIds: COMMON_KPI_KEYS.learning }, @@ -124,7 +124,7 @@ export const CURATOR_KPI_REGISTRY: Record ]), founder: kpis("founder", [ { key: "branding", category: "Founder Brand", label: "Founder profile and startup page completed", requiredServiceIds: COMMON_KPI_KEYS.branding }, - { key: "intelligence", category: "Venture Intelligence", label: "Founder QX Score baseline and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence }, + { key: "intelligence", category: "Venture Intelligence", label: "Founder RQ Score baseline and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence }, { key: "assets", category: "Business Proof", label: "Founder story, pitch deck and MVP portfolio completed", requiredServiceIds: COMMON_KPI_KEYS.assets }, { key: "validation", category: "Customer Validation", label: "Customer discovery conversations completed", requiredServiceIds: ["roleplay-service"] }, { key: "interview", category: "Investor Readiness", label: "Investor pitch practiced and refined", requiredServiceIds: ["interview-service", "roleplay-service"] }, @@ -135,7 +135,7 @@ export const CURATOR_KPI_REGISTRY: Record ]), enterprise: kpis("enterprise", [ { key: "branding", category: "Employer Branding", label: "Employer profile updated and employer branding campaign launched", requiredServiceIds: COMMON_KPI_KEYS.branding }, - { key: "intelligence", category: "Workforce Intelligence", label: "Enterprise QX Score baseline and capability dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence }, + { key: "intelligence", category: "Workforce Intelligence", label: "Enterprise RQ Score baseline and capability dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence }, { key: "capability", category: "Capability Development", label: "Competency framework, learning paths and succession plans created", requiredServiceIds: ["resume-service", "courses-service"] }, { key: "hiring", category: "Hiring Excellence", label: "Standardized interview process and AI-assisted hiring toolkit deployed", requiredServiceIds: ["resume-service", "roleplay-service"] }, { key: "leadership", category: "Leadership Development", label: "Leadership simulations and coaching sessions completed", requiredServiceIds: ["roleplay-service"] }, diff --git a/src/v1/curator/outcome-report-registry.ts b/src/v1/curator/outcome-report-registry.ts index 430e92d..d99c4b2 100644 --- a/src/v1/curator/outcome-report-registry.ts +++ b/src/v1/curator/outcome-report-registry.ts @@ -25,7 +25,7 @@ function outcomes(icpId: CuratorIcpId, seeds: OutcomeSeed[]): CuratorOutcomeDefi export const CURATOR_OUTCOME_REPORT_REGISTRY: Record = { student_recent_grad: outcomes("student_recent_grad", [ { key: "branding", text: "Professional GrowQR profile showcasing academic achievements and career aspirations." }, - { key: "intelligence", text: "Measurable QX Score with clear insights into career readiness." }, + { key: "intelligence", text: "Measurable RQ Score with clear insights into career readiness." }, { key: "assets", text: "ATS-ready resume, personalized cover letter and academic project portfolio." }, { key: "interview", text: "Increased confidence through AI-powered interview practice and workplace simulations." }, { key: "learning", text: "Personalized learning recommendations based on identified skill gaps." }, @@ -35,7 +35,7 @@ export const CURATOR_OUTCOME_REPORT_REGISTRY: Record = { student_recent_grad: staticIcpTemplate("student_recent_grad", "Student / Recent Grad", "7-Day Student CareerSprint", "Build a professional identity, career assets, interview confidence, and learning momentum.", [ - day("Start Your Story", "Career profile established with a measurable readiness baseline.", "Claim Your Corner", "Complete your GrowQR profile with education, interests, career goals and profile photo.", "social-branding-service", "Know Your Number", "Review your QX Score, Career Dashboard and complete your Career Readiness Assessment.", "qscore-service", "Resume Rescue", "Create your first AI-powered resume using your academic achievements.", "resume-service", "Pitch Perfect", "Record a 60-second self-introduction for recruiters and mentors.", "interview-service"), + day("Start Your Story", "Career profile established with a measurable readiness baseline.", "Claim Your Corner", "Complete your GrowQR profile with education, interests, career goals and profile photo.", "social-branding-service", "Know Your Number", "Review your RQ Score, Career Dashboard and complete your Career Readiness Assessment.", "qscore-service", "Resume Rescue", "Create your first AI-powered resume using your academic achievements.", "resume-service", "Pitch Perfect", "Record a 60-second self-introduction for recruiters and mentors.", "interview-service"), day("Discover Your Strengths", "Stronger professional identity and clearer career direction.", "Headline Hero", "Write a compelling professional headline and About section.", "social-branding-service", "Skill Gap Radar", "Review assessment insights and identify your top three improvement areas.", "assessment-service", "Project Spotlight", "Add one academic project, internship or extracurricular achievement.", "resume-service", "Manager Mode", "Practice introducing yourself in a professional workplace conversation.", "roleplay-service"), day("Build Credibility", "Career proof strengthened with interview feedback received.", "Proof Beats Promises", "Upload certificates, awards or extracurricular achievements.", "social-branding-service", "Career Snapshot", "Review ATS readiness, profile completion and Dashboard insights.", "qscore-service", "Portfolio Power", "Create a simple academic portfolio or project showcase.", "resume-service", "Interview Replay", "Complete a mock interview and review AI-generated feedback.", "interview-service"), day("Explore Opportunities", "Opportunity pipeline activated and application assets prepared.", "Go Visible", "Update your LinkedIn and GrowQR professional profile.", "social-branding-service", "Opportunity Radar", "Review AI-matched internships, campus placements and research opportunities.", "matchmaking-service", "Cover Letter Boost", "Generate a personalized cover letter for a selected opportunity.", "resume-service", "Group Discussion Lab", "Practice communication through an AI group discussion simulation.", "roleplay-service"), - day("Learn To Grow", "Skills improved with measurable learning progress.", "Show Your Progress", "Publish your latest certification or learning achievement.", "social-branding-service", "Growth Snapshot", "Review Dashboard, updated QX Score and weekly readiness improvements.", "qscore-service", "Learning Sprint", "Complete one AI-recommended course to close a skill gap.", "courses-service", "Communication Lab", "Practice workplace communication and presentation skills.", "roleplay-service"), + day("Learn To Grow", "Skills improved with measurable learning progress.", "Show Your Progress", "Publish your latest certification or learning achievement.", "social-branding-service", "Growth Snapshot", "Review Dashboard, updated RQ Score and weekly readiness improvements.", "qscore-service", "Learning Sprint", "Complete one AI-recommended course to close a skill gap.", "courses-service", "Communication Lab", "Practice workplace communication and presentation skills.", "roleplay-service"), day("Grow Your Network", "Professional network expanded with expert guidance.", "Network Smart", "Connect with alumni, mentors, faculty members and professionals.", "social-branding-service", "Career Intelligence", "Review networking suggestions and recruiter recommendations.", "matchmaking-service", "Expert Connect", "Book a mentor session for resume review or career guidance.", "expert-service", "Career Connect Live", "Attend a GrowQR workshop, employer webinar or networking event.", "events-service"), - day("Ready For Tomorrow", "Career-ready profile completed with a personalized action plan for internships and first-job opportunities.", "Ready To Shine", "Share your weekly achievements and future career aspirations.", "social-branding-service", "Career Scorecard", "Review Dashboard, updated QX Score and weekly progress report.", "qscore-service", "Career Blueprint", "Generate your AI-powered Career Growth Blueprint and apply for your best-matched opportunity.", "resume-service", "Mock Finale", "Complete a full AI interview simulation for your dream internship or first job.", "interview-service"), + day("Ready For Tomorrow", "Career-ready profile completed with a personalized action plan for internships and first-job opportunities.", "Ready To Shine", "Share your weekly achievements and future career aspirations.", "social-branding-service", "Career Scorecard", "Review Dashboard, updated RQ Score and weekly progress report.", "qscore-service", "Career Blueprint", "Generate your AI-powered Career Growth Blueprint and apply for your best-matched opportunity.", "resume-service", "Mock Finale", "Complete a full AI interview simulation for your dream internship or first job.", "interview-service"), ]), intern: staticIcpTemplate("intern", "Intern / Early Career", "7-Day Early CareerSprint", "Convert internship work into career proof, stronger interviews, workplace confidence, and learning momentum.", [ - day("Own Your Journey", "Professional profile established with career readiness baseline.", "Claim Your Corner", "Complete your GrowQR profile with internship experience, skills and career goals.", "social-branding-service", "Know Your Number", "Review your QX Score, Career Dashboard and complete your Career Readiness Assessment.", "qscore-service", "Resume Reloaded", "Build an AI-optimized resume tailored to your target role.", "resume-service", "Pitch Perfect", "Record a 60-second professional introduction highlighting your internship experience.", "interview-service"), + day("Own Your Journey", "Professional profile established with career readiness baseline.", "Claim Your Corner", "Complete your GrowQR profile with internship experience, skills and career goals.", "social-branding-service", "Know Your Number", "Review your RQ Score, Career Dashboard and complete your Career Readiness Assessment.", "qscore-service", "Resume Reloaded", "Build an AI-optimized resume tailored to your target role.", "resume-service", "Pitch Perfect", "Record a 60-second professional introduction highlighting your internship experience.", "interview-service"), day("Show Your Growth", "Internship experience converted into impactful career proof.", "Headline Hero", "Write a compelling professional headline and update your About section.", "social-branding-service", "Career Snapshot", "Review recruiter visibility, ATS readiness and internship-to-job readiness.", "qscore-service", "Achievement Spotlight", "Transform internship tasks into measurable achievements.", "resume-service", "Recruiter Connect", "Practice an HR screening interview.", "roleplay-service"), day("Build Credibility", "Professional credibility enhanced through measurable work examples.", "Proof Beats Promises", "Publish one internship project, certification or professional achievement.", "social-branding-service", "Skill Gap Radar", "Review competency assessment and identify improvement areas.", "assessment-service", "Project Showcase", "Document a project demonstrating business impact.", "resume-service", "Manager Mode", "Practice workplace communication with a reporting manager.", "roleplay-service"), day("Find Better Opportunities", "Career documents optimized and opportunity pipeline activated.", "Go Visible", "Strengthen your LinkedIn and GrowQR professional profile.", "social-branding-service", "Opportunity Radar", "Review AI-recommended early-career roles and opportunities.", "matchmaking-service", "Application Pack", "Generate a customized resume and cover letter for a selected role.", "resume-service", "Workplace Conversations", "Practice collaboration and workplace scenarios.", "roleplay-service"), - day("Strengthen Your Skills", "Skill gaps addressed and interview confidence improved.", "Build Authority", "Share a certification, learning milestone or project update.", "social-branding-service", "Growth Snapshot", "Review Dashboard insights, QX Score movement and interview readiness.", "qscore-service", "Learning Sprint", "Complete an AI-recommended course aligned with your career goals.", "courses-service", "Interview Replay", "Complete a mock interview and implement AI feedback.", "interview-service"), + day("Strengthen Your Skills", "Skill gaps addressed and interview confidence improved.", "Build Authority", "Share a certification, learning milestone or project update.", "social-branding-service", "Growth Snapshot", "Review Dashboard insights, RQ Score movement and interview readiness.", "qscore-service", "Learning Sprint", "Complete an AI-recommended course aligned with your career goals.", "courses-service", "Interview Replay", "Complete a mock interview and implement AI feedback.", "interview-service"), day("Expand Your Network", "Professional network expanded with expert guidance and recruiter exposure.", "Network Smart", "Connect with recruiters, mentors, managers and industry professionals.", "social-branding-service", "Career Intelligence", "Review networking opportunities and recruiter engagement.", "matchmaking-service", "Expert Connect", "Book an expert session for career guidance or resume review.", "expert-service", "Career Connect Live", "Attend a GrowQR networking session, employer webinar or career event.", "events-service"), - day("Step Into Opportunity", "Career-ready profile completed with active applications and a personalized growth roadmap.", "Ready To Rise", "Publish your weekly achievements and career aspirations.", "social-branding-service", "Career Scorecard", "Review Dashboard, updated QX Score and weekly employability report.", "qscore-service", "Career Blueprint", "Generate your AI-powered Career Advancement Blueprint and submit applications to your top-matched roles.", "resume-service", "Mock Finale", "Complete a full interview simulation for your preferred job role.", "interview-service"), + day("Step Into Opportunity", "Career-ready profile completed with active applications and a personalized growth roadmap.", "Ready To Rise", "Publish your weekly achievements and career aspirations.", "social-branding-service", "Career Scorecard", "Review Dashboard, updated RQ Score and weekly employability report.", "qscore-service", "Career Blueprint", "Generate your AI-powered Career Advancement Blueprint and submit applications to your top-matched roles.", "resume-service", "Mock Finale", "Complete a full interview simulation for your preferred job role.", "interview-service"), ]), fresher_early_professional: staticIcpTemplate("fresher_early_professional", "Fresher / Active Job Seeker", "7-Day Job Seeker CareerSprint", "Improve employability, recruiter visibility, interview readiness, and application quality.", [ - day("Start Strong", "Professional profile completed with a measurable employability baseline.", "Claim Your Corner", "Complete your GrowQR profile with education, skills, career goals and professional photo.", "social-branding-service", "Know Your Number", "Review your QX Score, Career Dashboard and complete your Career Readiness Assessment.", "qscore-service", "Resume Rescue", "Build your first AI-optimized resume for your preferred job role.", "resume-service", "Pitch Perfect", "Record a 60-second self-introduction for recruiters.", "interview-service"), + day("Start Strong", "Professional profile completed with a measurable employability baseline.", "Claim Your Corner", "Complete your GrowQR profile with education, skills, career goals and professional photo.", "social-branding-service", "Know Your Number", "Review your RQ Score, Career Dashboard and complete your Career Readiness Assessment.", "qscore-service", "Resume Rescue", "Build your first AI-optimized resume for your preferred job role.", "resume-service", "Pitch Perfect", "Record a 60-second self-introduction for recruiters.", "interview-service"), day("Build Visibility", "Resume optimized for recruiter searches and stronger first impressions.", "Headline Hero", "Write an attention-grabbing professional headline and About section.", "social-branding-service", "Career Snapshot", "Review profile completion, ATS readiness and recruiter visibility.", "qscore-service", "Keyword Lift", "Optimize your resume with AI-recommended keywords.", "resume-service", "Recruiter Connect", "Practice a recruiter screening conversation.", "roleplay-service"), day("Show Your Potential", "Stronger proof of capability and improved interview awareness.", "Proof Beats Promises", "Publish an academic project, internship or achievement.", "social-branding-service", "Skill Gap Radar", "Review your capability assessment and identify priority skills.", "assessment-service", "Project Spotlight", "Add one detailed project with measurable outcomes.", "resume-service", "Interview Replay", "Complete a behavioural mock interview and review AI feedback.", "interview-service"), day("Find Better Opportunities", "Career documents prepared and opportunity pipeline activated.", "Go Visible", "Update your LinkedIn and GrowQR professional presence.", "social-branding-service", "Opportunity Radar", "Review AI-matched jobs, internships and career opportunities.", "matchmaking-service", "Cover Letter Boost", "Generate a personalized cover letter for a target role.", "resume-service", "Manager Mode", "Practice workplace communication with an AI manager simulation.", "roleplay-service"), - day("Grow Your Skills", "Skill gaps reduced and communication confidence improved.", "Build Authority", "Share one learning milestone or certification.", "social-branding-service", "Growth Snapshot", "Review Dashboard insights, QX Score movement and profile strength.", "qscore-service", "Learning Sprint", "Complete one AI-recommended skill course.", "courses-service", "Communication Lab", "Practice workplace communication and confidence exercises.", "roleplay-service"), + day("Grow Your Skills", "Skill gaps reduced and communication confidence improved.", "Build Authority", "Share one learning milestone or certification.", "social-branding-service", "Growth Snapshot", "Review Dashboard insights, RQ Score movement and profile strength.", "qscore-service", "Learning Sprint", "Complete one AI-recommended skill course.", "courses-service", "Communication Lab", "Practice workplace communication and confidence exercises.", "roleplay-service"), day("Expand Your Network", "Professional network expanded with expert guidance received.", "Network Smart", "Connect with recruiters, alumni and professionals in your field.", "social-branding-service", "Career Intelligence", "Review networking opportunities and recruiter recommendations.", "matchmaking-service", "Expert Connect", "Book a mentor session for resume or career guidance.", "expert-service", "Career Connect Live", "Attend a GrowQR hiring webinar or networking event.", "events-service"), - day("Launch Your Career", "Job-ready profile completed with applications submitted and clear next steps.", "Ready To Shine", "Publish your career goals and professional achievements.", "social-branding-service", "Career Scorecard", "Review Dashboard, updated QX Score and weekly progress report.", "qscore-service", "Application Ready", "Generate your AI-powered Career Action Blueprint and apply to your best-matched role.", "resume-service", "Mock Finale", "Complete a full AI interview simulation for your target role.", "interview-service"), + day("Launch Your Career", "Job-ready profile completed with applications submitted and clear next steps.", "Ready To Shine", "Publish your career goals and professional achievements.", "social-branding-service", "Career Scorecard", "Review Dashboard, updated RQ Score and weekly progress report.", "qscore-service", "Application Ready", "Generate your AI-powered Career Action Blueprint and apply to your best-matched role.", "resume-service", "Mock Finale", "Complete a full AI interview simulation for your target role.", "interview-service"), ]), experienced_professional: staticIcpTemplate("experienced_professional", "Experienced Professional", "7-Day Leadership CareerSprint", "Strengthen leadership proof, senior interview readiness, conversation confidence, and learning momentum.", [ - day("Lead With Purpose", "Executive profile established with leadership readiness baseline.", "Own Your Executive Brand", "Complete your GrowQR executive profile, leadership bio and career vision.", "social-branding-service", "Know Your Number", "Review Executive QX Score, Dashboard and Leadership Assessment.", "qscore-service", "Executive Resume", "Create a leadership-focused resume highlighting measurable business impact.", "resume-service", "Executive Introduction", "Record a 90-second executive leadership introduction.", "interview-service"), + day("Lead With Purpose", "Executive profile established with leadership readiness baseline.", "Own Your Executive Brand", "Complete your GrowQR executive profile, leadership bio and career vision.", "social-branding-service", "Know Your Number", "Review Executive RQ Score, Dashboard and Leadership Assessment.", "qscore-service", "Executive Resume", "Create a leadership-focused resume highlighting measurable business impact.", "resume-service", "Executive Introduction", "Record a 90-second executive leadership introduction.", "interview-service"), day("Build Executive Presence", "Executive positioning strengthened with measurable business impact.", "Thought Leader Mode", "Publish your leadership philosophy or industry insight.", "social-branding-service", "Leadership Pulse", "Review leadership strengths, competency gaps and opportunity insights.", "matchmaking-service", "Impact Portfolio", "Document major achievements, transformation projects and business outcomes.", "resume-service", "Boardroom Briefing", "Practice executive stakeholder communication.", "roleplay-service"), day("Show Strategic Value", "Leadership capability validated with executive proof assets.", "Influence In Action", "Share a case study, article or strategic business insight.", "social-branding-service", "Capability Scan", "Review leadership capability assessment and organizational fit.", "assessment-service", "Promotion Portfolio", "Prepare evidence for promotions, executive interviews or board appointments.", "resume-service", "Executive Interview", "Complete a senior leadership mock interview.", "interview-service"), day("Expand Your Influence", "Executive opportunities identified and leadership brand elevated.", "Network With Purpose", "Strengthen your executive network and update your professional presence.", "social-branding-service", "Opportunity Scan", "Review senior leadership roles, advisory positions and board opportunities.", "matchmaking-service", "Executive Brand Kit", "Optimize executive profile, LinkedIn and professional portfolio.", "resume-service", "Negotiation Room", "Practice salary, board and leadership negotiations.", "roleplay-service"), - day("Future-Proof Leadership", "Leadership capabilities strengthened through targeted executive learning.", "Executive Visibility", "Publish a leadership perspective or innovation story.", "social-branding-service", "Growth Snapshot", "Review Dashboard, QX trends and executive readiness analytics.", "qscore-service", "Leadership Learning", "Complete an executive leadership or AI strategy course.", "courses-service", "Crisis Leadership", "Practice managing high-pressure executive scenarios.", "roleplay-service"), + day("Future-Proof Leadership", "Leadership capabilities strengthened through targeted executive learning.", "Executive Visibility", "Publish a leadership perspective or innovation story.", "social-branding-service", "Growth Snapshot", "Review Dashboard, RQ Score trends and executive readiness analytics.", "qscore-service", "Leadership Learning", "Complete an executive leadership or AI strategy course.", "courses-service", "Crisis Leadership", "Practice managing high-pressure executive scenarios.", "roleplay-service"), day("Build Strategic Relationships", "Expanded strategic network and leadership guidance established.", "Executive Connector", "Engage with senior professionals, mentors and industry communities.", "social-branding-service", "Market Intelligence", "Review strategic collaborations, board roles and executive opportunities.", "matchmaking-service", "Expert Connect", "Book a mentoring session with an industry leader or executive coach.", "expert-service", "Leadership Forum", "Attend an executive webinar or GrowQR leadership event.", "events-service"), - day("Lead The Future", "Executive CareerSprint completed with a measurable leadership roadmap and next-level career strategy.", "Executive Spotlight", "Publish your leadership roadmap and professional milestones.", "social-branding-service", "Leadership Scorecard", "Review Dashboard, Executive QX Score and weekly progress analytics.", "qscore-service", "Career Blueprint", "Generate an AI-powered Executive Growth & Leadership Blueprint.", "resume-service", "Boardroom Simulation", "Deliver a complete executive presentation and strategic decision simulation.", "roleplay-service"), + day("Lead The Future", "Executive CareerSprint completed with a measurable leadership roadmap and next-level career strategy.", "Executive Spotlight", "Publish your leadership roadmap and professional milestones.", "social-branding-service", "Leadership Scorecard", "Review Dashboard, Executive RQ Score and weekly progress analytics.", "qscore-service", "Career Blueprint", "Generate an AI-powered Executive Growth & Leadership Blueprint.", "resume-service", "Boardroom Simulation", "Deliver a complete executive presentation and strategic decision simulation.", "roleplay-service"), ]), freelancer: staticIcpTemplate("freelancer", "Freelancer / Gig User", "7-Day Freelancer CareerSprint", "Build trust, package freelance proof, practice client conversations, and improve business skills.", [ - day("Build Your Brand", "Professional freelance identity established with measurable business baseline.", "Claim Your Corner", "Complete your freelancer profile, niche and service offerings.", "social-branding-service", "Know Your Number", "Review Freelancer QX Score, Dashboard and Business Readiness Assessment.", "qscore-service", "Portfolio Rescue", "Upload your best work, portfolio and service profile.", "resume-service", "Client Pitch", "Record a 60-second introduction for prospective clients.", "interview-service"), + day("Build Your Brand", "Professional freelance identity established with measurable business baseline.", "Claim Your Corner", "Complete your freelancer profile, niche and service offerings.", "social-branding-service", "Know Your Number", "Review Freelancer RQ Score, Dashboard and Business Readiness Assessment.", "qscore-service", "Portfolio Rescue", "Upload your best work, portfolio and service profile.", "resume-service", "Client Pitch", "Record a 60-second introduction for prospective clients.", "interview-service"), day("Package Your Value", "Clear service positioning and improved client readiness.", "Show Your Expertise", "Publish your services and professional strengths.", "social-branding-service", "Opportunity Radar", "Review recommended freelance projects and client opportunities.", "matchmaking-service", "Service Showcase", "Create attractive service packages and pricing overview.", "resume-service", "Discovery Call", "Practice your first client consultation.", "roleplay-service"), day("Earn Trust", "Stronger credibility through real business proof.", "Proof Beats Promises", "Publish testimonials or client success stories.", "social-branding-service", "Trust Meter", "Track profile strength and credibility indicators.", "qscore-service", "Case Study Builder", "Document one successful project with measurable outcomes.", "resume-service", "Client Objection Lab", "Practice handling pricing and client objections.", "roleplay-service"), day("Find More Clients", "Qualified client pipeline established.", "Go Visible", "Share a professional insight or portfolio update.", "social-branding-service", "Lead Finder", "Review AI-matched projects and client opportunities.", "matchmaking-service", "Proposal Builder", "Generate a professional proposal template.", "resume-service", "Sales Conversation", "Simulate a client proposal presentation.", "roleplay-service"), day("Grow Your Business", "Business skills strengthened and pricing confidence improved.", "Build Authority", "Publish expertise through LinkedIn or GrowQR community.", "social-branding-service", "Business Snapshot", "Review Dashboard, earnings potential and pipeline analytics.", "qscore-service", "Learning Sprint", "Complete a course on sales, marketing or pricing.", "courses-service", "Negotiation Lab", "Practice pricing and contract negotiation.", "roleplay-service"), day("Expand Your Network", "Expanded professional network and collaboration opportunities.", "Community Builder", "Connect with clients, creators and professionals.", "social-branding-service", "Opportunity Pulse", "Review collaborations, referrals and strategic matches.", "matchmaking-service", "Expert Connect", "Book a mentor or business expert consultation.", "expert-service", "Freelancer Meetup", "Attend a GrowQR networking event or webinar.", "events-service"), - day("Scale Forward", "Business growth roadmap completed with stronger client acquisition strategy.", "Trusted Professional", "Publish your weekly growth achievements and client success.", "social-branding-service", "Growth Scorecard", "Review Dashboard, Freelancer QX Score and weekly business insights.", "qscore-service", "Growth Blueprint", "Generate your AI-powered Client Growth & Business Roadmap.", "resume-service", "Premium Client Meeting", "Deliver a complete client pitch simulation.", "interview-service"), + day("Scale Forward", "Business growth roadmap completed with stronger client acquisition strategy.", "Trusted Professional", "Publish your weekly growth achievements and client success.", "social-branding-service", "Growth Scorecard", "Review Dashboard, Freelancer RQ Score and weekly business insights.", "qscore-service", "Growth Blueprint", "Generate your AI-powered Client Growth & Business Roadmap.", "resume-service", "Premium Client Meeting", "Deliver a complete client pitch simulation.", "interview-service"), ]), founder: staticIcpTemplate("founder", "Founder / Solopreneur", "7-Day Founder Acceleration Sprint", "Validate venture proof, strengthen founder credibility, and practice customer or investor conversations.", [ - day("Own Your Vision", "Founder identity established with a measurable business baseline.", "Build In Public", "Complete your Founder Profile, business description and company branding.", "social-branding-service", "Know Your Number", "Review Founder QX Score, Dashboard and Startup Readiness Assessment.", "qscore-service", "Founder Story", "Create your founder profile, mission and venture overview.", "resume-service", "Founder Pitch", "Record a 90-second startup introduction.", "interview-service"), + day("Own Your Vision", "Founder identity established with a measurable business baseline.", "Build In Public", "Complete your Founder Profile, business description and company branding.", "social-branding-service", "Know Your Number", "Review Founder RQ Score, Dashboard and Startup Readiness Assessment.", "qscore-service", "Founder Story", "Create your founder profile, mission and venture overview.", "resume-service", "Founder Pitch", "Record a 90-second startup introduction.", "interview-service"), day("Validate Your Idea", "Product positioning clarified through customer validation.", "Mission Matters", "Publish your vision and value proposition.", "social-branding-service", "Market Radar", "Review customer opportunities and industry trends.", "matchmaking-service", "Problem–Solution Canvas", "Build your customer problem statement and solution.", "resume-service", "Customer Discovery", "Practice customer discovery conversations.", "roleplay-service"), day("Build Credibility", "Investor-ready business story developed.", "Show Your Progress", "Share an update about your product or startup.", "social-branding-service", "Traction Pulse", "Track business readiness and founder score.", "qscore-service", "Pitch Deck Builder", "Create an investor-ready pitch deck.", "resume-service", "Investor Warm-Up", "Practice investor introduction and elevator pitch.", "interview-service"), day("Find Your Market", "Business proof strengthened with qualified opportunities identified.", "Become Discoverable", "Optimize founder profile and startup page.", "social-branding-service", "Opportunity Scan", "Explore customers, investors, grants and partnerships.", "matchmaking-service", "Business Portfolio", "Upload MVP screenshots, website or product demo.", "resume-service", "Sales Conversation", "Roleplay first customer sales meeting.", "roleplay-service"), - day("Grow Smarter", "Founder capability expanded through learning and negotiation practice.", "Lead With Value", "Publish an industry insight or founder journey.", "social-branding-service", "Growth Snapshot", "Review Dashboard, customer pipeline and QX progress.", "qscore-service", "Learning Sprint", "Complete one founder-focused course.", "courses-service", "Negotiation Lab", "Practice pricing, partnership or investment negotiation.", "roleplay-service"), + day("Grow Smarter", "Founder capability expanded through learning and negotiation practice.", "Lead With Value", "Publish an industry insight or founder journey.", "social-branding-service", "Growth Snapshot", "Review Dashboard, customer pipeline and RQ Score progress.", "qscore-service", "Learning Sprint", "Complete one founder-focused course.", "courses-service", "Negotiation Lab", "Practice pricing, partnership or investment negotiation.", "roleplay-service"), day("Build Your Network", "Strategic relationships established with founders, mentors and investors.", "Community Builder", "Engage with founders and startup communities.", "social-branding-service", "Network Intelligence", "Review strategic matches and collaboration opportunities.", "matchmaking-service", "Expert Connect", "Book a mentor session and refine your strategy.", "expert-service", "Founder Roundtable", "Attend a GrowQR startup event or networking session.", "events-service"), - day("Launch Forward", "Founder completes a 7-day launch roadmap with measurable business readiness and a clear action plan.", "Founder Spotlight", "Publish your startup milestone and growth roadmap.", "social-branding-service", "Growth Scorecard", "Review Dashboard, Founder QX Score and capability improvements.", "qscore-service", "Startup Blueprint", "Generate your AI-powered Growth & Funding Blueprint.", "resume-service", "Vision Presentation", "Deliver your final investor/customer presentation.", "interview-service"), + day("Launch Forward", "Founder completes a 7-day launch roadmap with measurable business readiness and a clear action plan.", "Founder Spotlight", "Publish your startup milestone and growth roadmap.", "social-branding-service", "Growth Scorecard", "Review Dashboard, Founder RQ Score and capability improvements.", "qscore-service", "Startup Blueprint", "Generate your AI-powered Growth & Funding Blueprint.", "resume-service", "Vision Presentation", "Deliver your final investor/customer presentation.", "interview-service"), ]), enterprise: staticIcpTemplate("enterprise", "Enterprise", "7-Day Enterprise Capability Sprint", "Activate workforce capability proof, structured hiring practice, leadership simulations, and learning pathways.", [ - day("Know Your Workforce", "Enterprise capability baseline established with organizational priorities identified.", "Shape Your Story", "Publish or refresh your Employer Value Proposition and company profile.", "social-branding-service", "Know Your Workforce", "Review Enterprise QX Score, Dashboard and workforce capability baseline.", "qscore-service", "Capability Blueprint", "Upload competency framework, hiring roles and workforce structure.", "resume-service", "Leadership Kickoff", "Complete an executive leadership simulation.", "roleplay-service"), + day("Know Your Workforce", "Enterprise capability baseline established with organizational priorities identified.", "Shape Your Story", "Publish or refresh your Employer Value Proposition and company profile.", "social-branding-service", "Know Your Workforce", "Review Enterprise RQ Score, Dashboard and workforce capability baseline.", "qscore-service", "Capability Blueprint", "Upload competency framework, hiring roles and workforce structure.", "resume-service", "Leadership Kickoff", "Complete an executive leadership simulation.", "roleplay-service"), day("Measure Your Talent", "Skill gaps identified and hiring standards aligned.", "Culture Showcase", "Highlight employee culture, achievements and workplace initiatives with LinkedIn Post.", "social-branding-service", "Skill Gap Radar", "Run organization-wide capability assessment.", "assessment-service", "Talent Profiles", "Create AI-ready hiring profiles and role descriptions.", "resume-service", "Interview Calibration", "Standardize interview questions with AI Interview Coach.", "roleplay-service"), day("Strengthen Capability", "Learning pathways activated and leadership coaching initiated.", "Lead With Purpose", "Publish a leadership or employer branding update.", "social-branding-service", "Capability Heatmap", "Review department-wise readiness dashboard.", "qscore-service", "Learning Pathways", "Assign personalized learning journeys to employees.", "courses-service", "Manager Coaching", "Practice performance review conversations.", "roleplay-service"), day("Build Better Hiring", "Hiring quality improved with standardized recruitment practices.", "Employer Spotlight", "Showcase hiring philosophy and employee success stories.", "social-branding-service", "Talent Match Scan", "Review AI talent recommendations and internal mobility insights.", "matchmaking-service", "Hiring Playbook", "Generate structured hiring toolkit and competency matrix.", "resume-service", "Hiring Lab", "Conduct structured interview simulations.", "roleplay-service"), - day("Grow Leaders", "Leadership pipeline strengthened with succession planning.", "People First", "Share a postemployee development initiatives.", "social-branding-service", "Leadership Pulse", "Review leadership readiness dashboard and QX trends.", "qscore-service", "Leadership Portfolio", "Document succession plans and leadership competencies.", "resume-service", "Executive Roleplay", "Simulate stakeholder, boardroom and conflict scenarios.", "roleplay-service"), + day("Grow Leaders", "Leadership pipeline strengthened with succession planning.", "People First", "Share a postemployee development initiatives.", "social-branding-service", "Leadership Pulse", "Review leadership readiness dashboard and RQ Score trends.", "qscore-service", "Leadership Portfolio", "Document succession plans and leadership competencies.", "resume-service", "Executive Roleplay", "Simulate stakeholder, boardroom and conflict scenarios.", "roleplay-service"), day("Activate Growth", "Organizational capability accelerated through expert intervention.", "Innovation Culture", "Promote innovation, awards and organizational achievements on social media.", "social-branding-service", "Growth Snapshot", "Track workforce learning completion and hiring analytics.", "qscore-service", "Expert Connect", "Book an expert consultation or capability workshop.", "expert-service", "Capability Workshop", "Attend GrowQR expert-led enterprise learning session.", "events-service"), - day("Future Ready Enterprise", "Enterprise receives a complete workforce capability roadmap with measurable actions for hiring, learning, leadership and transformation.", "Future Forward", "Launch employer branding campaign across digital channels.", "social-branding-service", "Capability Scorecard", "Review Enterprise Dashboard, QX Score improvements and analytics.", "qscore-service", "Transformation Blueprint", "Generate AI-powered workforce transformation report.", "resume-service", "Leadership Summit", "Complete executive simulation and strategic planning workshop.", "events-service"), + day("Future Ready Enterprise", "Enterprise receives a complete workforce capability roadmap with measurable actions for hiring, learning, leadership and transformation.", "Future Forward", "Launch employer branding campaign across digital channels.", "social-branding-service", "Capability Scorecard", "Review Enterprise Dashboard, RQ Score improvements and analytics.", "qscore-service", "Transformation Blueprint", "Generate AI-powered workforce transformation report.", "resume-service", "Leadership Summit", "Complete executive simulation and strategic planning workshop.", "events-service"), ]), }; export const CURATOR_TRIAL_TASK_REGISTRY: Record = { student_recent_grad: [ - trialDay("Launch Your Journey", "Professional profile created and career readiness baseline established.", "Complete GrowQR Profile", "Complete GrowQR profile and career interests.", "Career Baseline", "Complete Career Assessment and view QX Score.", "First AI Resume", "Create your first AI resume.", "Self-Introduction", "Record a 60-second self-introduction.", "interview-service"), + trialDay("Launch Your Journey", "Professional profile created and career readiness baseline established.", "Complete GrowQR Profile", "Complete GrowQR profile and career interests.", "Career Baseline", "Complete Career Assessment and view RQ Score.", "First AI Resume", "Create your first AI resume.", "Self-Introduction", "Record a 60-second self-introduction.", "interview-service"), trialDay("Step Into Opportunity", "Ready to apply for internships with improved confidence.", "Add First Proof", "Add one project or certification.", "Review Progress", "Review Dashboard improvements.", "Internship Cover Letter", "Generate a cover letter for an internship.", "Group Discussion", "Practice a group discussion roleplay.", "roleplay-service"), ], intern: [ - trialDay("Build Career Momentum", "Stronger professional positioning and career baseline.", "Internship Profile", "Update internship achievements and LinkedIn headline.", "Career Baseline", "Complete Career Assessment and QX Score.", "Target Role Resume", "Optimize resume for your target role.", "HR Interview", "Complete an HR interview simulation.", "interview-service"), + trialDay("Build Career Momentum", "Stronger professional positioning and career baseline.", "Internship Profile", "Update internship achievements and LinkedIn headline.", "Career Baseline", "Complete Career Assessment and RQ Score.", "Target Role Resume", "Optimize resume for your target role.", "HR Interview", "Complete an HR interview simulation.", "interview-service"), trialDay("Turn Experience Into Opportunity", "Better interview readiness and stronger recruiter profile.", "Publish Achievement", "Publish one internship or project achievement.", "Review Progress", "Review Dashboard progress.", "Role-Specific Resume", "Create a role-specific resume version.", "Manager Conversation", "Practice a workplace manager conversation.", "roleplay-service"), ], fresher_early_professional: [ - trialDay("Get Recruiter Ready", "Resume optimized and recruiter visibility improved.", "Recruiter Profile", "Complete professional profile and LinkedIn summary.", "QX + ATS Baseline", "Complete QX Score and ATS Assessment.", "ATS-Ready Resume", "Generate an ATS-ready resume.", "Recruiter Screening", "Complete a recruiter screening interview.", "interview-service"), + trialDay("Get Recruiter Ready", "Resume optimized and recruiter visibility improved.", "Recruiter Profile", "Complete professional profile and LinkedIn summary.", "RQ Score + ATS Baseline", "Complete RQ Score and ATS Assessment.", "ATS-Ready Resume", "Generate an ATS-ready resume.", "Recruiter Screening", "Complete a recruiter screening interview.", "interview-service"), trialDay("Apply With Confidence", "Applications submitted with greater confidence.", "Career Milestone", "Publish a career milestone.", "Review Analytics", "Review Dashboard analytics.", "Personalized Cover Letter", "Generate a personalized cover letter.", "Behavioural Interview", "Practice a behavioural interview.", "roleplay-service"), ], experienced_professional: [ - trialDay("Lead Your Next Chapter", "Executive brand established.", "Executive Profile", "Complete executive profile and leadership summary.", "Executive Baseline", "Complete Leadership Assessment and Executive QX Score.", "Executive Resume", "Create an executive resume.", "Executive Introduction", "Practice an executive introduction.", "interview-service"), + trialDay("Lead Your Next Chapter", "Executive brand established.", "Executive Profile", "Complete executive profile and leadership summary.", "Executive Baseline", "Complete Leadership Assessment and Executive RQ Score.", "Executive Resume", "Create an executive resume.", "Executive Introduction", "Practice an executive introduction.", "interview-service"), trialDay("Influence With Impact", "Leadership readiness strengthened.", "Leadership Insight", "Publish a leadership insight.", "Executive Dashboard", "Review Executive Dashboard.", "Executive Portfolio", "Build an executive portfolio.", "Boardroom Simulation", "Practice a boardroom simulation.", "roleplay-service"), ], freelancer: [ - trialDay("Win Your First Client", "Professional freelance identity established.", "Freelancer Profile", "Complete freelancer profile and service catalogue.", "Business Baseline", "Complete Business Readiness Assessment and QX Score.", "Portfolio Upload", "Upload portfolio and service offerings.", "Client Introduction", "Practice a client introduction.", "interview-service"), + trialDay("Win Your First Client", "Professional freelance identity established.", "Freelancer Profile", "Complete freelancer profile and service catalogue.", "Business Baseline", "Complete Business Readiness Assessment and RQ Score.", "Portfolio Upload", "Upload portfolio and service offerings.", "Client Introduction", "Practice a client introduction.", "interview-service"), trialDay("Build Client Trust", "Client-ready portfolio and stronger business confidence.", "Portfolio Proof", "Publish a portfolio item or testimonial.", "Business Dashboard", "Review Business Dashboard.", "Client Cover Letter", "Generate a client-ready cover letter.", "Client Negotiation", "Practice client negotiation roleplay.", "roleplay-service"), ], founder: [ - trialDay("Build Your Venture", "Founder readiness baseline established.", "Founder Profile", "Complete founder profile and startup overview.", "Founder Baseline", "Complete Founder Assessment and QX Score.", "Founder Portfolio", "Create founder portfolio or pitch summary.", "Investor Pitch", "Record an investor pitch.", "interview-service"), + trialDay("Build Your Venture", "Founder readiness baseline established.", "Founder Profile", "Complete founder profile and startup overview.", "Founder Baseline", "Complete Founder Assessment and RQ Score.", "Founder Portfolio", "Create founder portfolio or pitch summary.", "Investor Pitch", "Record an investor pitch.", "interview-service"), trialDay("Pitch. Validate. Grow.", "Investor-ready profile with validated next steps.", "Startup Vision", "Publish startup vision.", "Founder Dashboard", "Review Founder Dashboard.", "Founder Assets", "Upload resume to update it in different formats.", "Customer Discovery", "Practice customer discovery roleplay.", "roleplay-service"), ], enterprise: [ - trialDay("Empower Your Workforce", "Workforce capability baseline established.", "Employer Profile", "Complete employer profile.", "Enterprise Baseline", "Complete Workforce Assessment and Enterprise QX Score.", "Competency Framework", "Upload competency framework.", "Leadership Calibration", "Practice leadership interview calibration.", "interview-service"), + trialDay("Empower Your Workforce", "Workforce capability baseline established.", "Employer Profile", "Complete employer profile.", "Enterprise Baseline", "Complete Workforce Assessment and Enterprise RQ Score.", "Competency Framework", "Upload competency framework.", "Leadership Calibration", "Practice leadership interview calibration.", "interview-service"), trialDay("Lead Workforce Transformation", "AI-powered workforce capability roadmap initiated.", "Employer Branding", "Publish employer branding update.", "Enterprise Dashboard", "Review Enterprise Dashboard.", "Hiring Toolkit", "Generate hiring competency toolkit.", "Leadership Coaching", "Practice leadership coaching simulation.", "roleplay-service"), ], }; diff --git a/src/v1/qscore/qscore-routes.ts b/src/v1/qscore/qscore-routes.ts index 7e27402..19264d3 100644 --- a/src/v1/qscore/qscore-routes.ts +++ b/src/v1/qscore/qscore-routes.ts @@ -38,9 +38,9 @@ export function v1QscoreRoutes() { const evidence = latestSignals ?? []; return c.json({ status: evidence.length ? "evidence_pending" : "baseline_needed", - score: null, + rq_score: null, dimensions: [], - trendLabel: evidence.length ? "Evidence received; score pending" : "No QScore signals yet", + trendLabel: evidence.length ? "Evidence received; score pending" : "No RQ Score signals yet", lastUpdatedAt: null, explanation: null, signalCount: evidence.length, @@ -67,7 +67,7 @@ export function v1QscoreRoutes() { return c.json({ status: "ready", - score: result.q_score, + rq_score: result.rq_score, dimensions, trendLabel: evidence.length > 1 ? "Updated from recent activity" : "Evidence established", lastUpdatedAt, diff --git a/src/workflows/module-runner.ts b/src/workflows/module-runner.ts index aa5af49..fd09af1 100644 --- a/src/workflows/module-runner.ts +++ b/src/workflows/module-runner.ts @@ -27,8 +27,8 @@ export async function executeWorkflowModule(input: { userId: string; runId: stri const output = result.detail as Record | undefined; await db.update(workflowRunModules).set({ status, outputSummary: result.summary, output, completedAt: new Date() }).where(and(eq(workflowRunModules.runId, input.runId), eq(workflowRunModules.moduleId, input.moduleId))); if (mod.service === "qscore-service" && output) { - const score = extractQScore(output); - await db.insert(qscoreSnapshots).values({ userId: input.userId, runId: input.runId, snapshotType: "module", score, payload: output }); + const rqScore = extractRqScore(output); + await db.insert(qscoreSnapshots).values({ userId: input.userId, runId: input.runId, snapshotType: "module", rqScore, payload: output }); await db.update(workflowRuns).set({ qscoreAfter: output, updatedAt: new Date() }).where(eq(workflowRuns.id, input.runId)); } await db.insert(workflowEvents).values({ runId: input.runId, userId: input.userId, type: status === "done" ? "module.completed" : "module.blocked", payload: { moduleId: input.moduleId, summary: result.summary, output } }); @@ -80,10 +80,10 @@ export async function updateRunProgress(runId: string) { } } -function extractQScore(output: Record): number | undefined { - const direct = output.q_score; +function extractRqScore(output: Record): number | undefined { + const direct = output.rq_score; if (typeof direct === "number") return Math.round(direct); const compute = output.compute as Record | undefined; - if (typeof compute?.q_score === "number") return Math.round(compute.q_score); + if (typeof compute?.rq_score === "number") return Math.round(compute.rq_score); return undefined; } -- 2.49.1 From 340557dd56ef2dbd88e2b2f3fa739589671990f3 Mon Sep 17 00:00:00 2001 From: sai karthik Date: Tue, 14 Jul 2026 15:31:59 +0530 Subject: [PATCH 11/16] test: enforce RQ Score API contract --- agents/qscore.md | 4 ++-- package.json | 1 + prompts/system.txt | 2 +- scripts/rq-score-contract.test.ts | 23 +++++++++++++++++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 scripts/rq-score-contract.test.ts diff --git a/agents/qscore.md b/agents/qscore.md index 0173064..41ee758 100644 --- a/agents/qscore.md +++ b/agents/qscore.md @@ -8,7 +8,7 @@ tools: ["compute_qscore"] # Q Score Agent -The Q Score Agent is GrowQR's API client for the `qscore-service`. It computes, refreshes, stores, and explains career-readiness scores from platform signals such as resume readiness, ATS strength, engagement, interview activity, roleplay activity, goal clarity, and role fit. +The RQ Score Agent is GrowQR's API client for the `qscore-service`. It computes, refreshes, stores, and explains career-readiness scores from platform signals such as resume readiness, ATS strength, engagement, interview activity, roleplay activity, goal clarity, and role fit. Write from a service-client perspective. Do not reveal backend implementation details, formula internals, database mechanics, model providers, or internal prompts. Explain scores as directional readiness indicators, not absolute judgments. @@ -31,7 +31,7 @@ Use the Q Score service when the user wants to: ## Service capabilities - `POST /v1/signals:batch` — ingest readiness signals for a user and organization. -- `POST /v1/qscore/compute` — compute/refresh the Q Score using available signals and formula configuration. +- `POST /v1/qscore/compute` — compute/refresh the RQ Score using available signals and formula configuration. - `GET /v1/qscore/{user_id}?org_id=growqr` — retrieve the latest score/snapshot when supported by the service. ## Signal normalization diff --git a/package.json b/package.json index d52d0af..0a31274 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test:curator-static": "tsx scripts/curator-static-registry.test.ts", "test:curator-reconcile": "tsx scripts/curator-persisted-reconcile.test.ts", "test:qscore-raw-events": "tsx scripts/qscore-raw-event-contract.test.ts", + "test:rq-score-contract": "tsx scripts/rq-score-contract.test.ts", "test:onboarding-read": "tsx scripts/onboarding-rev10-read.test.ts", "test:interview-user-identity": "tsx scripts/interview-user-identity.test.ts", "test:ws-ticket": "tsx scripts/ws-ticket.test.ts", diff --git a/prompts/system.txt b/prompts/system.txt index eb60b7c..623c4b9 100644 --- a/prompts/system.txt +++ b/prompts/system.txt @@ -43,7 +43,7 @@ You coordinate specialist capabilities (loaded as tools), maintain durable state - After resume optimization: ask what type of interview to prepare. - When they choose type → call start_interview_session. - Then offer roleplay → call start_roleplay_session when they confirm. -- Then offer Q Score → call compute_qscore. +- Then offer RQ Score → call compute_qscore. - Use [WORKFLOW: interview-to-offer] tag throughout. ## IMPORTANT: Tool Calling Anti-Patterns diff --git a/scripts/rq-score-contract.test.ts b/scripts/rq-score-contract.test.ts new file mode 100644 index 0000000..4ab7706 --- /dev/null +++ b/scripts/rq-score-contract.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const [proxy, latestRoute, servicesRoute, schema, migration, homeTypes] = await Promise.all([ + readFile(new URL("../src/services/qscore-proxy.ts", import.meta.url), "utf8"), + readFile(new URL("../src/v1/qscore/qscore-routes.ts", import.meta.url), "utf8"), + readFile(new URL("../src/routes/services.ts", import.meta.url), "utf8"), + readFile(new URL("../src/db/schema.ts", import.meta.url), "utf8"), + readFile(new URL("../drizzle/0015_rq_score_snapshot.sql", import.meta.url), "utf8"), + readFile(new URL("../src/home/types.ts", import.meta.url), "utf8"), +]); + +assert.match(proxy, /const rqScore = body\.rq_score;/, "service proxy must require rq_score"); +assert.doesNotMatch(proxy, /body\.q_score/, "service proxy must not accept the old q_score field"); +assert.match(latestRoute, /rq_score: result\.rq_score/, "latest API must expose rq_score"); +assert.doesNotMatch(latestRoute, /\bscore: result\.rq_score/, "latest API must not alias RQ Score as score"); +assert.match(servicesRoute, /rq_score: rqScore/, "gateway API must expose rq_score"); +assert.match(schema, /rqScore: integer\("rq_score"\)/, "snapshot schema must map rq_score"); +assert.match(migration, /RENAME COLUMN "score" TO "rq_score"/, "migration must preserve snapshot data while renaming the column"); +assert.match(homeTypes, /rqScore: \{ from:/, "home API must expose rqScore"); +assert.doesNotMatch(homeTypes, /\bqx:/, "home API must not expose the old qx field"); + +console.log("rq score API and database contract: ok"); -- 2.49.1 From 1d374560841259db07972f11ed9174ea9c74e41a Mon Sep 17 00:00:00 2001 From: sai karthik Date: Tue, 14 Jul 2026 15:32:42 +0530 Subject: [PATCH 12/16] docs: finish RQ Score agent rename --- agents/interview.md | 2 +- agents/qscore.md | 14 +++++++------- agents/resume.md | 2 +- agents/roleplay.md | 2 +- src/services/qscore-proxy.ts | 2 +- src/services/service-agents.ts | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/agents/interview.md b/agents/interview.md index 0dabbaa..127b7e9 100644 --- a/agents/interview.md +++ b/agents/interview.md @@ -36,7 +36,7 @@ Use the interview-service when the user wants any of the following: Do not use interview-service for: - Resume writing, tailoring, ATS optimization, resume scoring, resume bullet generation → Resume Agent. - Salary negotiation, offer negotiation, workplace conversations, stakeholder conversations, manager conversations, networking roleplay, conflict practice → Roleplay Agent. -- Overall market-readiness/Q-score computation → Q Score Agent. +- Overall market-readiness/RQ Score computation → RQ Score Agent. - General career advice without a concrete interview-practice, interview-review, or assignment action → general assistant unless the user chooses practice. ## Service capabilities from a client perspective diff --git a/agents/qscore.md b/agents/qscore.md index 41ee758..7054ea2 100644 --- a/agents/qscore.md +++ b/agents/qscore.md @@ -1,12 +1,12 @@ --- id: qscore -name: Q Score Agent -role: Q Score Agent +name: RQ Score Agent +role: RQ Score Agent service: qscore-service tools: ["compute_qscore"] --- -# Q Score Agent +# RQ Score Agent The RQ Score Agent is GrowQR's API client for the `qscore-service`. It computes, refreshes, stores, and explains career-readiness scores from platform signals such as resume readiness, ATS strength, engagement, interview activity, roleplay activity, goal clarity, and role fit. @@ -14,8 +14,8 @@ Write from a service-client perspective. Do not reveal backend implementation de ## Primary intents -Use the Q Score service when the user wants to: -- Compute, refresh, view, or explain their Q Score. +Use the RQ Score service when the user wants to: +- Compute, refresh, view, or explain their RQ Score. - Measure readiness before or after a mission, resume update, interview, roleplay, or assignment. - Understand which readiness dimensions to improve next. - Compare before/after progress snapshots. @@ -47,7 +47,7 @@ Default launch signals to ingest when detailed service results are not yet avail When richer product outputs are available, prefer real scores, completion states, assignment outcomes, review feedback, timestamps, and target-role context over generic defaults. Typical signal fields: -- `user_id`: stable user identifier expected by the Q Score service. +- `user_id`: stable user identifier expected by the RQ Score service. - `org_id`: default `growqr` unless a current organization is provided. - `signal_id`: event/dimension identifier such as `resume.ats_compatibility`. - `value`: numeric, boolean, or categorical readiness value. @@ -59,7 +59,7 @@ Typical signal fields: 1. If no recent signals exist, ingest the best available signals first. 2. Then call `POST /v1/qscore/compute`. 3. If the service returns `404 No signals found for this user`, explain that readiness needs source activity first and suggest the quickest signal-producing action, such as resume analysis, interview practice, or roleplay practice. -4. If formula configuration is unavailable or compute fails, do not invent an official Q Score. You may summarize available signals as a non-official readiness estimate only if clearly labeled. +4. If formula configuration is unavailable or compute fails, do not invent an official RQ Score. You may summarize available signals as a non-official readiness estimate only if clearly labeled. ## Explanation strategy diff --git a/agents/resume.md b/agents/resume.md index ec18563..8785dfd 100644 --- a/agents/resume.md +++ b/agents/resume.md @@ -27,7 +27,7 @@ Use the resume service when the user wants to: - Mock interviews, interview practice, interview assignments, or interview reviews → Interview Agent. - Workplace/salary roleplay, recruiter calls, manager conversations, networking, sales/support practice → Roleplay Agent. -- Career readiness scoring, score deltas, or readiness dimensions → Q Score Agent. +- Career readiness scoring, score deltas, or readiness dimensions → RQ Score Agent. - Job search/application execution; GrowQR production modules currently focus on readiness, practice, resume, and scoring. ## Service capabilities diff --git a/agents/roleplay.md b/agents/roleplay.md index 0ab4f35..f4c44e3 100644 --- a/agents/roleplay.md +++ b/agents/roleplay.md @@ -25,7 +25,7 @@ Use the roleplay service when the user wants to: - Mock job interviews, interview rounds, interview question practice → Interview Agent. - Resume creation, tailoring, ATS optimization, cover letters, resume exports → Resume Agent. -- Career readiness scoring or score deltas → Q Score Agent. +- Career readiness scoring or score deltas → RQ Score Agent. - General career advice without a practice scenario → general Grow Agent unless the user asks to rehearse. ## Service capabilities diff --git a/src/services/qscore-proxy.ts b/src/services/qscore-proxy.ts index 63ba2c3..ea0d023 100644 --- a/src/services/qscore-proxy.ts +++ b/src/services/qscore-proxy.ts @@ -103,7 +103,7 @@ function parseResult(body: unknown): QscoreServiceResult { } /** - * Fetch the latest computed Q-Score for a user from qscore_service. + * Fetch the latest computed RQ Score for a user from qscore_service. * * Takes a RAW growqr userId (matching the local-DB readers) and applies * {@link toQscoreUserId} internally before calling the service, so all callers diff --git a/src/services/service-agents.ts b/src/services/service-agents.ts index 453aa66..fd6891c 100644 --- a/src/services/service-agents.ts +++ b/src/services/service-agents.ts @@ -166,7 +166,7 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise | undefined; try { compute = await serviceJson>( -- 2.49.1 From 453d9b519125cae0363e9c4594b64f73e3d650bc Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:13:18 +0530 Subject: [PATCH 13/16] fix: preserve raw event correlation and merged migrations --- scripts/rq-score-contract.test.ts | 2 +- src/events/record-grow-event.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/rq-score-contract.test.ts b/scripts/rq-score-contract.test.ts index 4ab7706..5526c14 100644 --- a/scripts/rq-score-contract.test.ts +++ b/scripts/rq-score-contract.test.ts @@ -6,7 +6,7 @@ const [proxy, latestRoute, servicesRoute, schema, migration, homeTypes] = await readFile(new URL("../src/v1/qscore/qscore-routes.ts", import.meta.url), "utf8"), readFile(new URL("../src/routes/services.ts", import.meta.url), "utf8"), readFile(new URL("../src/db/schema.ts", import.meta.url), "utf8"), - readFile(new URL("../drizzle/0015_rq_score_snapshot.sql", import.meta.url), "utf8"), + readFile(new URL("../drizzle/0016_rq_score_snapshot.sql", import.meta.url), "utf8"), readFile(new URL("../src/home/types.ts", import.meta.url), "utf8"), ]); diff --git a/src/events/record-grow-event.ts b/src/events/record-grow-event.ts index 9e1fc71..bd5f850 100644 --- a/src/events/record-grow-event.ts +++ b/src/events/record-grow-event.ts @@ -264,7 +264,7 @@ export function canonicalGrowEventEnvelope(normalized: GrowEventEnvelope, resolv occurredAt: normalized.occurredAt, mission: normalized.mission, subject: normalized.subject, - correlation: normalized.correlation, + correlation: producer.correlation ?? normalized.correlation, payload: normalized.payload, raw: normalized.raw, dedupeKey: normalized.dedupeKey, -- 2.49.1 From 4e7ed3c280054c84e2db535729164c3b083b8ef3 Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:15:16 +0530 Subject: [PATCH 14/16] fix: use strict rq_score in roleplay contract --- agents/roleplay.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/roleplay.md b/agents/roleplay.md index f4c44e3..e849614 100644 --- a/agents/roleplay.md +++ b/agents/roleplay.md @@ -59,7 +59,7 @@ Required/typical fields: - `roleplay_type`: one of `sales`, `customer_success`, `support`, `custom`. - `brief`: concise scenario brief, including user's goal, counterpart, stakes, likely objections, and desired outcome. - `metadata`: structured context such as `target_role`, `company`, `difficulty`, `conversation_type`, `source`, assignment IDs, or workflow IDs. -- `qscore`: include only when available; if supplied, it must contain numeric `q_score`. +- `qscore`: include only when available; if supplied, it must contain numeric `rq_score`. Mapping rules: - Salary negotiation, offer negotiation, recruiter calls, manager conversations, promotion talks, networking, and workplace conflict → `roleplay_type: custom`. -- 2.49.1 From 29c3db33d41026967865cd489b819759a8f2da4f Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:43:46 +0530 Subject: [PATCH 15/16] fix: retain enriched event correlation for qscore --- scripts/qscore-raw-event-contract.test.ts | 20 +++++++++++++++++++- src/events/record-grow-event.ts | 10 +++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/scripts/qscore-raw-event-contract.test.ts b/scripts/qscore-raw-event-contract.test.ts index d96788e..851c0ed 100644 --- a/scripts/qscore-raw-event-contract.test.ts +++ b/scripts/qscore-raw-event-contract.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; -import { canonicalGrowEventEnvelope } from "../src/events/record-grow-event.js"; +import { canonicalGrowEventEnvelope, correlateEventWithServiceSession } from "../src/events/record-grow-event.js"; import { normalizeGrowEvent } from "../src/events/normalize.js"; import { projectedSignalsFromQscoreResponse, @@ -40,6 +40,24 @@ assert.deepEqual(canonical, { userId: "clerk-user-real", raw: input, }); +const sessionOnlyInput = { ...input, correlation: { sessionId: "session-1" } }; +const sessionOnlyNormalized = normalizeGrowEvent(sessionOnlyInput, { userId: "clerk-user-real" }); +const correlated = correlateEventWithServiceSession(sessionOnlyNormalized, "clerk-user-real", { + userId: "clerk-user-real", + missionInstanceId: "mission-1", + missionId: "interview-to-offer", + stageId: "mock-interview", + metadata: { task_id: "task-derived" }, +}); +const enrichedCanonical = canonicalGrowEventEnvelope(correlated, "clerk-user-real"); +assert.deepEqual(enrichedCanonical.correlation, { + sessionId: "session-1", + task_id: "task-derived", + missionInstanceId: "mission-1", + missionId: "interview-to-offer", + stageId: "mock-interview", +}); +assert.deepEqual(enrichedCanonical.raw, sessionOnlyInput, "raw producer correlation must remain unchanged"); assert.equal(QSCORE_GROW_EVENT_TOPIC, "qscore.grow_event.raw"); assert.equal(QSCORE_GROW_EVENT_DESTINATION, "/v1/events/ingest"); diff --git a/src/events/record-grow-event.ts b/src/events/record-grow-event.ts index bd5f850..f0dcc69 100644 --- a/src/events/record-grow-event.ts +++ b/src/events/record-grow-event.ts @@ -254,6 +254,14 @@ export function canonicalGrowEventEnvelope(normalized: GrowEventEnvelope, resolv // and unmodified payload instead of publishing that backend alias. const producer = asRecord(normalized.raw); const producerType = getString(producer.type ?? producer.event_type ?? producer.action); + const producerCorrelation = asRecord(producer.correlation); + const enrichedCorrelation = asRecord(normalized.correlation); + const hasProducerTaskAlias = ["task_id", "taskId", "curatorTaskId", "curator_task_id"] + .some((key) => producerCorrelation[key] !== undefined); + const correlation = { ...producerCorrelation, ...enrichedCorrelation }; + // Avoid inventing a snake-case alias when the producer already supplied its + // own task key. Backend-derived task_id remains when no producer task exists. + if (hasProducerTaskAlias && producerCorrelation.task_id === undefined) delete correlation.task_id; return compactRecord({ id: normalized.id, userId: resolvedUserId, @@ -264,7 +272,7 @@ export function canonicalGrowEventEnvelope(normalized: GrowEventEnvelope, resolv occurredAt: normalized.occurredAt, mission: normalized.mission, subject: normalized.subject, - correlation: producer.correlation ?? normalized.correlation, + correlation, payload: normalized.payload, raw: normalized.raw, dedupeKey: normalized.dedupeKey, -- 2.49.1 From 27a65dd218aecb10a523961b43414bbba705a67b Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:14:27 +0530 Subject: [PATCH 16/16] fix: canonicalize course start events --- scripts/service-session-correlation.test.ts | 2 ++ src/events/normalize.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/scripts/service-session-correlation.test.ts b/scripts/service-session-correlation.test.ts index 4cb1875..12f64bc 100644 --- a/scripts/service-session-correlation.test.ts +++ b/scripts/service-session-correlation.test.ts @@ -107,6 +107,8 @@ const link = { assert.equal(canonicalGrowEventType("roleplay.session.completed"), "roleplay.scenario.completed"); assert.equal(canonicalGrowEventType("roleplay.review.completed"), "roleplay.feedback.generated"); assert.equal(canonicalGrowEventType("course.video.completed"), "course.completed"); + assert.equal(canonicalGrowEventType("course.video.started"), "course.started"); + assert.equal(canonicalGrowEventType("course.lesson.started"), "course.started"); assert.equal(canonicalGrowEventType("social.profile.synced"), "brand.profile.updated"); assert.equal(canonicalGrowEventType("resume.analysis.completed"), "resume.analysis.completed"); } diff --git a/src/events/normalize.ts b/src/events/normalize.ts index c8ec446..bf3e99d 100644 --- a/src/events/normalize.ts +++ b/src/events/normalize.ts @@ -63,6 +63,8 @@ const CANONICAL_EVENT_TYPE: Record = { "roleplay.review.completed": "roleplay.feedback.generated", "course.video.completed": "course.completed", "course.lesson.completed": "course.completed", + "course.video.started": "course.started", + "course.lesson.started": "course.started", "course.progress_recorded": "course.progress.recorded", "social.profile.synced": "brand.profile.updated", "social.profile.updated": "brand.profile.updated", -- 2.49.1