Merge PR #12: PRM-80 canonical service events
This commit is contained in:
121
docs/prm-80-pr12-final-audit.md
Normal file
121
docs/prm-80-pr12-final-audit.md
Normal 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.
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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("_", ".")}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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."],
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user