diff --git a/docs/prm-80-pr12-final-audit.md b/docs/prm-80-pr12-final-audit.md new file mode 100644 index 0000000..823f0a3 --- /dev/null +++ b/docs/prm-80-pr12-final-audit.md @@ -0,0 +1,121 @@ +# PRM-80 PR #12 Final Audit + +Date: 2026-06-25 + +PR: https://git.openputer.com/growqr-app/growqr-backend/pulls/12 + +Branch: `prm-80-canonical-events` -> `staging` + +Latest verified code commit before this audit doc: `e13dfe7d468209685596385edc749e5506f9f8a2` + +## Commits In PR + +- `b895d6b` - `fix: emit canonical service events for PRM-80` +- `e13dfe7` - `fix: keep qscore out of curator tasks` + +## Scope + +This PR now covers the PRM-80 canonical Grow Events contract work plus the curator task policy update requested after QA: + +- Workflow/service events are normalized into canonical PRM-80 event names before ingestion. +- Workflow bridge events carry `subject` and service identity details instead of storing `subject: null`. +- Resume, roleplay, QScore analytics/read, and matchmaking workflow paths emit or bridge their canonical service events into `grow_events`. +- Duplicate canonical service events are kept idempotent through deterministic dedupe behavior. +- Curator no longer assigns `qscore-service` as a task or handoff service. + +## Curator QScore Policy Change + +QScore remains available as a scoring/readiness projection service for dashboard and backend consumers. It is no longer offered as a curator task. + +Files changed: + +- `src/v1/curator/curator-store.ts` +- `src/v1/curator/curator-tools.ts` + +Behavior added: + +- Curator seed generation remaps `qscore-service` tasks to non-QScore services. +- Measurement tasks that previously pointed at QScore now point at `assessment-service`. +- Proof tasks that previously pointed at QScore now point at `resume-service`. +- Practice tasks that previously pointed at QScore now point at `interview-service`. +- Recovery tasks that previously pointed at QScore now point at `roleplay-service`. +- Curator capability listing filters out `qscore-service`. +- `prepare_qscore_review` is disabled for curator task handoffs and returns `qscore_curator_handoff_disabled`. + +## Curator Services Allowed After Change + +- `interview-service` +- `roleplay-service` +- `resume-service` +- `cover-letter-service` +- `courses-service` +- `assessment-service` +- `matchmaking-service` +- `social-branding-service` + +Excluded from curator task assignment: + +- `qscore-service` + +## Runtime Verification + +Backend container: + +- `growqr-backend` rebuilt successfully with Docker. +- `growqr-backend` restarted and is healthy. +- Root backend health response returned `200 application/json` with `{"name":"growqr-backend","status":"ok","env":"production"}`. + +Curator today API verification: + +```text +task_count 3 +qscore_task_count 0 + +measurement | assessment-service | Assessment | Check whether confidence is improving | Open assessment +proof | resume-service | Resume | Generate a cleaner role-fit artifact | Open resume workspace +practice | interview-service | Interview | Run one focused interview rep | Open interview preview +``` + +Curator 30-day sprint verification: + +```text +planned_service_count 90 +planned_qscore_count 0 +task_qscore_count 0 + +assessment-service: 30 +interview-service: 11 +matchmaking-service: 11 +resume-service: 21 +roleplay-service: 8 +social-branding-service: 9 +``` + +Build verification: + +```text +docker compose build backend +Image growqr-backend-backend Built +``` + +Remote branch verification: + +```text +refs/heads/prm-80-canonical-events -> e13dfe7d468209685596385edc749e5506f9f8a2 +``` + +PR page verification: + +```text +https://git.openputer.com/growqr-app/growqr-backend/pulls/12 +HTTP/2 200 +PR title: #12 - PRM-80: Emit canonical service events for Grow Events +Latest commit visible: e13dfe7 fix: keep qscore out of curator tasks +``` + +## Audit Notes + +- Only the intended tracked files were committed for the curator update. +- VPS has untracked `.bak.*` backup files from prior QA/debug work; these were intentionally not staged or committed. +- QScore was not removed from the service registry because dashboard scoring, backend QScore reads, and projection consumers still depend on it. +- The change is limited to curator task assignment/handoff behavior. diff --git a/src/events/envelope.ts b/src/events/envelope.ts index 7ddaf81..30f9fee 100644 --- a/src/events/envelope.ts +++ b/src/events/envelope.ts @@ -8,8 +8,10 @@ export type GrowEventCategory = | "system"; export type GrowEventSubject = { - kind: string; - id: string; + kind?: string; + id?: string; + serviceId?: string; + externalId?: string; }; export type GrowEventMissionRef = { diff --git a/src/events/normalize.ts b/src/events/normalize.ts index 6cd0b36..56aee81 100644 --- a/src/events/normalize.ts +++ b/src/events/normalize.ts @@ -12,6 +12,9 @@ function normalizeSubject(value: unknown) { const record = asRecord(value); const kind = getString(record.kind); const id = getString(record.id); + const serviceId = getString(record.serviceId ?? record.service_id); + const externalId = getString(record.externalId ?? record.external_id); + if (serviceId || externalId) return { serviceId, externalId }; return kind && id ? { kind, id } : undefined; } @@ -48,6 +51,9 @@ export function normalizeGrowEvent(input: unknown, overrides: { userId?: string; request_id: raw.request_id ?? payload.request_id, }); const subject = normalizeSubject(raw.subject) ?? (() => { + const serviceId = getString(raw.subject_service_id ?? payload.subject_service_id); + const externalId = getString(raw.subject_external_id ?? payload.subject_external_id); + if (serviceId || externalId) return { serviceId, externalId }; const kind = getString(raw.subject_kind ?? payload.subject_kind); const id = getString(raw.subject_id ?? payload.subject_id); return kind && id ? { kind, id } : undefined; diff --git a/src/events/projectors/qscore-projector.ts b/src/events/projectors/qscore-projector.ts index a5636bc..27629c9 100644 --- a/src/events/projectors/qscore-projector.ts +++ b/src/events/projectors/qscore-projector.ts @@ -129,6 +129,7 @@ function extractScoredServiceSignals(event: GrowEventRow): QscoreSignal[] { event.type.includes("completed") || event.type.includes("updated") || event.type.includes("signal_projected") || + event.type.includes("signal.projected") || status === "completed"; if (!isCompletion) return []; diff --git a/src/events/projectors/service-session-projector.ts b/src/events/projectors/service-session-projector.ts index 5f7556d..d2b59e7 100644 --- a/src/events/projectors/service-session-projector.ts +++ b/src/events/projectors/service-session-projector.ts @@ -22,7 +22,7 @@ function statusFor(event: GrowEventRow): string { const payload = event.payload ?? {}; const explicit = getString(payload.status); if (explicit) return explicit; - if (event.type.includes("review_completed") || event.type.includes("completed")) return "completed"; + if (event.type.includes("review_completed") || event.type.includes("feedback.generated") || event.type.includes("completed")) return "completed"; if (event.type.includes("failed")) return "failed"; if (event.type.includes("configured") || event.type.includes("created")) return "active"; return "active"; diff --git a/src/events/redis-consumer.ts b/src/events/redis-consumer.ts index 5b9d50e..c75bb69 100644 --- a/src/events/redis-consumer.ts +++ b/src/events/redis-consumer.ts @@ -101,26 +101,26 @@ function actionToEventType(serviceId: ServiceRedisSpec["serviceId"], action: str const effective = msgAction || action || "event"; if (serviceId === "interview") { - if (effective === "interview_configured" || action === "configure_interview") return "interview.configured"; + if (effective === "interview_configured" || action === "configure_interview") return "interview.session.configured"; if (effective === "review_loaded") { const data = asRecord(message.data); - return data.status === "completed" ? "interview.review_completed" : "interview.review_processing"; + return data.status === "completed" ? "interview.feedback.generated" : "interview.feedback.processing"; } if (effective === "interview_page_loaded") return "interview.page_state_loaded"; return `interview.${effective.replaceAll("_", ".")}`; } if (serviceId === "roleplay") { - if (effective === "roleplay_configured" || action === "configure_roleplay") return "roleplay.configured"; + if (effective === "roleplay_configured" || action === "configure_roleplay") return "roleplay.scenario.configured"; if (effective === "roleplay_review_loaded" || effective === "review_loaded") { const data = asRecord(message.data); - return data.status === "completed" ? "roleplay.review_completed" : "roleplay.review_processing"; + return data.status === "completed" ? "roleplay.feedback.generated" : "roleplay.feedback.processing"; } if (effective === "roleplay_page_loaded") return "roleplay.page_state_loaded"; return `roleplay.${effective.replaceAll("_", ".")}`; } - if (effective === "ai_analysis_complete" || action === "ai_analyze") return "resume.analysis_completed"; + if (effective === "ai_analysis_complete" || action === "ai_analyze") return "resume.analysis.completed"; if (effective === "resume_loaded") return "resume.loaded"; if (effective === "resume_parsed") return "resume.parsed"; return `resume.${effective.replaceAll("_", ".")}`; diff --git a/src/routes/services.ts b/src/routes/services.ts index 7b11040..110f281 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -1,9 +1,11 @@ import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { createHash } from "node:crypto"; import { desc, eq } from "drizzle-orm"; import { requireUser, type AuthContext } from "../auth/clerk.js"; import { config } from "../config.js"; import { listServiceCapabilities } from "../workflows/service-capabilities.js"; -import { interviewService, resumeService, roleplayService, type JsonObject } from "../services/product-service-clients.js"; +import { interviewService, ProductServiceError, resumeService, roleplayService, type JsonObject } from "../services/product-service-clients.js"; import { db } from "../db/client.js"; import { events, growQscoreLatest, growQscoreProjectionState } from "../db/schema.js"; import { recordGrowEvent } from "../events/record-grow-event.js"; @@ -64,7 +66,10 @@ function missionFromRequest(req: Request, body?: JsonObject): Record).curatorTaskId) : undefined; + const params = body && isRecord(body.params) ? body.params : undefined; + const fromBody = body + ? getString((body as Record).curatorTaskId ?? (body as Record).taskId ?? params?.curatorTaskId ?? params?.taskId) + : undefined; if (fromBody) return fromBody; const url = new URL(req.url); return getString(url.searchParams.get("curatorTaskId")); @@ -76,6 +81,90 @@ function stripMissionFromBody(body: JsonObject): JsonObject { return rest; } +function canonicalSubjectServiceId(source: string) { + if (source.includes("interview")) return "interview"; + if (source.includes("roleplay")) return "roleplay"; + if (source.includes("resume")) return "resume"; + if (source.includes("qscore")) return "qscore"; + if (source.includes("matchmaking")) return "matchmaking"; + if (source.includes("analytics")) return "analytics"; + return source; +} + +function externalIdFromPayload(payload: Record, correlation?: Record) { + const result = isRecord(payload.result) ? payload.result : {}; + return getString( + correlation?.externalId ?? + correlation?.sessionId ?? + correlation?.resumeId ?? + result.task_id ?? + result.taskId ?? + result.session_id ?? + result.sessionId ?? + result.scenario_id ?? + result.scenarioId ?? + result.id, + ); +} + +function dedupeSegment(value: unknown) { + return getString(value)?.replace(/[^A-Za-z0-9_.:-]+/g, "-"); +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`; + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`; +} + +function stableDedupeFingerprint(input: { + userId: string; + source: string; + type: string; + payload: Record; + correlation?: Record; + subject?: Record; +}) { + return createHash("sha256") + .update(stableStringify({ + userId: input.userId, + source: input.source, + type: input.type, + subject: input.subject, + correlation: input.correlation, + payload: input.payload, + })) + .digest("hex") + .slice(0, 24); +} + +function gatewayDedupeKey(input: { + userId: string; + source: string; + type: string; + payload: Record; + correlation?: Record; + subject?: Record; +}) { + const result = isRecord(input.payload.result) ? input.payload.result : {}; + const request = isRecord(input.payload.request) ? input.payload.request : {}; + const subject = input.subject ?? {}; + const externalId = externalIdFromPayload(input.payload, input.correlation); + const anchor = + dedupeSegment(request.request_id ?? request.requestId) ?? + dedupeSegment(input.correlation?.requestId) ?? + dedupeSegment(input.correlation?.taskId) ?? + dedupeSegment(input.correlation?.curatorTaskId) ?? + dedupeSegment(input.correlation?.sessionId) ?? + dedupeSegment(input.correlation?.resumeId) ?? + dedupeSegment(input.correlation?.externalId) ?? + dedupeSegment(subject.externalId) ?? + dedupeSegment(externalId) ?? + dedupeSegment(result.task_id ?? result.taskId ?? result.session_id ?? result.sessionId ?? result.id); + return `${input.source}:${input.type}:${input.userId}:${anchor ?? stableDedupeFingerprint(input)}`; +} + async function recordGatewayEvent(input: { userId: string; source: string; @@ -83,6 +172,8 @@ async function recordGatewayEvent(input: { payload: Record; correlation?: Record; mission?: Record; + subject?: Record; + dedupeKey?: string; }) { await db.insert(events).values({ userId: input.userId, @@ -97,7 +188,12 @@ async function recordGatewayEvent(input: { category: "service", userId: input.userId, occurredAt: new Date().toISOString(), + dedupeKey: input.dedupeKey ?? gatewayDedupeKey(input), mission: input.mission, + subject: input.subject ?? { + serviceId: canonicalSubjectServiceId(input.source), + externalId: externalIdFromPayload(input.payload, input.correlation) ?? `${canonicalSubjectServiceId(input.source)}:${input.type}`, + }, correlation: input.correlation, payload: input.payload, }); @@ -107,21 +203,107 @@ async function recordGatewayEvent(input: { function eventTypeForReview(prefix: "interview" | "roleplay", result: Record) { const status = getString(result.status); - if (status === "completed") return `${prefix}.review_completed`; - if (status === "failed") return `${prefix}.review_failed`; - return `${prefix}.review_processing`; + if (status === "completed") return prefix === "interview" ? "interview.feedback.generated" : "roleplay.feedback.generated"; + if (status === "failed") return prefix === "interview" ? "interview.feedback.failed" : "roleplay.feedback.failed"; + return prefix === "interview" ? "interview.feedback.processing" : "roleplay.feedback.processing"; } function resumeEventTypeForRest(method: string, rest: string, ok: boolean) { if (!ok) return "resume.request_failed"; if (method === "POST" && /^resumes\/upload/.test(rest)) return "resume.uploaded"; if (method === "POST" && /^parse\/resume\/[^/]+\/parse/.test(rest)) return "resume.parsed"; - if (method === "POST" && /^ai\/analyze\//.test(rest)) return "resume.analysis_completed"; + if (method === "POST" && /^ai\/analyze\//.test(rest)) return "resume.analysis.completed"; if ((method === "POST" || method === "PUT" || method === "PATCH") && /versions?/.test(rest)) return "resume.version_created"; if (method !== "GET") return "resume.updated"; return "resume.loaded"; } +function agentDataAction(result: Record) { + const messages = Array.isArray(result.messages) ? result.messages : []; + for (const item of messages) { + const message = isRecord(item) ? item : {}; + const action = getString(message.action); + if (message.type === "agent_data" && action) return action; + } + return undefined; +} + +function resultHasAgentError(result: Record) { + if (getString(result.status) === "error") return true; + const messages = Array.isArray(result.messages) ? result.messages : []; + return messages.some((item) => isRecord(item) && item.type === "agent_error"); +} + +function resumeEventTypeForA2a(action: string | undefined, result: Record) { + if (resultHasAgentError(result)) return "resume.request_failed"; + const effective = agentDataAction(result) ?? action ?? ""; + if (["ai_analyze", "analyze_resume", "bg_parse_analyze", "ai_analysis_complete"].includes(effective)) return "resume.analysis.completed"; + if (["parse_resume", "resume_parsed"].includes(effective)) return "resume.parsed"; + if (["export_pdf", "export_analysis_pdf", "resume_exported"].includes(effective)) return "resume.exported"; + if (["create_resume", "resume_created", "save_version", "update_resume_meta"].includes(effective)) return "resume.updated"; + return "resume.updated"; +} + +function resumeIdFromA2a(body: JsonObject, result: Record) { + const params = isRecord(body.params) ? body.params : {}; + const messages = Array.isArray(result.messages) ? result.messages : []; + for (const item of messages) { + const message = isRecord(item) ? item : {}; + const data = isRecord(message.data) ? message.data : {}; + const resume = isRecord(data.resume) ? data.resume : {}; + const id = getString(data.resume_id ?? data.resumeId ?? resume.id); + if (id) return id; + } + return getString(params.resume_id ?? params.resumeId ?? result.task_id ?? result.taskId); +} + +function serviceErrorResponse(err: unknown): never { + if (err instanceof ProductServiceError) { + let detail: unknown = err.body; + try { + detail = err.body ? JSON.parse(err.body) : {}; + } catch { + detail = { detail: err.body }; + } + throw new HTTPException(err.status as never, { message: JSON.stringify(detail) }); + } + throw err; +} + +function matchmakingEventType(action: string, response: Record) { + if (response.status === "error") return "matchmaking.request.failed"; + if (action === "run_search" || action === "generate_matches") return "matchmaking.matches.generated"; + if (action === "get_scout_feed" || action === "get_feed" || action === "session_start") return "matchmaking.feed.viewed"; + if (action === "get_opportunity_detail" || action === "mark_viewed") return "matchmaking.match.viewed"; + if (action === "mark_saved" || action === "record_feedback") return "matchmaking.match.saved"; + if (action === "dismiss_opportunity") return "matchmaking.match.dismissed"; + if (action === "tailor_resume") return "matchmaking.application.started"; + if (action === "submit_application") return "matchmaking.application.completed"; + return "matchmaking.workflow.completed"; +} + +async function callMatchmakingA2a(body: Record, userId: string) { + const target = new URL("/a2a/tasks", config.matchmakingServiceUrl.replace(/\/$/, "")); + const action = getString(body.action) ?? (body.session_start ? "session_start" : ""); + const payload = { + ...body, + action: action === "get_feed" ? "get_scout_feed" : action, + user_id: getString(body.user_id) ?? userId, + }; + const res = await fetch(target, { + method: "POST", + headers: { + "content-type": "application/json", + ...(config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : {}), + }, + body: JSON.stringify(payload), + }); + const text = await res.text(); + const result = text ? JSON.parse(text) as Record : {}; + if (!res.ok) throw new HTTPException(res.status as never, { message: text || "matchmaking request failed" }); + return { action: String(payload.action || action), result }; +} + function parseJsonBody(body: ArrayBuffer | undefined, headers: Headers): JsonObject { if (!body || !headers.get("content-type")?.includes("application/json")) return {}; try { @@ -517,7 +699,7 @@ export function serviceRoutes() { ? Math.round(signals.reduce((sum, signal) => sum + signal.score, 0) / signals.length) : null; - return c.json({ + const response = { qscore: score === null ? null : { score, signalCount: projection?.signalCount ?? signals.length, @@ -533,7 +715,17 @@ export function serviceRoutes() { occurredAt: signal.occurredAt.toISOString(), updatedAt: signal.updatedAt.toISOString(), })), - }); + }; + + await recordGatewayEvent({ + userId, + source: "qscore-service", + type: "qscore.review.opened", + payload: { score, signalCount: signals.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); }); app.get("/interview/page-state", async (c) => { @@ -556,12 +748,12 @@ export function serviceRoutes() { const body = await c.req.json(); const mission = missionFromRequest(c.req.raw, body); const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, userId); - const result = await interviewService.configure(payload); + const result = await interviewService.configure(payload).catch(serviceErrorResponse); const resultObj = result as Record; await recordGatewayEvent({ userId, source: "interview-service", - type: "interview.configured", + type: "interview.session.configured", payload: { request: payload, result: resultObj }, correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), taskId: curatorTaskIdFromRequest(c.req.raw, body) }, mission, @@ -585,7 +777,7 @@ export function serviceRoutes() { app.get("/interview/review/:sessionId", async (c) => { const userId = c.get("userId"); const sessionId = c.req.param("sessionId"); - const result = await interviewService.review(sessionId, userId); + const result = await interviewService.review(sessionId, userId).catch(serviceErrorResponse); const resultObj = result as Record; await recordGatewayEvent({ userId, @@ -621,12 +813,12 @@ export function serviceRoutes() { const body = await c.req.json(); const mission = missionFromRequest(c.req.raw, body); const payload = await buildPersonalizedRoleplayConfigurePayload(c.req.raw, body, userId); - const result = await roleplayService.configure(payload); + const result = await roleplayService.configure(payload).catch(serviceErrorResponse); const resultObj = result as Record; await recordGatewayEvent({ userId, source: "roleplay-service", - type: "roleplay.configured", + type: "roleplay.scenario.configured", payload: { request: payload, result: resultObj }, correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), taskId: curatorTaskIdFromRequest(c.req.raw, body) }, mission, @@ -650,7 +842,7 @@ export function serviceRoutes() { app.get("/roleplay/review/:sessionId", async (c) => { const userId = c.get("userId"); const sessionId = c.req.param("sessionId"); - const result = await roleplayService.review(sessionId, userId); + const result = await roleplayService.review(sessionId, userId).catch(serviceErrorResponse); const resultObj = result as Record; await recordGatewayEvent({ userId, @@ -668,8 +860,23 @@ export function serviceRoutes() { app.get("/resume/state/:clerkId", async (c) => c.json(await resumeService.state(c.req.param("clerkId")))); app.post("/resume/tasks", async (c) => { + const userId = c.get("userId"); const body = await c.req.json(); - return c.json(await resumeService.task({ ...body, user_id: String(body.user_id ?? c.get("userId")) })); + const result = await resumeService.task({ ...body, user_id: String(body.user_id ?? userId) }); + const resultObj = result as Record; + const resumeId = resumeIdFromA2a(body, resultObj); + await recordGatewayEvent({ + userId, + source: "resume-builder", + type: resumeEventTypeForA2a(getString(body.action), resultObj), + payload: { request: body, result: resultObj }, + correlation: { + taskId: curatorTaskIdFromRequest(c.req.raw, body), + resumeId, + externalId: resumeId, + }, + }).catch((err) => log.warn({ err, userId, action: body.action }, "failed to record resume A2A workflow event")); + return c.json(result); }); // Frontend Resume Builder routes should preserve the user's Clerk bearer token @@ -689,5 +896,22 @@ export function serviceRoutes() { return proxySocialRequest(c.req.raw, rest, c.get("userId")); }); + app.post("/matchmaking/a2a", async (c) => { + const userId = c.get("userId"); + const body = await c.req.json().catch(() => ({})); + const { action, result } = await callMatchmakingA2a(body, userId); + await recordGatewayEvent({ + userId, + source: "matchmaking-v2", + type: matchmakingEventType(action, result), + payload: { request: body, result }, + correlation: { + taskId: curatorTaskIdFromRequest(c.req.raw, body), + externalId: getString(result.task_id ?? result.taskId), + }, + }).catch((err) => log.warn({ err, userId, action }, "failed to record matchmaking workflow event")); + return c.json(result); + }); + return app; } diff --git a/src/routes/users.ts b/src/routes/users.ts index 335b6cc..4e4985b 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -11,6 +11,13 @@ import { runCuratorOnboardingLoopSafely, } from "../v1/curator/curator-onboarding-loop.js"; +const ONBOARDING_LEDGER_EVENT_TYPES = [ + "onboarding.snapshot.saved", + "onboarding.completed", + "user.onboarding.completed", + "profile.onboarding.completed", +] as const; + function publicStack(stack: UserStack | null | undefined) { if (!stack) return stack; const { opencodePassword: _opencodePassword, ...safe } = stack; @@ -67,7 +74,7 @@ async function getOnboardingEventStatus(userId: string) { .where( and( eq(growEvents.userId, userId), - inArray(growEvents.type, ["onboarding.snapshot.saved", "onboarding.completed"]), + inArray(growEvents.type, [...ONBOARDING_LEDGER_EVENT_TYPES]), ), ) .orderBy(desc(growEvents.occurredAt)) diff --git a/src/services/product-service-clients.ts b/src/services/product-service-clients.ts index bf6113f..3bbcdc8 100644 --- a/src/services/product-service-clients.ts +++ b/src/services/product-service-clients.ts @@ -9,6 +9,18 @@ export type ServiceCallOptions = { timeoutMs?: number; }; +export class ProductServiceError extends Error { + constructor( + message: string, + readonly status: number, + readonly body: string, + readonly path: string, + ) { + super(message); + this.name = "ProductServiceError"; + } +} + const DEFAULT_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_SERVICE_TIMEOUT_MS ?? 3500); const INTERACTIVE_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS ?? 120000); @@ -38,7 +50,7 @@ async function serviceJson( signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_SERVICE_TIMEOUT_MS), }); const text = await res.text(); - if (!res.ok) throw new Error(`${path} returned HTTP ${res.status}: ${text}`); + if (!res.ok) throw new ProductServiceError(`${path} returned HTTP ${res.status}: ${text}`, res.status, text, path); return (text ? JSON.parse(text) : {}) as T; } diff --git a/src/services/service-registry.ts b/src/services/service-registry.ts index 2a67196..c0a04fb 100644 --- a/src/services/service-registry.ts +++ b/src/services/service-registry.ts @@ -196,7 +196,7 @@ const serviceRegistry: ServiceRecord[] = [ media: "video", }, toolName: "prepare_interview_preview", - completionEvents: ["interview.configured", "interview.review_completed", "interview.completed"], + completionEvents: ["interview.session.configured", "interview.feedback.generated", "interview.completed"], qscoreSignals: ["communication.interview", "proof.story_bank", "readiness.practice"], usage: "Include missionInstanceId, missionId, stageId, curatorTaskId, role, and media when building stateful handoffs.", }, @@ -281,7 +281,7 @@ const serviceRegistry: ServiceRecord[] = [ mode: "video", }, toolName: "prepare_roleplay_preview", - completionEvents: ["roleplay.configured", "roleplay.review_completed", "roleplay.completed"], + completionEvents: ["roleplay.scenario.configured", "roleplay.feedback.generated", "roleplay.completed"], qscoreSignals: ["communication.roleplay", "networking.conversation", "readiness.practice"], usage: "Include role, brief, mission state, and curatorTaskId when building stateful handoffs.", }, @@ -356,7 +356,7 @@ const serviceRegistry: ServiceRecord[] = [ tab: "resumes", }, toolName: "prepare_resume_upload", - completionEvents: ["resume.analysis_completed", "resume.parsed", "resume.updated"], + completionEvents: ["resume.analysis.completed", "resume.parsed", "resume.updated"], qscoreSignals: ["proof.resume", "readiness.ats", "profile.skills"], usage: "Include mission state and optional section when linking into resume work.", }, @@ -625,8 +625,8 @@ const serviceRegistry: ServiceRecord[] = [ review: "Review QScore", }, toolName: "prepare_qscore_review", - completionEvents: ["qscore.updated", "qscore.signal_projected"], - qscoreSignals: ["qscore.updated", "qscore.signal_projected"], + completionEvents: ["qscore.updated", "qscore.signal.projected"], + qscoreSignals: ["qscore.updated", "qscore.signal.projected"], usage: "Use for measurement and projected readiness review tasks.", }, usageDocs: ["Call buildServiceLink('qscore-service', 'dashboard', state) for QScore handoffs."], diff --git a/src/v1/analytics/analytics-routes.ts b/src/v1/analytics/analytics-routes.ts index df4f22e..e222e11 100644 --- a/src/v1/analytics/analytics-routes.ts +++ b/src/v1/analytics/analytics-routes.ts @@ -4,6 +4,8 @@ import { and, desc, eq, gte, sql } from "drizzle-orm"; import { requireUser, type AuthContext } from "../../auth/clerk.js"; import { db } from "../../db/client.js"; import { growEvents, growQscoreLatest, growQscoreProjectionState } from "../../db/schema.js"; +import { recordGrowEvent } from "../../events/record-grow-event.js"; +import { routeGrowEventToUserActor } from "../../events/route-to-user-actor.js"; import { v1AnalyticsActor } from "./analytics-actor.js"; function daysAgo(days: number) { @@ -73,7 +75,7 @@ export function v1AnalyticsRoutes() { const strongestSignal = [...latestSignals].sort((a, b) => b.score - a.score)[0]; const weakestSignal = [...latestSignals].sort((a, b) => a.score - b.score)[0]; - return c.json({ + const response = { roleFit: { score, label: score === null ? "baseline_needed" : score >= 75 ? "strong" : score >= 55 ? "building" : "needs_focus", @@ -96,7 +98,29 @@ export function v1AnalyticsRoutes() { latestEventAt: recentEvents.find((event) => sourceBucket(event.source) === "opportunities")?.occurredAt.toISOString() ?? null, }, source: "grow_events", + }; + + const event = await recordGrowEvent({ + source: "growqr-backend:analytics", + type: "analytics.insight_snapshot.opened", + category: "usage", + userId, + occurredAt: new Date().toISOString(), + dedupeKey: `analytics:insight-snapshot:${userId}:${new Date().toISOString().slice(0, 10)}`, + subject: { + serviceId: "analytics", + externalId: "insight-snapshot", + }, + payload: { + score, + signalCount: projection?.signalCount ?? latestSignals.length, + totalEvents14d: counts?.total ?? 0, + source: "v1.analytics.insight-snapshot", + }, }); + await routeGrowEventToUserActor(event).catch(() => undefined); + + return c.json(response); }); app.get("/activity-history", async (c) => { diff --git a/src/v1/curator/curator-store.ts b/src/v1/curator/curator-store.ts index b94646c..4c0ea62 100644 --- a/src/v1/curator/curator-store.ts +++ b/src/v1/curator/curator-store.ts @@ -22,17 +22,17 @@ import { buildCuratorTaskDeepLink, completionEventsForService, serviceName, serv const VALID_COMPLETION_TYPES = [ "service.completed", - "resume.analysis_completed", + "resume.analysis.completed", "resume.parsed", "resume.updated", - "interview.configured", - "interview.review_completed", + "interview.session.configured", + "interview.feedback.generated", "interview.completed", - "roleplay.configured", - "roleplay.review_completed", + "roleplay.scenario.configured", + "roleplay.feedback.generated", "roleplay.completed", "qscore.updated", - "qscore.signal_projected", + "qscore.signal.projected", "curator.task.completed", ] as const; @@ -102,6 +102,60 @@ type ServiceCurationPreviewInput = { userContext?: Partial>>; }; +const QSCORE_TASK_SERVICE_REPLACEMENTS: Record = { + measurement: "assessment-service", + proof: "resume-service", + practice: "interview-service", + recovery: "roleplay-service", +}; + +function curatorAssignableServiceId(serviceId: CuratorServiceId, taskType: CuratorTaskType): CuratorServiceId { + return serviceId === "qscore-service" ? QSCORE_TASK_SERVICE_REPLACEMENTS[taskType] : serviceId; +} + +function qscoreFreeTaskCopy(input: { + serviceId: CuratorServiceId; + taskType: CuratorTaskType; + title: string; + subtitle: string; + cta: string; + signals: string[]; +}) { + if (input.serviceId !== "qscore-service") { + return { + title: input.title, + subtitle: input.subtitle, + cta: input.cta, + signals: input.signals, + }; + } + + if (input.taskType === "proof") { + return { + title: input.title.replace(/Q Score|QScore/gi, "resume proof"), + subtitle: input.subtitle.replace(/Q Score|QScore/gi, "resume proof"), + cta: "Open resume workspace", + signals: input.signals.map((signal) => signal.replace(/q\s?score/gi, "resume proof")), + }; + } + + if (input.taskType === "practice") { + return { + title: input.title.replace(/Q Score|QScore/gi, "interview practice"), + subtitle: input.subtitle.replace(/Q Score|QScore/gi, "interview practice"), + cta: "Open interview preview", + signals: input.signals.map((signal) => signal.replace(/q\s?score/gi, "interview practice")), + }; + } + + return { + title: input.title.replace(/Q Score|QScore/gi, "readiness assessment"), + subtitle: input.subtitle.replace(/Q Score|QScore/gi, "the assessment service"), + cta: "Open assessment", + signals: input.signals.map((signal) => signal.replace(/q\s?score/gi, "assessment")), + }; +} + const FRESHER_WEEK_TEMPLATES: WeekTemplate[] = [ { theme: "Baseline + First Proof", @@ -811,7 +865,9 @@ function seed( cta: string, signals: string[], ): TaskSeed { - return { taskType, serviceId, title, subtitle, effort, qxImpact, cta, signals }; + const assignedServiceId = curatorAssignableServiceId(serviceId, taskType); + const copy = qscoreFreeTaskCopy({ serviceId, taskType, title, subtitle, cta, signals }); + return { taskType, serviceId: assignedServiceId, title: copy.title, subtitle: copy.subtitle, effort, qxImpact, cta: copy.cta, signals: copy.signals }; } function todayIso(date = new Date()) { @@ -1792,7 +1848,7 @@ export async function listCuratorRegistryCapabilities() { title: mission.title, modules: mission.modules.map((module) => ({ id: module.id, title: module.title, service: module.service, role: module.role })), })), - services: listServiceCapabilities(), + services: listServiceCapabilities().filter((service) => service.id !== "qscore-service"), }; } diff --git a/src/v1/curator/curator-tools.ts b/src/v1/curator/curator-tools.ts index 8e26853..0087816 100644 --- a/src/v1/curator/curator-tools.ts +++ b/src/v1/curator/curator-tools.ts @@ -72,9 +72,9 @@ async function latestInterviewResumeEvidence(userId: string) { .where(and( eq(growEvents.userId, userId), inArray(growEvents.type as any, [ - "interview.review_completed", + "interview.feedback.generated", "interview.completed", - "roleplay.review_completed", + "roleplay.feedback.generated", "roleplay.completed", ]), )) @@ -497,13 +497,9 @@ export function buildCuratorTools(ctx: { userId: string; date: string; conversat }), prepare_qscore_review: tool({ - description: "Prepare a Q-score review handoff.", + description: "Disabled for curator task handoffs; QScore is read-only for dashboard scoring and should not be assigned as a curator task.", inputSchema: z.object({ taskId: z.string().optional() }), - execute: async ({ taskId }) => { - const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date); - if (!task) return { error: "task_not_found" }; - return prepareHandoffForTask(ctx.userId, task, "qscore-service"); - }, + execute: async () => ({ error: "qscore_curator_handoff_disabled", replacementServices: ["assessment-service", "interview-service", "roleplay-service", "resume-service", "matchmaking-service", "courses-service", "social-branding-service"] }), }), emit_curator_event: tool({