diff --git a/package.json b/package.json index 0a31274..49f2f33 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test:curator-reconcile": "tsx scripts/curator-persisted-reconcile.test.ts", "test:qscore-raw-events": "tsx scripts/qscore-raw-event-contract.test.ts", "test:rq-score-contract": "tsx scripts/rq-score-contract.test.ts", + "test:home-insight-snapshot": "tsx scripts/home-insight-snapshot.test.ts", "test:onboarding-read": "tsx scripts/onboarding-rev10-read.test.ts", "test:interview-user-identity": "tsx scripts/interview-user-identity.test.ts", "test:ws-ticket": "tsx scripts/ws-ticket.test.ts", diff --git a/scripts/home-insight-snapshot.test.ts b/scripts/home-insight-snapshot.test.ts new file mode 100644 index 0000000..61bccc7 --- /dev/null +++ b/scripts/home-insight-snapshot.test.ts @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; + +import { buildHomeInsightSnapshots } from "../src/v1/analytics/home-insight-snapshot.js"; +import type { QscoreProxyResult, QscoreReadResult } from "../src/services/qscore-proxy.js"; + +function qscoreFixture(): QscoreProxyResult { + const category = (id: string, score: number, active = true, totalRules = 2) => ({ + id, + label: id, + score, + active, + present_rules: active ? 1 : 0, + total_rules: totalRules, + weight_percent: 10, + signals: [], + }); + return { + org_id: "growqr", + user_id: "00000000-0000-4000-8000-000000000000", + rq_score: 0, + iq_score: null, + eq_score: null, + sq_score: null, + profession: "", + formula_version: "v2", + formula_id: "fixture", + calculated_at: "2026-07-15T00:00:00Z", + quotients: {}, + breakdown: { + display: { + version: "rq-score-display-v1", + registered_signal_count: 67, + active_signal_count: 7, + categories: [ + category("learning_credentials", 45), + category("resume_presence", 55), + category("linkedin_presence", 65), + category("matching_activity", 75), + category("interview_performance", 85), + category("pathway_progress", 0, false, 0), + category("roleplay_performance", 70), + category("engagement_consistency", 40), + category("goals_readiness", 35), + ], + }, + }, + ledger_seq_from: null, + ledger_seq_to: null, + }; +} + +function inactiveCategoryQscoreFixture(): QscoreProxyResult { + const fixture = qscoreFixture(); + const display = fixture.breakdown.display as { categories: Array> }; + for (const category of display.categories) { + if (category.id === "pathway_progress") continue; + category.score = 0; + category.active = false; + category.present_rules = 0; + } + return fixture; +} + +const readyQscore: QscoreReadResult = { status: "ready", data: qscoreFixture() }; +const history = { status: "ready" as const, data: [{ rq_score: 0, date: "2026-07-15" }] }; + +const producers = { + interviewPageState: async () => ({ recent_sessions: [{ overall_score: 0, completed_at: "2026-07-15T01:00:00Z" }] }), + roleplayPageState: async () => ({ recent_sessions: [{ overall_score: 0, completed_at: "2026-07-15T02:00:00Z" }] }), + resumeState: async () => ({ resume_count: 1, resume_completeness: 0, resume_status: "draft" }), + socialState: async () => ({ + linkedin_connected: true, + profile_analysis_score: 0, + followers_count: 0, + connections_count: 0, + skills_count: 0, + experience_count: 0, + improvements_count: 0, + section_scores: {}, + }), + matchmakingStats: async () => ({ + status: "completed", + messages: [{ action: "scout_stats", data: { matchesFound: 0, funnel: [], searches: 0, tailoredResumes: 0 } }], + }), + courseSummary: async () => ({ + status: "completed", + messages: [{ + action: "course_state_loaded", + data: { has_interest_profile: true, watch_count: 0, watch_stats: { watched: 0, total_s: 0, anchors: [] } }, + }], + }), + assessments: async () => ({ items: [], total: 0 }), +}; + +const genuineZeros = await buildHomeInsightSnapshots({ + userId: "user_fixture", + qscore: readyQscore, + qscoreHistory: history, + recentEvents: [{ payload: { result: { weakness_tags: ["SQL", "sql", "System design"] } } }], +}, producers); + +assert.deepEqual(Object.keys(genuineZeros), [ + "rqScore", + "interview", + "resume", + "opportunity", + "pathway", + "socialBrand", + "roleplay", + "learning", + "skillGap", +]); +for (const value of Object.values(genuineZeros)) { + assert.deepEqual(Object.keys(value), ["status", "source", "value", "unit", "label", "updatedAt", "reason", "detail"]); +} + +assert.deepEqual( + { + rq: [genuineZeros.rqScore.status, genuineZeros.rqScore.value], + interview: [genuineZeros.interview.status, genuineZeros.interview.value], + resume: [genuineZeros.resume.status, genuineZeros.resume.value], + opportunity: [genuineZeros.opportunity.status, genuineZeros.opportunity.value], + social: [genuineZeros.socialBrand.status, genuineZeros.socialBrand.value], + roleplay: [genuineZeros.roleplay.status, genuineZeros.roleplay.value], + learning: [genuineZeros.learning.status, genuineZeros.learning.value], + skillGap: [genuineZeros.skillGap.status, genuineZeros.skillGap.value], + pathway: [genuineZeros.pathway.status, genuineZeros.pathway.value], + }, + { + rq: ["ready", 0], + interview: ["ready", 0], + resume: ["ready", 0], + opportunity: ["empty", 0], + social: ["ready", 0], + roleplay: ["ready", 0], + learning: ["empty", 0], + skillGap: ["ready", 2], + pathway: ["unsupported", null], + }, + "genuine zero, confirmed empty, and unsupported must remain distinct", +); +assert.deepEqual(genuineZeros.socialBrand.detail?.audienceReach, { + value: 0, + unit: "followers", + label: "Audience reach", + updatedAt: null, +}, "Audience Reach must be an explicit producer-owned display object and preserve genuine zero"); + +const failed = async () => { throw new Error("unreachable"); }; +const unavailable = await buildHomeInsightSnapshots({ + userId: "user_fixture", + qscore: { status: "unavailable", reason: "request_failed" }, + qscoreHistory: { status: "unavailable", reason: "request_failed" }, + recentEvents: [], +}, { + interviewPageState: failed, + roleplayPageState: failed, + resumeState: failed, + socialState: failed, + matchmakingStats: failed, + courseSummary: failed, + assessments: failed, +}); + +for (const [key, value] of Object.entries(unavailable)) { + assert.equal(value.status, "unavailable", `${key} outage must be unavailable`); + assert.equal(value.value, null, `${key} outage must not become zero`); +} + +const inactiveQscore: QscoreReadResult = { + status: "ready", + data: inactiveCategoryQscoreFixture(), +}; +const primaryOutageWithNoQscoreEvidence = await buildHomeInsightSnapshots({ + userId: "user_fixture", + qscore: inactiveQscore, + qscoreHistory: history, + recentEvents: [], +}, { + interviewPageState: failed, + roleplayPageState: failed, + resumeState: failed, + socialState: failed, + matchmakingStats: failed, + courseSummary: failed, + assessments: failed, +}); + +for (const key of ["interview", "resume", "opportunity", "socialBrand", "roleplay", "learning", "skillGap"] as const) { + assert.equal(primaryOutageWithNoQscoreEvidence[key].status, "unavailable", `${key} outage must not be masked by inactive qscore evidence`); + assert.equal(primaryOutageWithNoQscoreEvidence[key].value, null, `${key} outage with inactive qscore must not become zero`); +} + +const malformedCourseA2a = await buildHomeInsightSnapshots({ + userId: "user_fixture", + qscore: inactiveQscore, + qscoreHistory: history, + recentEvents: [], +}, { + ...producers, + courseSummary: async () => ({ status: "completed", messages: [] }), +}); +assert.equal(malformedCourseA2a.learning.status, "unavailable"); +assert.equal(malformedCourseA2a.learning.value, null); +assert.equal(malformedCourseA2a.learning.reason, "producer_response_invalid"); +assert.equal(malformedCourseA2a.learning.source, "courses.course-state"); + +const malformedA2a = await buildHomeInsightSnapshots({ + userId: "user_fixture", + qscore: readyQscore, + qscoreHistory: history, + recentEvents: [], +}, { + ...producers, + matchmakingStats: async () => ({ status: "completed", messages: [] }), +}); +assert.equal(malformedA2a.opportunity.status, "ready", "valid qscore category is the honest fallback"); +assert.equal(malformedA2a.opportunity.source, "qscore.breakdown.display"); +assert.equal(malformedA2a.opportunity.value, 75); + +console.log("home insight snapshot contract: ok"); diff --git a/src/services/product-service-clients.ts b/src/services/product-service-clients.ts index 638f366..9a66ab6 100644 --- a/src/services/product-service-clients.ts +++ b/src/services/product-service-clients.ts @@ -167,6 +167,44 @@ export const courseService = { health: () => serviceJson(config.coursesServiceUrl, "/health"), task: (payload: { action?: string; params?: JsonObject; user_id: string; user_context?: JsonObject; session_start?: boolean; task_id?: string }) => serviceJson(config.coursesServiceUrl, "/a2a/tasks", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }), + summaryState: (userId: string) => + serviceJson(config.coursesServiceUrl, "/a2a/tasks", { + body: { user_id: userId, session_start: true, params: { page: "summary" } }, + timeoutMs: DEFAULT_SERVICE_TIMEOUT_MS, + }), +}; + +export const assessmentService = { + health: () => serviceJson(config.assessmentServiceUrl, "/api/v1/health"), + listForUser: (userId: string, limit = 50) => + serviceJson(config.assessmentServiceUrl, `/api/v1/assessments?${new URLSearchParams({ + user_id: userId, + limit: String(limit), + })}`, { + headers: userHeader(userId), + }), +}; + +export const matchmakingService = { + health: () => serviceJson(config.matchmakingServiceUrl, "/api/v1/health"), + task: (payload: { action: string; params?: JsonObject; user_id: string; user_context?: JsonObject }) => + serviceJson(config.matchmakingServiceUrl, "/a2a/tasks", { + body: payload, + timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS, + }), + scoutStats: (userId: string, qscoreUserId: string) => + serviceJson(config.matchmakingServiceUrl, "/a2a/tasks", { + body: { action: "get_scout_stats", user_id: userId, params: { user_uuid: qscoreUserId } }, + timeoutMs: DEFAULT_SERVICE_TIMEOUT_MS, + }), +}; + +export const socialBrandingService = { + health: () => serviceJson(config.socialBrandingServiceUrl, "/healthz"), + state: (clerkId: string) => + serviceJson(config.socialBrandingServiceUrl, `/api/state/${encodeURIComponent(clerkId)}`, { + headers: userHeader(clerkId), + }), }; export const resumeService = { diff --git a/src/services/qscore-proxy.ts b/src/services/qscore-proxy.ts index ea0d023..de5812f 100644 --- a/src/services/qscore-proxy.ts +++ b/src/services/qscore-proxy.ts @@ -54,6 +54,16 @@ export type QscoreServiceResult = { */ export type QscoreProxyResult = QscoreServiceResult; +export type QscoreReadResult = + | { status: "ready"; data: T } + | { status: "empty"; reason: "not_found" } + | { status: "unavailable"; reason: "request_failed" | "invalid_response" | `http_${number}` }; + +export type QscoreHistoryPoint = { + rq_score: number; + date: string; +}; + export type QscoreLatestSignal = { signal_id: string; source: string | null; @@ -117,6 +127,16 @@ export async function getQscoreFromService( orgId: string = DEFAULT_QSCORE_ORG_ID, timeoutMs: number = QSCORE_PROXY_TIMEOUT_MS, ): Promise { + const result = await readQscoreFromService(userId, orgId, timeoutMs); + return result.status === "ready" ? result.data : null; +} + +/** Status-bearing score read for callers that must not collapse absence and outage. */ +export async function readQscoreFromService( + userId: string, + orgId: string = DEFAULT_QSCORE_ORG_ID, + timeoutMs: number = QSCORE_PROXY_TIMEOUT_MS, +): Promise> { const qscoreUserId = toQscoreUserId(userId); const path = `/v1/qscore/${encodeURIComponent(qscoreUserId)}?org_id=${encodeURIComponent(orgId)}`; const url = `${config.qscoreServiceUrl.replace(/\/$/, "")}${path}`; @@ -130,21 +150,64 @@ export async function getQscoreFromService( }, signal: AbortSignal.timeout(timeoutMs), }); - // 404 = no run computed yet for this user → treat as null (graceful). - if (res.status === 404) return null; + // A missing run is distinct from an unavailable service for status-aware callers. + if (res.status === 404) return { status: "empty", reason: "not_found" }; if (!res.ok) { - const text = await res.text().catch(() => ""); log.warn( - { status: res.status, body: text, userId, orgId }, + { status: res.status, userId, orgId }, "qscore_service returned non-success status", ); - return null; + return { status: "unavailable", reason: `http_${res.status}` }; } const body = await res.json(); - return parseResult(body); + try { + return { status: "ready", data: parseResult(body) }; + } catch (err) { + log.warn({ err, userId, orgId }, "qscore_service returned invalid response"); + return { status: "unavailable", reason: "invalid_response" }; + } } catch (err) { log.warn({ err, userId, orgId }, "qscore_service proxy call failed"); - return null; + return { status: "unavailable", reason: "request_failed" }; + } +} + +/** Fetch daily RQ Score history while preserving empty vs unavailable. */ +export async function readQscoreHistoryFromService( + userId: string, + orgId: string = DEFAULT_QSCORE_ORG_ID, + points = 10, + timeoutMs: number = QSCORE_PROXY_TIMEOUT_MS, +): Promise> { + const qscoreUserId = toQscoreUserId(userId); + const query = new URLSearchParams({ org_id: orgId, points: String(points) }); + const url = `${config.qscoreServiceUrl.replace(/\/$/, "")}/v1/qscore/${encodeURIComponent(qscoreUserId)}/history?${query}`; + try { + const res = await fetch(url, { + headers: { + "content-type": "application/json", + ...(config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : {}), + }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (res.status === 404) return { status: "empty", reason: "not_found" }; + if (!res.ok) { + log.warn({ status: res.status, userId, orgId }, "qscore_service history returned non-success status"); + return { status: "unavailable", reason: `http_${res.status}` }; + } + const body: unknown = await res.json(); + if (!isRecord(body) || !Array.isArray(body.points)) { + return { status: "unavailable", reason: "invalid_response" }; + } + const history = body.points.flatMap((value): QscoreHistoryPoint[] => { + if (!isRecord(value) || typeof value.rq_score !== "number" || !Number.isFinite(value.rq_score) || typeof value.date !== "string") return []; + return [{ rq_score: value.rq_score, date: value.date }]; + }); + if (history.length !== body.points.length) return { status: "unavailable", reason: "invalid_response" }; + return { status: "ready", data: history }; + } catch (err) { + log.warn({ err, userId, orgId }, "qscore_service history proxy call failed"); + return { status: "unavailable", reason: "request_failed" }; } } diff --git a/src/v1/analytics/README.md b/src/v1/analytics/README.md index d7f7137..e8fb056 100644 --- a/src/v1/analytics/README.md +++ b/src/v1/analytics/README.md @@ -8,3 +8,73 @@ The added responsibility here is the nightly improvement loop: 2. Generate validated improvement signal objects with the Vercel AI SDK. 3. Apply those signals to the V1 Curator as events. 4. The Curator uses them on the next day when shaping tasks and nudges. + +## Home insight snapshot contract + +`GET /v1/analytics/insight-snapshot` keeps its legacy `roleFit`, +`readinessTrend`, `activity`, and `opportunities` fields and adds the +`home-insight-snapshot-v1` contract: + +```json +{ + "contractVersion": "home-insight-snapshot-v1", + "generatedAt": "ISO-8601 timestamp", + "snapshots": { + "rqScore": {}, + "interview": {}, + "resume": {}, + "opportunity": {}, + "pathway": {}, + "socialBrand": {}, + "roleplay": {}, + "learning": {}, + "skillGap": {} + } +} +``` + +Every snapshot has the same fields: + +- `status`: `ready`, `empty`, `unavailable`, or `unsupported`. +- `source`: the backend-owned producer identifier. +- `value`, `unit`, and `label`: display-ready values from real producer data. +- `updatedAt`: producer timestamp when one exists. +- `reason`: a stable machine-readable reason for non-ready states. +- `detail`: real supporting evidence; consumers must not recalculate scores from it. + +The status contract is strict. `ready` requires a real numeric value. `empty` +requires a successful producer read that confirms absence or a genuine zero +count. `unavailable` means the producer failed, timed out, or returned an +invalid response. `unsupported` means no viable producer contract exists. +Failures and nulls are never converted to zero. + +The authenticated Clerk user id is passed unchanged to product services. +Qscore is the only exception: `toQscoreUserId` applies the shared deterministic +UUID mapping before current/history reads or matchmaking's optional qscore +cross-read. + +### Producer ownership + +- RQ Score: qscore current plus daily history. +- Interview and Roleplay: their page-state APIs, with canonical qscore display + categories as a read-only fallback. +- Resume: resume state, with canonical qscore display fallback. +- Opportunity: matchmaking `get_scout_stats` A2A data, with qscore matching + activity fallback. +- Pathway: canonical qscore display only. Formula versions with zero registered + pathway rules return `unsupported`, not zero progress. +- Social Brand: social state/profile-analysis score. Audience Reach is the + separate producer-owned `detail.audienceReach` display object + (`value`, `unit: "followers"`, `label`, and `updatedAt`) only when Social + confirms a connected profile and numeric follower count. It is never blended + into the brand score or inferred by the consumer. +- Learning: course A2A summary state and persisted substantial-watch count. +- Skill Gap: explicit producer-owned weakness/gap tags from GrowEvents. An + assessment result without an explicit gap metric is evidence detail, not a + fabricated gap count. + +All product-service calls are concurrent, bounded, and failure-isolated. A +single unavailable producer does not fail the full snapshot response. A direct +producer failure may fall back only to an active, `ready` canonical qscore +category. An inactive category with `no_category_evidence` never masks an +outage as an empty numeric zero. diff --git a/src/v1/analytics/analytics-routes.ts b/src/v1/analytics/analytics-routes.ts index b74c67d..fe5bf2f 100644 --- a/src/v1/analytics/analytics-routes.ts +++ b/src/v1/analytics/analytics-routes.ts @@ -7,7 +7,13 @@ import { growEvents } from "../../db/schema.js"; import { recordGrowEvent } from "../../events/record-grow-event.js"; import { routeGrowEventToUserActor } from "../../events/route-to-user-actor.js"; import { v1AnalyticsActor } from "./analytics-actor.js"; -import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService, getQscoreLatestSignalsFromService } from "../../services/qscore-proxy.js"; +import { + DEFAULT_QSCORE_ORG_ID, + getQscoreLatestSignalsFromService, + readQscoreFromService, + readQscoreHistoryFromService, +} from "../../services/qscore-proxy.js"; +import { buildHomeInsightSnapshots } from "./home-insight-snapshot.js"; function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -45,10 +51,12 @@ export function v1AnalyticsRoutes() { app.get("/insight-snapshot", async (c) => { const userId = c.get("userId"); - const [qscoreResult, qscoreEvidence] = await Promise.all([ - getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID), + const [qscoreRead, qscoreHistory, qscoreEvidence] = await Promise.all([ + readQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID), + readQscoreHistoryFromService(userId, DEFAULT_QSCORE_ORG_ID), getQscoreLatestSignalsFromService(userId, DEFAULT_QSCORE_ORG_ID), ]); + const qscoreResult = qscoreRead.status === "ready" ? qscoreRead.data : null; const breakdown = qscoreResult?.breakdown ?? {}; const latestSignals = (qscoreEvidence ?? []).map((signal) => ({ signalId: signal.signal_id, @@ -81,8 +89,18 @@ export function v1AnalyticsRoutes() { const readinessSummary = null; const lastUpdatedAt = qscoreResult?.calculated_at ?? null; const signalCount = latestSignals.length; + const snapshots = await buildHomeInsightSnapshots({ + userId, + qscore: qscoreRead, + qscoreHistory, + recentEvents, + }); + const generatedAt = new Date().toISOString(); const response = { + contractVersion: "home-insight-snapshot-v1", + generatedAt, + snapshots, roleFit: { score, label: score === null ? "baseline_needed" : score >= 75 ? "strong" : score >= 55 ? "building" : "needs_focus", diff --git a/src/v1/analytics/home-insight-snapshot.ts b/src/v1/analytics/home-insight-snapshot.ts new file mode 100644 index 0000000..0b3c91f --- /dev/null +++ b/src/v1/analytics/home-insight-snapshot.ts @@ -0,0 +1,547 @@ +import { + assessmentService, + courseService, + interviewService, + matchmakingService, + resumeService, + roleplayService, + socialBrandingService, +} from "../../services/product-service-clients.js"; +import { + toQscoreUserId, + type QscoreHistoryPoint, + type QscoreProxyResult, + type QscoreReadResult, +} from "../../services/qscore-proxy.js"; + +export type InsightSnapshotStatus = "ready" | "empty" | "unavailable" | "unsupported"; + +export type InsightSnapshot = { + status: InsightSnapshotStatus; + source: string; + value: number | null; + unit: string | null; + label: string | null; + updatedAt: string | null; + reason: string | null; + detail: Record | null; +}; + +export type HomeInsightSnapshots = { + rqScore: InsightSnapshot; + interview: InsightSnapshot; + resume: InsightSnapshot; + opportunity: InsightSnapshot; + pathway: InsightSnapshot; + socialBrand: InsightSnapshot; + roleplay: InsightSnapshot; + learning: InsightSnapshot; + skillGap: InsightSnapshot; +}; + +export type InsightEvent = { + payload: unknown; + occurredAt?: Date | string | null; +}; + +type ProducerDependencies = { + interviewPageState: (userId: string) => Promise; + roleplayPageState: (userId: string) => Promise; + resumeState: (userId: string) => Promise; + socialState: (userId: string) => Promise; + matchmakingStats: (userId: string, qscoreUserId: string) => Promise; + courseSummary: (userId: string) => Promise; + assessments: (userId: string) => Promise; +}; + +const defaultDependencies: ProducerDependencies = { + interviewPageState: interviewService.pageState, + roleplayPageState: roleplayService.pageState, + resumeState: resumeService.state, + socialState: socialBrandingService.state, + matchmakingStats: matchmakingService.scoutStats, + courseSummary: courseService.summaryState, + assessments: assessmentService.listForUser, +}; + +type ProducerRead = + | { status: "ready"; data: Record } + | { status: "unavailable"; reason: "producer_unavailable" | "producer_response_invalid" }; + +type CategoryEvidence = { + id: string; + label: string; + score: number; + active: boolean; + presentRules: number; + totalRules: number; + weightPercent: number | null; + signals: unknown[]; +}; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function finiteCount(value: unknown): number | null { + const number = finiteNumber(value); + return number !== null && number >= 0 ? number : null; +} + +function text(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value : null; +} + +function snapshot( + status: InsightSnapshotStatus, + source: string, + label: string, + unit: string, + options: { + value?: number | null; + updatedAt?: string | null; + reason?: string | null; + detail?: Record | null; + } = {}, +): InsightSnapshot { + return { + status, + source, + value: options.value ?? null, + unit, + label, + updatedAt: options.updatedAt ?? null, + reason: options.reason ?? null, + detail: options.detail ?? null, + }; +} + +async function readProducer(call: () => Promise): Promise { + try { + const value = await call(); + return isRecord(value) + ? { status: "ready", data: value } + : { status: "unavailable", reason: "producer_response_invalid" }; + } catch { + return { status: "unavailable", reason: "producer_unavailable" }; + } +} + +function qscoreDisplay(result: QscoreProxyResult): Record | null { + const display = result.breakdown.display; + return isRecord(display) && display.version === "rq-score-display-v1" ? display : null; +} + +function categoryEvidence(result: QscoreProxyResult, id: string): CategoryEvidence | null { + const display = qscoreDisplay(result); + const categories = display?.categories; + if (!Array.isArray(categories)) return null; + const raw = categories.find((value) => isRecord(value) && value.id === id); + if (!isRecord(raw)) return null; + const score = finiteNumber(raw.score); + const presentRules = finiteCount(raw.present_rules); + const totalRules = finiteCount(raw.total_rules); + if (score === null || presentRules === null || totalRules === null || typeof raw.active !== "boolean") return null; + return { + id, + label: text(raw.label) ?? id, + score, + active: raw.active, + presentRules, + totalRules, + weightPercent: finiteNumber(raw.weight_percent), + signals: Array.isArray(raw.signals) ? raw.signals : [], + }; +} + +function categoryDetail(category: CategoryEvidence | null): Record | null { + if (!category) return null; + return { + category: { + id: category.id, + score: category.score, + active: category.active, + presentRules: category.presentRules, + totalRules: category.totalRules, + weightPercent: category.weightPercent, + signals: category.signals, + }, + }; +} + +function qscoreCategorySnapshot( + qscore: QscoreReadResult, + categoryId: string, + label: string, +): InsightSnapshot { + if (qscore.status === "unavailable") { + return snapshot("unavailable", "qscore.current", label, "score", { reason: "qscore_unavailable" }); + } + if (qscore.status === "empty") { + return snapshot("empty", "qscore.current", label, "score", { reason: "no_qscore_run" }); + } + const category = categoryEvidence(qscore.data, categoryId); + if (!category) { + return snapshot("unsupported", "qscore.breakdown.display", label, "score", { + reason: "display_contract_missing", + updatedAt: qscore.data.calculated_at || null, + }); + } + const detail = categoryDetail(category); + if (category.totalRules === 0) { + return snapshot("unsupported", "qscore.breakdown.display", label, "score", { + reason: categoryId === "pathway_progress" ? "pathway_signal_contract_missing" : "category_unsupported", + updatedAt: qscore.data.calculated_at || null, + detail, + }); + } + if (!category.active || category.presentRules === 0) { + return snapshot("empty", "qscore.breakdown.display", label, "score", { + value: 0, + reason: "no_category_evidence", + updatedAt: qscore.data.calculated_at || null, + detail, + }); + } + return snapshot("ready", "qscore.breakdown.display", label, "score", { + value: category.score, + updatedAt: qscore.data.calculated_at || null, + detail, + }); +} + +function combineDetail(...parts: Array | null | undefined>): Record | null { + const merged = Object.assign({}, ...parts.filter((part): part is Record => !!part)); + return Object.keys(merged).length ? merged : null; +} + +function unavailableWhenNoUsableFallback( + read: ProducerRead, + fallback: InsightSnapshot, + source: string, + label: string, + unit: string, +): InsightSnapshot { + if (read.status === "ready" || fallback.status === "ready") return fallback; + return snapshot("unavailable", source, label, unit, { + reason: read.reason, + detail: fallback.detail, + }); +} + +function sessionPageData(read: ProducerRead): ProducerRead { + if (read.status === "unavailable") return read; + return Array.isArray(read.data.recent_sessions) + ? read + : { status: "unavailable", reason: "producer_response_invalid" }; +} + +function socialStateData(read: ProducerRead): ProducerRead { + if (read.status === "unavailable") return read; + return typeof read.data.linkedin_connected === "boolean" + ? read + : { status: "unavailable", reason: "producer_response_invalid" }; +} + +function a2aData(read: ProducerRead, action: string): ProducerRead { + if (read.status === "unavailable") return read; + if (read.data.status !== "completed" || !Array.isArray(read.data.messages)) { + return { status: "unavailable", reason: "producer_response_invalid" }; + } + const message = read.data.messages.find((value) => isRecord(value) && value.action === action); + return isRecord(message) && isRecord(message.data) + ? { status: "ready", data: message.data } + : { status: "unavailable", reason: "producer_response_invalid" }; +} + +function latestSessionScore(data: Record): { score: number | null; count: number; updatedAt: string | null } { + const sessions = Array.isArray(data.recent_sessions) ? data.recent_sessions.filter(isRecord) : []; + const latest = sessions[0]; + return { + score: latest ? finiteNumber(latest.overall_score) : null, + count: sessions.length, + updatedAt: latest ? text(latest.completed_at ?? latest.updated_at ?? latest.created_at) : null, + }; +} + +function gapTagsFromEvents(events: InsightEvent[]): string[] { + const keys = new Set(["weakness_tags", "weaknessTags", "skill_gaps", "skillGaps"]); + const tags = new Map(); + const visit = (value: unknown, depth: number) => { + if (depth > 4 || !isRecord(value)) return; + for (const [key, item] of Object.entries(value)) { + if (keys.has(key) && Array.isArray(item)) { + for (const tag of item) { + if (typeof tag !== "string" || !tag.trim()) continue; + tags.set(tag.trim().toLowerCase(), tag.trim()); + } + } else if (isRecord(item)) { + visit(item, depth + 1); + } + } + }; + for (const event of events) visit(event.payload, 0); + return [...tags.values()]; +} + +export async function buildHomeInsightSnapshots(input: { + userId: string; + qscore: QscoreReadResult; + qscoreHistory: QscoreReadResult; + recentEvents: InsightEvent[]; +}, dependencies: ProducerDependencies = defaultDependencies): Promise { + const [interviewRaw, roleplayRaw, resumeRead, socialRaw, matchmakingRaw, courseRaw, assessmentRead] = await Promise.all([ + readProducer(() => dependencies.interviewPageState(input.userId)), + readProducer(() => dependencies.roleplayPageState(input.userId)), + readProducer(() => dependencies.resumeState(input.userId)), + readProducer(() => dependencies.socialState(input.userId)), + readProducer(() => dependencies.matchmakingStats(input.userId, toQscoreUserId(input.userId))), + readProducer(() => dependencies.courseSummary(input.userId)), + readProducer(() => dependencies.assessments(input.userId)), + ]); + const interviewRead = sessionPageData(interviewRaw); + const roleplayRead = sessionPageData(roleplayRaw); + const socialRead = socialStateData(socialRaw); + + const qscoreData = input.qscore.status === "ready" ? input.qscore.data : null; + const historyDetail = input.qscoreHistory.status === "ready" + ? { history: input.qscoreHistory.data, historyStatus: "ready" } + : { history: null, historyStatus: input.qscoreHistory.status }; + const rqScore = input.qscore.status === "ready" + ? snapshot("ready", "qscore.current", "RQ Score", "score", { + value: input.qscore.data.rq_score, + updatedAt: input.qscore.data.calculated_at || null, + detail: { + contractVersion: qscoreDisplay(input.qscore.data)?.version ?? null, + formulaVersion: input.qscore.data.formula_version, + quotients: input.qscore.data.quotients, + ...historyDetail, + }, + }) + : input.qscore.status === "empty" + ? snapshot("empty", "qscore.current", "RQ Score", "score", { reason: "no_qscore_run", detail: historyDetail }) + : snapshot("unavailable", "qscore.current", "RQ Score", "score", { reason: "qscore_unavailable", detail: historyDetail }); + + const interviewFallback = qscoreCategorySnapshot(input.qscore, "interview_performance", "Interview performance"); + let interview = unavailableWhenNoUsableFallback(interviewRead, interviewFallback, "interview.page-state", "Interview performance", "score"); + if (interviewRead.status === "ready") { + const direct = latestSessionScore(interviewRead.data); + const directDetail = { completedSessions: direct.count, latestScore: direct.score }; + if (direct.count === 0) { + interview = snapshot("empty", "interview.page-state", "Interview performance", "score", { + reason: "no_interview_sessions", + detail: combineDetail(directDetail, interviewFallback.detail), + }); + } else if (direct.score !== null) { + interview = snapshot("ready", "interview.page-state", "Interview performance", "score", { + value: direct.score, + updatedAt: direct.updatedAt, + detail: combineDetail(directDetail, interviewFallback.detail), + }); + } else if (interviewFallback.status !== "ready") { + interview = snapshot("unavailable", "interview.page-state", "Interview performance", "score", { + reason: "producer_value_missing", + detail: combineDetail(directDetail, interviewFallback.detail), + }); + } + } + + const resumeFallback = qscoreCategorySnapshot(input.qscore, "resume_presence", "Resume completeness"); + let resume = unavailableWhenNoUsableFallback(resumeRead, resumeFallback, "resume.state", "Resume completeness", "percent"); + if (resumeRead.status === "ready") { + const resumeCount = finiteCount(resumeRead.data.resume_count); + const completeness = finiteNumber(resumeRead.data.resume_completeness); + const directDetail = { resumeCount, resumeStatus: text(resumeRead.data.resume_status) }; + if (resumeCount === 0) { + resume = snapshot("empty", "resume.state", "Resume completeness", "percent", { + value: 0, + reason: "no_resumes", + detail: combineDetail(directDetail, resumeFallback.detail), + }); + } else if (resumeCount !== null && resumeCount > 0 && completeness !== null) { + resume = snapshot("ready", "resume.state", "Resume completeness", "percent", { + value: completeness, + detail: combineDetail(directDetail, resumeFallback.detail), + }); + } else if (resumeFallback.status !== "ready") { + resume = snapshot("unavailable", "resume.state", "Resume completeness", "percent", { + reason: "producer_response_invalid", + detail: resumeFallback.detail, + }); + } + } + + const opportunityFallback = qscoreCategorySnapshot(input.qscore, "matching_activity", "Opportunities matched"); + const matchmakingRead = a2aData(matchmakingRaw, "scout_stats"); + let opportunity = unavailableWhenNoUsableFallback(matchmakingRead, opportunityFallback, "matchmaking.scout-stats", "Opportunities matched", "matches"); + if (matchmakingRead.status === "ready") { + const matches = finiteCount(matchmakingRead.data.matchesFound); + const directDetail = { + matchesFound: matches, + topMatch: finiteNumber(matchmakingRead.data.topMatch), + funnel: Array.isArray(matchmakingRead.data.funnel) ? matchmakingRead.data.funnel : [], + searches: finiteCount(matchmakingRead.data.searches), + tailoredResumes: finiteCount(matchmakingRead.data.tailoredResumes), + }; + if (matches === 0) { + opportunity = snapshot("empty", "matchmaking.scout-stats", "Opportunities matched", "matches", { + value: 0, + reason: "no_matches", + detail: combineDetail(directDetail, opportunityFallback.detail), + }); + } else if (matches !== null) { + opportunity = snapshot("ready", "matchmaking.scout-stats", "Opportunities matched", "matches", { + value: matches, + detail: combineDetail(directDetail, opportunityFallback.detail), + }); + } + } + + const pathway = qscoreCategorySnapshot(input.qscore, "pathway_progress", "Pathway progress"); + + const socialFallback = qscoreCategorySnapshot(input.qscore, "linkedin_presence", "Social brand"); + let socialBrand = unavailableWhenNoUsableFallback(socialRead, socialFallback, "social.state", "Social brand", "score"); + if (socialRead.status === "ready") { + const connected = socialRead.data.linkedin_connected === true; + const brandScore = finiteNumber(socialRead.data.profile_analysis_score); + const sectionScores = isRecord(socialRead.data.section_scores) ? socialRead.data.section_scores : null; + const followers = finiteCount(socialRead.data.followers_count); + const lastSyncedAt = text(socialRead.data.last_synced_at); + const directDetail = { + connected, + followers, + audienceReach: connected && followers !== null + ? { value: followers, unit: "followers", label: "Audience reach", updatedAt: lastSyncedAt } + : null, + connections: finiteCount(socialRead.data.connections_count), + skills: finiteCount(socialRead.data.skills_count), + experience: finiteCount(socialRead.data.experience_count), + improvements: finiteCount(socialRead.data.improvements_count), + grade: text(socialRead.data.score_grade), + sectionScores, + lastSyncedAt, + }; + if (!connected) { + socialBrand = snapshot("empty", "social.state", "Social brand", "score", { + reason: "no_social_profile", + detail: combineDetail(directDetail, socialFallback.detail), + }); + } else if (brandScore !== null) { + socialBrand = snapshot("ready", "social.state", "Social brand", "score", { + value: brandScore, + updatedAt: lastSyncedAt, + detail: combineDetail(directDetail, socialFallback.detail), + }); + } else if (socialFallback.status !== "ready") { + socialBrand = snapshot("unavailable", "social.state", "Social brand", "score", { + reason: "producer_value_missing", + detail: combineDetail(directDetail, socialFallback.detail), + }); + } + } + + const roleplayFallback = qscoreCategorySnapshot(input.qscore, "roleplay_performance", "Roleplay performance"); + let roleplay = unavailableWhenNoUsableFallback(roleplayRead, roleplayFallback, "roleplay.page-state", "Roleplay performance", "score"); + if (roleplayRead.status === "ready") { + const direct = latestSessionScore(roleplayRead.data); + const directDetail = { completedSessions: direct.count, latestScore: direct.score }; + if (direct.count === 0) { + roleplay = snapshot("empty", "roleplay.page-state", "Roleplay performance", "score", { + reason: "no_roleplay_sessions", + detail: combineDetail(directDetail, roleplayFallback.detail), + }); + } else if (direct.score !== null) { + roleplay = snapshot("ready", "roleplay.page-state", "Roleplay performance", "score", { + value: direct.score, + updatedAt: direct.updatedAt, + detail: combineDetail(directDetail, roleplayFallback.detail), + }); + } else if (roleplayFallback.status !== "ready") { + roleplay = snapshot("unavailable", "roleplay.page-state", "Roleplay performance", "score", { + reason: "producer_value_missing", + detail: combineDetail(directDetail, roleplayFallback.detail), + }); + } + } + + const learningFallback = qscoreCategorySnapshot(input.qscore, "learning_credentials", "Learning progress"); + const courseRead = a2aData(courseRaw, "course_state_loaded"); + let learning = unavailableWhenNoUsableFallback(courseRead, learningFallback, "courses.course-state", "Learning progress", "videos"); + if (courseRead.status === "ready") { + const hasProfile = courseRead.data.has_interest_profile === true; + const watchStats = isRecord(courseRead.data.watch_stats) ? courseRead.data.watch_stats : {}; + const watched = finiteCount(watchStats.watched ?? courseRead.data.watch_count); + const directDetail = { + hasInterestProfile: hasProfile, + watchCount: finiteCount(courseRead.data.watch_count), + watched, + totalSeconds: finiteCount(watchStats.total_s), + anchors: Array.isArray(watchStats.anchors) ? watchStats.anchors : [], + }; + if (!hasProfile || watched === 0) { + learning = snapshot("empty", "courses.course-state", "Learning progress", "videos", { + value: 0, + reason: "no_learning_activity", + detail: combineDetail(directDetail, learningFallback.detail), + }); + } else if (watched !== null) { + learning = snapshot("ready", "courses.course-state", "Learning progress", "videos", { + value: watched, + detail: combineDetail(directDetail, learningFallback.detail), + }); + } + } + + const explicitGapTags = gapTagsFromEvents(input.recentEvents); + let skillGap: InsightSnapshot; + if (explicitGapTags.length > 0) { + skillGap = snapshot("ready", "grow_events.weakness-tags", "Skill gaps", "gaps", { + value: explicitGapTags.length, + detail: { gaps: explicitGapTags }, + }); + } else if (assessmentRead.status === "unavailable") { + skillGap = snapshot("unavailable", "assessment.list", "Skill gaps", "gaps", { + reason: assessmentRead.reason, + }); + } else { + const items = Array.isArray(assessmentRead.data.items) ? assessmentRead.data.items.filter(isRecord) : null; + const total = finiteCount(assessmentRead.data.total); + if (!items || total === null) { + skillGap = snapshot("unavailable", "assessment.list", "Skill gaps", "gaps", { + reason: "producer_response_invalid", + }); + } else if (total === 0) { + skillGap = snapshot("empty", "assessment.list", "Skill gaps", "gaps", { + reason: "no_skill_gap_evidence", + detail: { assessmentCount: 0 }, + }); + } else { + const latestCompleted = items.find((item) => item.status === "completed"); + skillGap = snapshot("unsupported", "assessment.list", "Skill gaps", "gaps", { + reason: "skill_gap_contract_missing", + updatedAt: latestCompleted ? text(latestCompleted.completed_at ?? latestCompleted.updated_at) : null, + detail: { + assessmentCount: total, + latestCompletedScore: latestCompleted ? finiteNumber(latestCompleted.score) : null, + latestCompletedMaxScore: latestCompleted ? finiteNumber(latestCompleted.max_score) : null, + resultBreakdownItems: latestCompleted && isRecord(latestCompleted.result) && Array.isArray(latestCompleted.result.breakdown) + ? latestCompleted.result.breakdown.length + : null, + }, + }); + } + } + + // Keep the successful Qscore timestamp discoverable even when a direct producer wins. + if (qscoreData?.calculated_at) { + for (const item of [interview, resume, opportunity, pathway, socialBrand, roleplay, learning]) { + if (item.detail && item.detail.qscoreCalculatedAt === undefined) item.detail.qscoreCalculatedAt = qscoreData.calculated_at; + } + } + + return { rqScore, interview, resume, opportunity, pathway, socialBrand, roleplay, learning, skillGap }; +}