feat: integrate live Home Insight snapshots #19
@@ -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",
|
||||
|
||||
221
scripts/home-insight-snapshot.test.ts
Normal file
221
scripts/home-insight-snapshot.test.ts
Normal file
@@ -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<Record<string, unknown>> };
|
||||
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<QscoreProxyResult> = { 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<QscoreProxyResult> = {
|
||||
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");
|
||||
@@ -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 = {
|
||||
|
||||
@@ -54,6 +54,16 @@ export type QscoreServiceResult = {
|
||||
*/
|
||||
export type QscoreProxyResult = QscoreServiceResult;
|
||||
|
||||
export type QscoreReadResult<T> =
|
||||
| { 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<QscoreProxyResult | null> {
|
||||
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<QscoreReadResult<QscoreProxyResult>> {
|
||||
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<QscoreReadResult<QscoreHistoryPoint[]>> {
|
||||
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" };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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",
|
||||
|
||||
547
src/v1/analytics/home-insight-snapshot.ts
Normal file
547
src/v1/analytics/home-insight-snapshot.ts
Normal file
@@ -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<string, unknown> | 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<unknown>;
|
||||
roleplayPageState: (userId: string) => Promise<unknown>;
|
||||
resumeState: (userId: string) => Promise<unknown>;
|
||||
socialState: (userId: string) => Promise<unknown>;
|
||||
matchmakingStats: (userId: string, qscoreUserId: string) => Promise<unknown>;
|
||||
courseSummary: (userId: string) => Promise<unknown>;
|
||||
assessments: (userId: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown> }
|
||||
| { 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<string, unknown> {
|
||||
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<string, unknown> | 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<unknown>): Promise<ProducerRead> {
|
||||
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<string, unknown> | 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<string, unknown> | 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<QscoreProxyResult>,
|
||||
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<Record<string, unknown> | null | undefined>): Record<string, unknown> | null {
|
||||
const merged = Object.assign({}, ...parts.filter((part): part is Record<string, unknown> => !!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<string, unknown>): { 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<string, string>();
|
||||
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<QscoreProxyResult>;
|
||||
qscoreHistory: QscoreReadResult<QscoreHistoryPoint[]>;
|
||||
recentEvents: InsightEvent[];
|
||||
}, dependencies: ProducerDependencies = defaultDependencies): Promise<HomeInsightSnapshots> {
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user