Merge PR #12: PRM-80 canonical service events

This commit is contained in:
Sai-karthik
2026-06-25 12:55:07 +00:00
13 changed files with 496 additions and 47 deletions

View File

@@ -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.

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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 [];

View File

@@ -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";

View File

@@ -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("_", ".")}`;

View File

@@ -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<string, unk
}
function curatorTaskIdFromRequest(req: Request, body?: JsonObject) {
const fromBody = body ? getString((body as Record<string, unknown>).curatorTaskId) : undefined;
const params = body && isRecord(body.params) ? body.params : undefined;
const fromBody = body
? getString((body as Record<string, unknown>).curatorTaskId ?? (body as Record<string, unknown>).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<string, unknown>, correlation?: Record<string, unknown>) {
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<string, unknown>;
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<string, unknown>;
correlation?: Record<string, unknown>;
subject?: Record<string, unknown>;
}) {
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<string, unknown>;
correlation?: Record<string, unknown>;
subject?: Record<string, unknown>;
}) {
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<string, unknown>;
correlation?: Record<string, unknown>;
mission?: Record<string, unknown>;
subject?: Record<string, unknown>;
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>, 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<string, unknown> : {};
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<JsonObject>();
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<string, unknown>;
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<string, unknown>;
await recordGatewayEvent({
userId,
@@ -621,12 +813,12 @@ export function serviceRoutes() {
const body = await c.req.json<JsonObject>();
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<string, unknown>;
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<string, unknown>;
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<JsonObject>();
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<string, unknown>;
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<JsonObject>().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;
}

View File

@@ -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))

View File

@@ -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<T = JsonObject>(
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;
}

View File

@@ -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."],

View File

@@ -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) => {

View File

@@ -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<Awaited<ReturnType<typeof buildCuratorUserContext>>>;
};
const QSCORE_TASK_SERVICE_REPLACEMENTS: Record<CuratorTaskType, CuratorServiceId> = {
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"),
};
}

View File

@@ -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({