Compare commits

..

12 Commits

18 changed files with 2167 additions and 29 deletions

View File

@@ -13,11 +13,15 @@
"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:career-report": "tsx scripts/career-report.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",
"test:roleplay-drafts": "tsx scripts/roleplay-drafts.test.ts",
"test:artifact-transport": "tsx scripts/artifact-transport.test.ts",
"test:resume-gateway": "tsx scripts/resume-gateway.test.ts",
"test:roleplay-leaderboard": "tsx scripts/roleplay-leaderboard.test.ts",
"test:interview-leaderboard": "tsx scripts/interview-leaderboard.test.ts",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"workflows:smoke": "tsx src/workflows/smoke-test.ts",

View File

@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { serviceRoutes } from "../src/routes/services.js";
const originalFetch = globalThis.fetch;
const requests: Request[] = [];
globalThis.fetch = (async (input, init) => {
const request = new Request(input, init);
requests.push(request);
const path = new URL(request.url).pathname;
if (path.endsWith("/api/v1/artifacts/sess-1/session_video")) {
return new Response(JSON.stringify({ url: "http://s3.test/obj", mime_type: "video/webm" }), { headers: { "content-type": "application/json" } });
}
if (path === "/obj") return new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "video/webm", "content-length": "3" } });
if (path.endsWith("/api/v1/sessions/sess-1/video/upload-url")) {
return new Response(JSON.stringify({ url: "http://s3.test/put", mime_type: "video/webm" }), { headers: { "content-type": "application/json" } });
}
if (path === "/put") return new Response(null, { status: 200 });
throw new Error(`unexpected upstream ${request.method} ${request.url}`);
}) as typeof fetch;
const app = serviceRoutes({ skipAuth: true });
try {
for (const product of ["interview", "roleplay"] as const) {
const download = await app.request(`http://backend.test/${product}/artifacts/sess-1/session_video/download`);
assert.equal(download.status, 200);
const downloadedBytes = new Uint8Array(await download.arrayBuffer());
assert.deepEqual(Array.from(downloadedBytes), [1, 2, 3]);
assert.equal(download.headers.get("content-type"), "video/webm");
assert.equal(new TextDecoder().decode(downloadedBytes).includes("s3.test"), false);
const uploadBody = new Uint8Array([9, 8, 7]);
const upload = await app.request(`http://backend.test/${product}/sessions/sess-1/video/upload`, {
method: "PUT",
headers: { "content-type": "video/webm" },
body: uploadBody,
});
assert.equal(upload.status, 204);
const put = requests.at(-1)!;
assert.equal(put.method, "PUT");
assert.equal(put.headers.get("content-type"), "video/webm");
assert.deepEqual(Array.from(new Uint8Array(await put.arrayBuffer())), [9, 8, 7]);
}
} finally {
globalThis.fetch = originalFetch;
}
console.log("artifact transport gateway tests passed");

View File

@@ -0,0 +1,264 @@
import assert from "node:assert/strict";
import {
buildCareerReportProjection,
parseCareerReportDisplay,
type CareerReportActivity,
} from "../src/v1/analytics/career-report.js";
import type { HomeInsightSnapshots, InsightSnapshot } from "../src/v1/analytics/home-insight-snapshot.js";
import type { QscoreProxyResult } from "../src/services/qscore-proxy.js";
const categorySpecs = [
["learning_credentials", "Learning & Credentials"],
["resume_presence", "Resume Presence"],
["linkedin_presence", "LinkedIn Presence"],
["matching_activity", "Matching Activity"],
["interview_performance", "Interview Performance"],
["pathway_progress", "Pathway Progress"],
["roleplay_performance", "Roleplay Performance"],
["engagement_consistency", "Engagement & Consistency"],
["goals_readiness", "Goals & Readiness"],
];
function displayFixture() {
let signalIndex = 0;
const categories = categorySpecs.map(([id, label], categoryIndex) => {
const count = categoryIndex === categorySpecs.length - 1 ? 3 : 8;
const signals = Array.from({ length: count }, () => {
const index = signalIndex++;
return { id: `signal.${index}`, label: `Signal ${index}`, present: index < 4, score: index < 4 ? 50 : 0 };
});
return {
id,
label,
input_ids: [`input_${categoryIndex}`],
weight_percent: 10,
score: signals.some((signal) => signal.present) ? 50 : 0,
active: signals.some((signal) => signal.present),
present_rules: signals.filter((signal) => signal.present).length,
total_rules: signals.length,
signals,
};
});
return {
version: "rq-score-display-v1",
registered_signal_count: 67,
active_signal_count: 4,
categories,
};
}
function metric(overrides: Partial<InsightSnapshot> = {}): InsightSnapshot {
return {
status: "empty",
source: "fixture",
value: 0,
unit: "count",
label: "Fixture",
updatedAt: null,
reason: "no_evidence",
detail: null,
...overrides,
};
}
const snapshots: HomeInsightSnapshots = {
rqScore: metric({ status: "ready", source: "qscore.current", value: 61, reason: null }),
interview: metric({ source: "interview.page-state" }),
resume: metric({ status: "ready", source: "resume.state", value: 72, reason: null }),
opportunity: metric({ source: "matchmaking.scout-stats" }),
pathway: metric({ status: "unsupported", source: "qscore.breakdown.display", value: null, reason: "pathway_signal_contract_missing" }),
socialBrand: metric({ source: "social.state" }),
roleplay: metric({ source: "roleplay.page-state" }),
learning: metric({ source: "courses.course-state" }),
skillGap: metric({ source: "assessment.list" }),
};
const qscore: QscoreProxyResult = {
org_id: "growqr",
user_id: "00000000-0000-4000-8000-000000000000",
rq_score: 61,
iq_score: null,
eq_score: null,
sq_score: null,
profession: "",
formula_version: "v2",
formula_id: "fixture",
calculated_at: "2026-07-16T00:00:00Z",
quotients: {},
breakdown: { display: displayFixture() },
ledger_seq_from: null,
ledger_seq_to: null,
};
const activity: CareerReportActivity = {
windowDays: 14,
totalEvents: 0,
completedEvents: 0,
openedEvents: 0,
services: [],
opportunityEvents: 0,
};
const producerStatuses = {
interview: { status: "ready" as const, reason: null },
roleplay: { status: "ready" as const, reason: null },
resume: { status: "ready" as const, reason: null },
linkedin: { status: "ready" as const, reason: null },
matchmaking: { status: "ready" as const, reason: null },
courses: { status: "ready" as const, reason: null },
assessment: { status: "ready" as const, reason: null },
};
const report = buildCareerReportProjection({
generatedAt: "2026-07-16T01:00:00Z",
profile: {
status: "ready",
data: {
first_name: "Ada",
last_name: "Lovelace",
avatar_url: "https://images.example/ada.png",
updated_at: "2026-07-15T22:00:00Z",
preferences: {
onboarding: {
progress: { updated_at: "2026-07-15T23:00:00Z" },
responses: {
target_role: "Platform Engineer",
target_field: "Technology",
experience_level: "3-5 years",
current_situation: "employed",
desired_outcomes: ["Move into platform engineering"],
career_barriers: [],
},
},
},
},
},
qscore: { status: "ready", data: qscore },
qscoreHistory: { status: "ready", data: [{ date: "2026-07-15", rq_score: 61 }] },
snapshots,
producerStatuses,
activity,
});
assert.equal(report.contractVersion, "career-report-v1");
assert.deepEqual(report.subject.displayName, {
status: "ready",
source: "user-service.profile",
value: "Ada Lovelace",
updatedAt: "2026-07-15T22:00:00Z",
reason: null,
});
assert.equal(report.subject.targetRole.value, "Platform Engineer");
assert.equal(report.subject.barriers.status, "empty");
assert.deepEqual(report.subject.barriers.value, []);
assert.equal(report.rqScore.score.value, 61);
assert.equal(report.rqScore.display.status, "ready");
assert.equal(report.rqScore.display.value?.registeredSignalCount, 67);
assert.equal(report.rqScore.display.value?.categories.length, 9);
assert.deepEqual(report.activity.value, activity);
assert.equal(report.sources.find((source) => source.id === "grow_events")?.status, "ready");
const malformed = structuredClone(displayFixture());
malformed.registered_signal_count = 66;
assert.equal(parseCareerReportDisplay(malformed), null, "mislabelled canonical display must be rejected");
const reportWithMalformedDisplay = buildCareerReportProjection({
profile: { status: "ready", data: {} },
qscore: { status: "ready", data: { ...qscore, breakdown: { display: malformed } } },
qscoreHistory: { status: "ready", data: [] },
snapshots,
producerStatuses,
activity,
});
assert.equal(reportWithMalformedDisplay.rqScore.display.status, "unavailable");
assert.equal(reportWithMalformedDisplay.rqScore.display.value, null);
assert.equal(reportWithMalformedDisplay.rqScore.display.reason, "display_contract_invalid");
const reportWithoutDisplay = buildCareerReportProjection({
profile: { status: "ready", data: {} },
qscore: { status: "ready", data: { ...qscore, breakdown: {} } },
qscoreHistory: { status: "ready", data: [] },
snapshots,
producerStatuses,
activity,
});
assert.equal(reportWithoutDisplay.rqScore.display.status, "unsupported");
assert.equal(reportWithoutDisplay.rqScore.display.value, null);
assert.equal(reportWithoutDisplay.rqScore.display.reason, "display_contract_missing");
const wrongCategoryId = structuredClone(displayFixture());
wrongCategoryId.categories[0].id = "not_a_canonical_category";
assert.equal(parseCareerReportDisplay(wrongCategoryId), null, "wrong category ids must be rejected");
const wrongCategoryOrder = structuredClone(displayFixture());
[wrongCategoryOrder.categories[0], wrongCategoryOrder.categories[1]] = [wrongCategoryOrder.categories[1], wrongCategoryOrder.categories[0]];
assert.equal(parseCareerReportDisplay(wrongCategoryOrder), null, "canonical categories out of order must be rejected");
const outOfRangeCategoryScore = structuredClone(displayFixture());
outOfRangeCategoryScore.categories[0].score = 101;
assert.equal(parseCareerReportDisplay(outOfRangeCategoryScore), null, "category scores above 100 must be rejected");
const outOfRangeSignalScore = structuredClone(displayFixture());
outOfRangeSignalScore.categories[0].signals[0].score = -1;
assert.equal(parseCareerReportDisplay(outOfRangeSignalScore), null, "signal scores below zero must be rejected");
const outOfRangeWeight = structuredClone(displayFixture());
outOfRangeWeight.categories[0].weight_percent = 101;
assert.equal(parseCareerReportDisplay(outOfRangeWeight), null, "category weights above 100 must be rejected");
const duplicatedSignal = structuredClone(displayFixture());
duplicatedSignal.categories[1].signals[0].id = duplicatedSignal.categories[0].signals[0].id;
assert.equal(parseCareerReportDisplay(duplicatedSignal), null, "signals assigned more than once must be rejected");
const categoryCountMismatch = structuredClone(displayFixture());
categoryCountMismatch.categories[0].present_rules += 1;
assert.equal(parseCareerReportDisplay(categoryCountMismatch), null, "category count mismatches must be rejected");
const malformedOnboarding = buildCareerReportProjection({
profile: {
status: "ready",
data: {
preferences: {
onboarding: {
responses: {
target_role: 42,
desired_outcomes: ["Real goal", 7],
},
},
},
},
},
qscore: { status: "ready", data: qscore },
qscoreHistory: { status: "ready", data: [] },
snapshots,
producerStatuses,
activity,
});
assert.equal(malformedOnboarding.subject.targetRole.status, "unavailable");
assert.equal(malformedOnboarding.subject.targetRole.value, null);
assert.equal(malformedOnboarding.subject.targetRole.reason, "producer_response_invalid");
assert.equal(malformedOnboarding.subject.goals.status, "unavailable");
assert.equal(malformedOnboarding.subject.goals.value, null);
assert.equal(malformedOnboarding.subject.barriers.status, "empty", "missing optional arrays remain confirmed empty");
assert.deepEqual(malformedOnboarding.subject.barriers.value, []);
const unavailable = buildCareerReportProjection({
profile: { status: "unavailable", reason: "profile_unavailable" },
qscore: { status: "unavailable", reason: "request_failed" },
qscoreHistory: { status: "unavailable", reason: "request_failed" },
snapshots: Object.fromEntries(
Object.keys(snapshots).map((key) => [key, metric({ status: "unavailable", value: null, reason: "producer_unavailable" })]),
) as HomeInsightSnapshots,
producerStatuses: Object.fromEntries(
Object.keys(producerStatuses).map((key) => [key, { status: "unavailable", reason: "producer_unavailable" }]),
) as typeof producerStatuses,
activity,
});
assert.equal(unavailable.subject.displayName.status, "unavailable");
assert.equal(unavailable.subject.displayName.value, null);
assert.equal(unavailable.rqScore.score.status, "unavailable");
assert.equal(unavailable.rqScore.score.value, null);
assert.equal(unavailable.rqScore.history.value, null);
assert.equal(unavailable.rqScore.display.value, null);
console.log("career report contract: ok");

View File

@@ -6,6 +6,7 @@ import {
decideCuratorSprintReconciliation,
resolveCuratorSprintAccess,
shouldReconcileCuratorSprint,
shouldRebaseCuratorSprintForOnboardingContext,
} from "../src/v1/curator/curator-store.js";
import { templateSetFor, trialDaysFor } from "../src/v1/curator/task-registry.js";
@@ -51,6 +52,26 @@ const startDate = "2026-07-13";
const studentFingerprint = curatorSprintPlanFingerprint({ startDate, variantId: "student_recent_grad", durationDays: 2, access: "trial" });
const experiencedFingerprint = curatorSprintPlanFingerprint({ startDate, variantId: "experienced_professional", durationDays: 2, access: "trial" });
const experiencedPlan = curatorSprintTaskPlan({ startDate, variantId: "experienced_professional", durationDays: 2, access: "trial" });
assert.equal(
shouldRebaseCuratorSprintForOnboardingContext({
existingStartDate: "2026-07-14",
focusDate: "2026-07-15",
onboardingContextProvided: true,
existingCompatible: true,
}),
false,
"a normal sprint read with onboarding context must preserve the active sprint across days",
);
assert.equal(
shouldRebaseCuratorSprintForOnboardingContext({
existingStartDate: "2026-07-14",
focusDate: "2026-07-15",
onboardingContextProvided: true,
existingCompatible: false,
}),
true,
"changed onboarding compatibility may replace the existing sprint without resetting its calendar identity",
);
assert.notEqual(studentFingerprint, experiencedFingerprint, "ICP changes must change the compatibility fingerprint");
assert.equal(shouldReconcileCuratorSprint(
{ access: "trial", durationDays: 2, variantId: "student_recent_grad", planFingerprint: studentFingerprint },

View File

@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { INTERACTIVE_SERVICE_TIMEOUT_MS, serviceJson, roleplayService } from "../src/services/product-service-clients.js";
assert.equal(INTERACTIVE_SERVICE_TIMEOUT_MS, 240_000, "interactive service deadline must stay inside the 300s proxy budget");
const originalFetch = globalThis.fetch;
const originalTimeout = AbortSignal.timeout;
const requests: Request[] = [];
let capturedTimeout = 0;
AbortSignal.timeout = ((ms: number) => {
capturedTimeout = ms;
return new AbortController().signal;
}) as typeof AbortSignal.timeout;
globalThis.fetch = (async (input, init) => {
const request = new Request(input, init);
requests.push(request);
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" } });
}) as typeof fetch;
try {
await roleplayService.plan("session-1", "user-1");
assert.equal(new URL(requests[0]!.url).pathname, "/api/v1/roleplays/sessions/session-1/plan");
assert.equal(capturedTimeout, INTERACTIVE_SERVICE_TIMEOUT_MS, "interactive plan calls must use the inner deadline");
requests.length = 0;
capturedTimeout = 0;
await serviceJson("http://service.test", "/slow", { timeoutMs: 17 });
assert.equal(capturedTimeout, 17);
} finally {
AbortSignal.timeout = originalTimeout;
globalThis.fetch = originalFetch;
}
console.log("deadline hierarchy tests passed");

View File

@@ -0,0 +1,239 @@
import assert from "node:assert/strict";
import { buildHomeInsightSnapshots } from "../src/v1/analytics/home-insight-snapshot.js";
import type { HomeInsightProducerStatuses } 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);
let malformedDirectStatuses: HomeInsightProducerStatuses | null = null;
await buildHomeInsightSnapshots({
userId: "user_fixture",
qscore: readyQscore,
qscoreHistory: history,
recentEvents: [],
onProducerStatuses: (statuses) => { malformedDirectStatuses = statuses; },
}, {
...producers,
resumeState: async () => ({}),
assessments: async () => ({}),
});
assert.equal(malformedDirectStatuses?.resume.status, "unavailable", "malformed resume objects must not mark the direct source ready");
assert.equal(malformedDirectStatuses?.resume.reason, "producer_response_invalid");
assert.equal(malformedDirectStatuses?.assessment.status, "unavailable", "malformed assessment objects must not mark the direct source ready");
assert.equal(malformedDirectStatuses?.assessment.reason, "producer_response_invalid");
console.log("home insight snapshot contract: ok");

View File

@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { serviceRoutes } from "../src/routes/services.js";
const originalFetch = globalThis.fetch;
const requests: Request[] = [];
globalThis.fetch = (async (input, init) => {
const request = new Request(input, init);
requests.push(request);
return new Response(JSON.stringify({ entries: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
const app = serviceRoutes({ skipAuth: true });
try {
const leaderboard = await app.request("http://backend.test/interview/leaderboard?org_id=org-X&limit=100");
assert.equal(leaderboard.status, 200);
assert.deepEqual(await leaderboard.json(), { entries: [] });
const upstream = new URL(requests[0]!.url);
assert.equal(upstream.pathname, "/api/v1/leaderboard");
assert.equal(upstream.searchParams.get("limit"), "100");
assert.equal(upstream.searchParams.get("org_id"), "org-X");
assert.match(requests[0]!.headers.get("authorization") ?? "", /^Bearer /);
const clamped = await app.request("http://backend.test/interview/leaderboard?limit=101");
assert.equal(clamped.status, 200);
assert.equal(new URL(requests[1]!.url).searchParams.get("limit"), "100");
const defaulted = await app.request("http://backend.test/interview/leaderboard?limit=not-a-number");
assert.equal(defaulted.status, 200);
assert.equal(new URL(requests[2]!.url).searchParams.get("limit"), "10");
} finally {
globalThis.fetch = originalFetch;
}
console.log("interview leaderboard gateway: all assertions passed");

View File

@@ -12,9 +12,13 @@ globalThis.fetch = (async (input, init) => {
? { drafts: [{ session_id: "draft-1" }] }
: url.pathname.endsWith("/plan")
? { session_id: "draft-1", status: "draft" }
: url.pathname.endsWith("/configure/approve")
? { session_id: "draft-1", status: "configured", approved: true }
: { deleted: true, session_id: "draft-1" };
: url.pathname.endsWith("/video-usage")
? { used: 1, limit: 3, remaining: 2 }
: url.pathname.endsWith("/vip-check")
? { valid: true }
: url.pathname.endsWith("/configure/approve")
? { session_id: "draft-1", status: "configured", approved: true }
: { deleted: true, session_id: "draft-1" };
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
@@ -42,13 +46,16 @@ try {
assert.equal(deleted.status, 200);
assert.deepEqual(await deleted.json(), { deleted: true, session_id: "draft-1" });
assert.equal(requests.length, 4);
assert.match(requests[0]!.url, /\/api\/v1\/roleplays\/drafts\?user_id=user_test&limit=25$/);
assert.match(requests[1]!.url, /\/api\/v1\/roleplays\/sessions\/draft-1\/plan$/);
assert.match(requests[2]!.url, /\/api\/v1\/roleplays\/configure\/approve$/);
assert.equal(requests[3]!.method, "DELETE");
assert.match(requests[3]!.url, /\/api\/v1\/roleplays\/drafts\/draft-1$/);
for (const request of requests) {
const usage = await app.request("http://backend.test/roleplay/video-usage");
assert.equal(usage.status, 200);
assert.deepEqual(await usage.json(), { used: 1, limit: 3, remaining: 2 });
const vip = await app.request("http://backend.test/roleplay/vip-check?code=grow26");
assert.equal(vip.status, 200);
assert.deepEqual(await vip.json(), { valid: true });
assert.match(requests.find((request) => request.url.includes("/api/v1/roleplays/vip-check"))?.url ?? "", /code=grow26&user_id=user_test$/);
assert.match(requests.find((request) => request.url.includes("/api/v1/roleplays/video-usage"))?.url ?? "", /\/api\/v1\/roleplays\/video-usage\?user_id=user_test$/);
assert.ok(requests.length >= 5);
for (const request of requests.filter((request) => request.url.includes("/api/v1/"))) {
assert.equal(request.headers.get("x-growqr-user"), "user_test");
assert.match(request.headers.get("authorization") ?? "", /^Bearer /);
}

View File

@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { serviceRoutes } from "../src/routes/services.js";
const originalFetch = globalThis.fetch;
const requests: Request[] = [];
globalThis.fetch = (async (input, init) => {
const request = new Request(input, init);
requests.push(request);
return new Response(JSON.stringify({ entries: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
const app = serviceRoutes({ skipAuth: true });
try {
const leaderboard = await app.request("http://backend.test/roleplay/leaderboard?org_id=org-X&limit=100");
assert.equal(leaderboard.status, 200);
assert.deepEqual(await leaderboard.json(), { entries: [] });
const upstream = new URL(requests[0]!.url);
assert.equal(upstream.pathname, "/api/v1/roleplays/leaderboard");
assert.equal(upstream.searchParams.get("limit"), "100");
assert.equal(upstream.searchParams.get("org_id"), "org-X");
assert.match(requests[0]!.headers.get("authorization") ?? "", /^Bearer /);
const clamped = await app.request("http://backend.test/roleplay/leaderboard?limit=101");
assert.equal(clamped.status, 200);
assert.equal(new URL(requests[1]!.url).searchParams.get("limit"), "100");
const defaulted = await app.request("http://backend.test/roleplay/leaderboard?limit=not-a-number");
assert.equal(defaulted.status, 200);
assert.equal(new URL(requests[2]!.url).searchParams.get("limit"), "10");
} finally {
globalThis.fetch = originalFetch;
}
console.log("roleplay leaderboard gateway: all assertions passed");

View File

@@ -68,6 +68,33 @@ try {
assert.equal(requests.length, 0);
assert.deepEqual(suppliedPayload.qscore, { rq_score: 74, source: "caller" });
const curatorTaskId = "curator-sprint:icp-v10-static:2026-07-14:day-2:practice:roleplay";
const correlatedPayload = await buildPersonalizedRoleplayConfigurePayload(
new Request("http://backend.test/roleplay/preview"),
{
qscore: { rq_score: 74 },
metadata: {},
curatorTaskId,
taskId: curatorTaskId,
mission: {
source: "curator-v1",
missionId: "curator-sprint",
missionInstanceId: "curator-sprint:icp-v10-static:2026-07-14",
stageId: "wk-1:day-2:practice:roleplay",
},
},
clerkUserId,
{ resolveUserContext: async () => ({ clerk_id: clerkUserId }) },
);
assert.deepEqual(correlatedPayload.mission, {
source: "curator-v1",
missionId: "curator-sprint",
missionInstanceId: "curator-sprint:icp-v10-static:2026-07-14",
stageId: "wk-1:day-2:practice:roleplay",
});
assert.equal(correlatedPayload.curatorTaskId, curatorTaskId);
assert.equal(correlatedPayload.taskId, curatorTaskId);
console.log("roleplay QScore identity: all assertions passed");
} finally {
globalThis.fetch = originalFetch;

View File

@@ -677,7 +677,7 @@ export async function buildPersonalizedRoleplayConfigurePayload(
loadQscore?: typeof getQscoreFromService;
} = {},
): Promise<JsonObject> {
const { mission: _mission, ...rest } = body;
const { mission, ...rest } = body;
const userContext = await (dependencies.resolveUserContext ?? resolveGrowUserContext)(req, userId).catch((err) => {
log.warn({ err, userId }, "failed to resolve Grow user context for roleplay configure");
return {} as Record<string, unknown>;
@@ -713,6 +713,7 @@ export async function buildPersonalizedRoleplayConfigurePayload(
user_id: String(rest.user_id ?? userId),
org_id: orgId,
...(qscore ? { qscore } : {}),
...(isRecord(mission) ? { mission } : {}),
user_context: userContext,
};
}
@@ -787,6 +788,46 @@ async function proxySocialRequest(req: Request, rest: string, userId: string) {
});
}
async function streamServiceArtifact(input: { service: "interview" | "roleplay"; sessionId: string; artifactType: string; userId: string; request: Request }) {
const metadata = input.service === "interview"
? await interviewService.artifact(input.sessionId, input.artifactType, input.userId)
: await roleplayService.artifact(input.sessionId, input.artifactType, input.userId);
const url = isRecord(metadata) ? getString(metadata.url) : undefined;
if (!url) throw new HTTPException(502, { message: "artifact URL missing" });
const headers = new Headers({ accept: input.request.headers.get("accept") ?? "*/*" });
const range = input.request.headers.get("range");
if (range) headers.set("range", range);
const upstream = await fetch(url, { headers });
if (!upstream.ok && upstream.status !== 206) {
throw new HTTPException(upstream.status as never, { message: `artifact download failed: ${upstream.status}` });
}
const responseHeaders = new Headers();
for (const name of ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified"]) {
const value = upstream.headers.get(name);
if (value) responseHeaders.set(name, value);
}
if (!responseHeaders.has("content-type") && isRecord(metadata) && typeof metadata.mime_type === "string") responseHeaders.set("content-type", metadata.mime_type);
responseHeaders.set("content-disposition", `inline; filename="${input.artifactType}"`);
responseHeaders.set("cache-control", "private, max-age=300");
return new Response(upstream.body, { status: upstream.status, headers: responseHeaders });
}
async function proxyVideoUpload(input: { service: "interview" | "roleplay"; sessionId: string; userId: string; request: Request }) {
const payload = input.service === "interview"
? await interviewService.createVideoUploadUrl(input.sessionId, input.userId)
: await roleplayService.createVideoUploadUrl(input.sessionId, input.userId);
const url = isRecord(payload) ? getString(payload.url) : undefined;
if (!url) throw new HTTPException(502, { message: "video upload URL missing" });
const headers = new Headers();
const contentType = input.request.headers.get("content-type");
if (contentType) headers.set("content-type", contentType);
const contentLength = input.request.headers.get("content-length");
if (contentLength) headers.set("content-length", contentLength);
const upstream = await fetch(url, { method: "PUT", headers, body: input.request.body, duplex: "half" } as RequestInit & { duplex: "half" });
if (!upstream.ok) throw new HTTPException(upstream.status as never, { message: `video upload failed: ${upstream.status}` });
return new Response(null, { status: 204 });
}
export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
const app = new Hono<AuthContext>();
if (!options.skipAuth) app.use("*", requireUser);
@@ -914,6 +955,11 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
linkedin_available: hasLinkedInContext(userContext),
});
});
app.get("/interview/sessions", async (c) => {
const requested = Number(c.req.query("limit") ?? 100);
const limit = Math.min(100, Math.max(1, Number.isFinite(requested) ? Math.trunc(requested) : 100));
return c.json(await interviewService.listSessions(c.get("userId"), limit).catch(serviceErrorResponse));
});
app.post("/interview/configure", async (c) => {
const userId = c.get("userId");
const body = await c.req.json<JsonObject>();
@@ -968,8 +1014,13 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
}).catch((err) => log.warn({ err }, "failed to record interview review event"));
return c.json(result);
});
app.get("/interview/leaderboard", async (c) => c.json(await interviewService.leaderboard()));
app.get("/interview/leaderboard", async (c) => {
const requestedLimit = Number(c.req.query("limit"));
const limit = Number.isInteger(requestedLimit) ? Math.min(100, Math.max(1, requestedLimit)) : 10;
return c.json(await interviewService.leaderboard(c.req.query("org_id"), limit));
});
app.get("/interview/artifacts/:sessionId/:artifactType", async (c) => c.json(await interviewService.artifact(c.req.param("sessionId"), c.req.param("artifactType"), c.get("userId"))));
app.get("/interview/artifacts/:sessionId/:artifactType/download", async (c) => streamServiceArtifact({ service: "interview", sessionId: c.req.param("sessionId"), artifactType: c.req.param("artifactType"), userId: c.get("userId"), request: c.req.raw }));
app.post("/interview/sessions/:sessionId/ticket", async (c) => {
if (!config.sessionTicketSecret) throw new HTTPException(503, { message: "session ticket signing is not configured" });
const sessionId = c.req.param("sessionId");
@@ -979,6 +1030,7 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
return c.json({ ticket: issued.ticket, expiresAt: issued.expiresAt });
});
app.post("/interview/sessions/:sessionId/video/upload-url", async (c) => c.json(await interviewService.createVideoUploadUrl(c.req.param("sessionId"), c.get("userId"))));
app.put("/interview/sessions/:sessionId/video/upload", async (c) => proxyVideoUpload({ service: "interview", sessionId: c.req.param("sessionId"), userId: c.get("userId"), request: c.req.raw }));
app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId"), c.get("userId"))));
app.get("/roleplay/page-state", async (c) => {
@@ -996,6 +1048,11 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
linkedin_available: hasLinkedInContext(userContext),
});
});
app.get("/roleplay/sessions", async (c) => {
const requested = Number(c.req.query("limit") ?? 200);
const limit = Math.min(200, Math.max(1, Number.isFinite(requested) ? Math.trunc(requested) : 200));
return c.json(await roleplayService.listSessions(c.get("userId"), limit).catch(serviceErrorResponse));
});
app.post("/roleplay/configure", async (c) => {
const userId = c.get("userId");
const body = await c.req.json<JsonObject>();
@@ -1044,6 +1101,10 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
const limit = Math.min(200, Math.max(1, Number(c.req.query("limit") ?? 50) || 50));
return c.json(await roleplayService.drafts(c.get("userId"), limit).catch(serviceErrorResponse));
});
app.get("/roleplay/video-usage", async (c) =>
c.json(await roleplayService.videoUsage(c.get("userId")).catch(serviceErrorResponse)));
app.get("/roleplay/vip-check", async (c) =>
c.json(await roleplayService.vipCheck(c.req.query("code") ?? "", c.get("userId")).catch(serviceErrorResponse)));
app.delete("/roleplay/drafts/:sessionId", async (c) =>
c.json(await roleplayService.deleteDraft(c.req.param("sessionId"), c.get("userId")).catch(serviceErrorResponse)));
app.post("/roleplay/assignments", async (c) => c.json(await roleplayService.createAssignments(await c.req.json())));
@@ -1068,8 +1129,13 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
}).catch((err) => log.warn({ err }, "failed to record roleplay review event"));
return c.json(result);
});
app.get("/roleplay/leaderboard", async (c) => c.json(await roleplayService.leaderboard()));
app.get("/roleplay/leaderboard", async (c) => {
const requestedLimit = Number(c.req.query("limit"));
const limit = Number.isInteger(requestedLimit) ? Math.min(100, Math.max(1, requestedLimit)) : 10;
return c.json(await roleplayService.leaderboard(c.req.query("org_id"), limit));
});
app.get("/roleplay/artifacts/:sessionId/:artifactType", async (c) => c.json(await roleplayService.artifact(c.req.param("sessionId"), c.req.param("artifactType"), c.get("userId"))));
app.get("/roleplay/artifacts/:sessionId/:artifactType/download", async (c) => streamServiceArtifact({ service: "roleplay", sessionId: c.req.param("sessionId"), artifactType: c.req.param("artifactType"), userId: c.get("userId"), request: c.req.raw }));
app.post("/roleplay/sessions/:sessionId/ticket", async (c) => {
if (!config.sessionTicketSecret) throw new HTTPException(503, { message: "session ticket signing is not configured" });
const sessionId = c.req.param("sessionId");
@@ -1079,6 +1145,7 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
return c.json({ ticket: issued.ticket, expiresAt: issued.expiresAt });
});
app.post("/roleplay/sessions/:sessionId/video/upload-url", async (c) => c.json(await roleplayService.createVideoUploadUrl(c.req.param("sessionId"), c.get("userId"))));
app.put("/roleplay/sessions/:sessionId/video/upload", async (c) => proxyVideoUpload({ service: "roleplay", sessionId: c.req.param("sessionId"), userId: c.get("userId"), request: c.req.raw }));
app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"), c.get("userId"))));
app.get("/resume/state/:clerkId", async (c) => c.json(await resumeService.state(c.req.param("clerkId"))));

View File

@@ -21,8 +21,8 @@ export class ProductServiceError extends Error {
}
}
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);
export const DEFAULT_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_SERVICE_TIMEOUT_MS ?? 3500);
export const INTERACTIVE_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS ?? 240000);
function userHeader(userId?: string): Record<string, string> | undefined {
return userId ? { "x-growqr-user": userId } : undefined;
@@ -60,6 +60,10 @@ export const interviewService = {
serviceJson(config.interviewServiceUrl, `/api/v1/interviews/page-state?${new URLSearchParams({ user_id: userId })}`, {
headers: userHeader(userId),
}),
listSessions: (userId: string, limit = 100) =>
serviceJson(config.interviewServiceUrl, `/api/v1/sessions?${new URLSearchParams({ user_id: userId, limit: String(limit) })}`, {
headers: userHeader(userId),
}),
configure: (payload: JsonObject, userId?: string) =>
serviceJson(config.interviewServiceUrl, "/api/v1/configure", { body: payload, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
preview: (payload: JsonObject, userId?: string) =>
@@ -80,7 +84,11 @@ export const interviewService = {
serviceJson(config.interviewServiceUrl, `/api/v1/review/${encodeURIComponent(sessionId)}`, {
headers: userHeader(userId),
}),
leaderboard: () => serviceJson(config.interviewServiceUrl, "/api/v1/leaderboard"),
leaderboard: (orgId?: string, limit = 10) =>
serviceJson(config.interviewServiceUrl, `/api/v1/leaderboard?${new URLSearchParams({
...(orgId ? { org_id: orgId } : {}),
limit: String(limit),
})}`),
artifact: (sessionId: string, artifactType: string, userId?: string) =>
serviceJson(config.interviewServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`, {
headers: userHeader(userId),
@@ -109,6 +117,10 @@ export const roleplayService = {
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/page-state?${new URLSearchParams({ user_id: userId })}`, {
headers: userHeader(userId),
}),
listSessions: (userId: string, limit = 200) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/sessions?${new URLSearchParams({ user_id: userId, limit: String(limit) })}`, {
headers: userHeader(userId),
}),
configure: (payload: JsonObject, userId?: string) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure", { body: payload, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
preview: (payload: JsonObject, userId?: string) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/preview", { body: payload, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
editQuestions: (payload: { session_id: string; questions: Array<JsonObject | string> }, userId?: string) =>
@@ -118,6 +130,7 @@ export const roleplayService = {
plan: (sessionId: string, userId?: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/sessions/${encodeURIComponent(sessionId)}/plan`, {
headers: userHeader(userId),
timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS,
}),
drafts: (userId: string, limit = 50) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/drafts?${new URLSearchParams({ user_id: userId, limit: String(limit) })}`, {
@@ -140,7 +153,19 @@ export const roleplayService = {
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/review/${encodeURIComponent(sessionId)}`, {
headers: userHeader(userId),
}),
leaderboard: () => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/leaderboard"),
leaderboard: (orgId?: string, limit = 10) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/leaderboard?${new URLSearchParams({
...(orgId ? { org_id: orgId } : {}),
limit: String(limit),
})}`),
videoUsage: (userId: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/video-usage?${new URLSearchParams({ user_id: userId })}`, {
headers: userHeader(userId),
}),
vipCheck: (code: string, userId: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/vip-check?${new URLSearchParams({ code, user_id: userId })}`, {
headers: userHeader(userId),
}),
artifact: (sessionId: string, artifactType: string, userId?: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`, {
headers: userHeader(userId),
@@ -167,6 +192,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 = {

View File

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

View File

@@ -8,3 +8,111 @@ 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.
## Career report data contract
`GET /v1/analytics/career-report` is an authenticated, read-only aggregation
for the dashboard-owned fixed PDF template. It returns
`contractVersion: "career-report-v1"` with these additive top-level fields:
- `subject`: canonical user-service profile and onboarding values.
- `rqScore`: the current score, producer history, and a strictly validated
`rq-score-display-v1` projection.
- `snapshots`: the existing nine Home Insight producer projections.
- `activity`: real GrowEvents counts for the previous 14 days.
- `sources`: status and timestamp metadata for every queried producer.
Every field is status-bearing. `ready` carries a real producer value; `empty`
means a successful read confirmed no value or an empty array/count;
`unavailable` means the producer could not be read; and `unsupported` means the
expected producer contract is absent. Errors and missing integrations never
become numeric zeroes.
Wrongly typed onboarding values and malformed canonical RQ display payloads
are `unavailable`, never `empty` or `ready`. RQ display validation enforces the
exact nine categories in producer order, all 67 uniquely assigned signals,
and bounded scores, weights, and counts. Activity totals and service buckets
are aggregated across the complete 14-day database window; the bounded recent
event sample is used only as supporting snapshot evidence.
The endpoint deliberately does not calculate labels, rankings, strongest or
weakest categories, recommendations, timelines, peer/market benchmarks,
profile scores, percentages, or narrative prose. It creates no database rows,
jobs, or stored reports. PDF layout and fixed explanatory labels remain owned
by the dashboard.
Profile identity is read strictly from user-service `/api/v1/users/me` using
the authenticated request. The backend-mirror profile fallback is not used,
so internal user ids or synthetic service-local email addresses cannot leak
into the report. No email, token, link, secret, or internal credential is
included in the response.

View File

@@ -7,7 +7,22 @@ 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 { fetchUserProfile } from "../../services/user-profile.js";
import {
buildHomeInsightSnapshots,
type HomeInsightProducerStatuses,
} from "./home-insight-snapshot.js";
import {
buildCareerReportProjection,
type CareerReportActivity,
type CareerReportProfileRead,
} from "./career-report.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
@@ -45,10 +60,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 +98,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",
@@ -130,6 +157,74 @@ export function v1AnalyticsRoutes() {
return c.json(response);
});
app.get("/career-report", async (c) => {
const userId = c.get("userId");
const profilePromise: Promise<CareerReportProfileRead> = fetchUserProfile(c.req.raw)
.then((data) => isRecord(data)
? { status: "ready" as const, data }
: { status: "unavailable" as const, reason: "profile_response_invalid" as const })
.catch(() => ({ status: "unavailable" as const, reason: "profile_unavailable" as const }));
const [qscoreRead, qscoreHistory, profile, recentEvents, counts, sourceCounts] = await Promise.all([
readQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID),
readQscoreHistoryFromService(userId, DEFAULT_QSCORE_ORG_ID),
profilePromise,
db
.select()
.from(growEvents)
.where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14))))
.orderBy(desc(growEvents.occurredAt))
.limit(100),
db
.select({
total: sql<number>`count(*)::int`,
completed: sql<number>`count(*) filter (where ${growEvents.type} ilike '%completed%' or ${growEvents.type} ilike '%review_completed%')::int`,
opened: sql<number>`count(*) filter (where ${growEvents.type} = 'task.opened' or ${growEvents.type} ilike '%started%')::int`,
})
.from(growEvents)
.where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14)))),
db
.select({
source: growEvents.source,
count: sql<number>`count(*)::int`,
})
.from(growEvents)
.where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14))))
.groupBy(growEvents.source),
]);
let producerStatuses: HomeInsightProducerStatuses | null = null;
const snapshots = await buildHomeInsightSnapshots({
userId,
qscore: qscoreRead,
qscoreHistory,
recentEvents,
onProducerStatuses: (statuses) => { producerStatuses = statuses; },
});
if (!producerStatuses) throw new Error("career report producer status collection failed");
const serviceCounts = new Map<string, number>();
for (const row of sourceCounts) {
const bucket = sourceBucket(row.source);
serviceCounts.set(bucket, (serviceCounts.get(bucket) ?? 0) + row.count);
}
const aggregate = counts[0];
const activity: CareerReportActivity = {
windowDays: 14,
totalEvents: aggregate?.total ?? 0,
completedEvents: aggregate?.completed ?? 0,
openedEvents: aggregate?.opened ?? 0,
services: Array.from(serviceCounts.entries()).map(([service, count]) => ({ service, count })),
opportunityEvents: serviceCounts.get("opportunities") ?? 0,
};
return c.json(buildCareerReportProjection({
profile,
qscore: qscoreRead,
qscoreHistory,
snapshots,
producerStatuses,
activity,
}));
});
app.get("/activity-history", async (c) => {
const userId = c.get("userId");
const limit = Math.min(200, Math.max(1, Number(c.req.query("limit") ?? 80)));

View File

@@ -0,0 +1,397 @@
import type {
HomeInsightProducerStatuses,
HomeInsightSnapshots,
InsightSnapshotStatus,
} from "./home-insight-snapshot.js";
import type {
QscoreHistoryPoint,
QscoreProxyResult,
QscoreReadResult,
} from "../../services/qscore-proxy.js";
export type CareerReportStatus = InsightSnapshotStatus;
export type CareerReportField<T> = {
status: CareerReportStatus;
source: string;
value: T | null;
updatedAt: string | null;
reason: string | null;
};
export type CareerReportSignal = {
id: string;
label: string;
present: boolean;
score: number;
};
export type CareerReportCategory = {
id: string;
label: string;
inputIds: string[];
weightPercent: number;
score: number;
active: boolean;
presentRules: number;
totalRules: number;
signals: CareerReportSignal[];
};
export type CareerReportDisplay = {
version: "rq-score-display-v1";
registeredSignalCount: number;
activeSignalCount: number;
categories: CareerReportCategory[];
};
export type CareerReportActivity = {
windowDays: 14;
totalEvents: number;
completedEvents: number;
openedEvents: number;
services: Array<{ service: string; count: number }>;
opportunityEvents: number;
};
export type CareerReportSourceId =
| "profile"
| "onboarding"
| "qscore"
| "resume"
| "linkedin"
| "interview"
| "roleplay"
| "matchmaking"
| "courses"
| "assessment"
| "grow_events";
export type CareerReportSource = {
id: CareerReportSourceId;
label: string;
status: CareerReportStatus;
updatedAt: string | null;
reason: string | null;
};
export type CareerReportProfileRead =
| { status: "ready"; data: Record<string, unknown> }
| { status: "unavailable"; reason: "profile_unavailable" | "profile_response_invalid" };
export type CareerReportResponse = {
contractVersion: "career-report-v1";
generatedAt: string;
subject: {
displayName: CareerReportField<string>;
avatarUrl: CareerReportField<string>;
targetRole: CareerReportField<string>;
targetField: CareerReportField<string>;
experienceLevel: CareerReportField<string>;
currentSituation: CareerReportField<string>;
goals: CareerReportField<string[]>;
barriers: CareerReportField<string[]>;
};
rqScore: {
score: CareerReportField<number>;
history: CareerReportField<Array<{ date: string; score: number }>>;
display: CareerReportField<CareerReportDisplay>;
};
snapshots: HomeInsightSnapshots;
activity: CareerReportField<CareerReportActivity>;
sources: CareerReportSource[];
};
const CANONICAL_CATEGORY_SPECS = [
["learning_credentials", "Learning & Credentials"],
["resume_presence", "Resume Presence"],
["linkedin_presence", "LinkedIn Presence"],
["matching_activity", "Matching Activity"],
["interview_performance", "Interview Performance"],
["pathway_progress", "Pathway Progress"],
["roleplay_performance", "Roleplay Performance"],
["engagement_consistency", "Engagement & Consistency"],
["goals_readiness", "Goals & Readiness"],
] as const;
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 nonNegativeInteger(value: unknown): number | null {
const number = finiteNumber(value);
return number !== null && Number.isInteger(number) && number >= 0 ? number : null;
}
function boundedNumber(value: unknown, minimum: number, maximum: number): number | null {
const number = finiteNumber(value);
return number !== null && number >= minimum && number <= maximum ? number : null;
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function stringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const items = value.flatMap((item) => {
const parsed = stringValue(item);
return parsed ? [parsed] : [];
});
return items.length === value.length ? items : null;
}
function readyField<T>(source: string, value: T, updatedAt: string | null = null): CareerReportField<T> {
return { status: "ready", source, value, updatedAt, reason: null };
}
function emptyField<T>(source: string, value: T | null, reason: string): CareerReportField<T> {
return { status: "empty", source, value, updatedAt: null, reason };
}
function unavailableField<T>(source: string, reason: string): CareerReportField<T> {
return { status: "unavailable", source, value: null, updatedAt: null, reason };
}
function unsupportedField<T>(source: string, reason: string, updatedAt: string | null = null): CareerReportField<T> {
return { status: "unsupported", source, value: null, updatedAt, reason };
}
function scalarField(source: string, value: unknown, updatedAt: string | null = null): CareerReportField<string> {
if (value === undefined || value === null || (typeof value === "string" && !value.trim())) {
return { ...emptyField(source, null, "field_not_present"), updatedAt };
}
const parsed = stringValue(value);
return parsed
? readyField(source, parsed, updatedAt)
: { ...unavailableField<string>(source, "producer_response_invalid"), updatedAt };
}
function arrayField(source: string, value: unknown, updatedAt: string | null = null): CareerReportField<string[]> {
if (value === undefined || value === null) {
return { ...emptyField(source, [], "field_not_present"), updatedAt };
}
const parsed = stringArray(value);
if (parsed === null) return { ...unavailableField<string[]>(source, "producer_response_invalid"), updatedAt };
return parsed.length ? readyField(source, parsed, updatedAt) : { ...emptyField(source, [], "no_values"), updatedAt };
}
function displayName(profile: Record<string, unknown>): string | null {
const parts = [stringValue(profile.first_name ?? profile.firstName), stringValue(profile.last_name ?? profile.lastName)].filter(
(value): value is string => !!value,
);
return parts.length ? parts.join(" ") : null;
}
function avatarUrl(profile: Record<string, unknown>): string | null {
return stringValue(
profile.avatar_url ??
profile.avatarUrl ??
profile.image_url ??
profile.imageUrl ??
profile.profile_image_url ??
profile.profileImageUrl,
);
}
function parseSignal(value: unknown): CareerReportSignal | null {
if (!isRecord(value)) return null;
const id = stringValue(value.id);
const label = stringValue(value.label);
const score = boundedNumber(value.score, 0, 100);
if (!id || !label || score === null || typeof value.present !== "boolean") return null;
return { id, label, present: value.present, score };
}
function parseCategory(value: unknown): CareerReportCategory | null {
if (!isRecord(value)) return null;
const id = stringValue(value.id);
const label = stringValue(value.label);
const inputIds = stringArray(value.input_ids);
const weightPercent = boundedNumber(value.weight_percent, 0, 100);
const score = boundedNumber(value.score, 0, 100);
const presentRules = nonNegativeInteger(value.present_rules);
const totalRules = nonNegativeInteger(value.total_rules);
if (
!id ||
!label ||
inputIds === null ||
weightPercent === null ||
score === null ||
presentRules === null ||
totalRules === null ||
typeof value.active !== "boolean" ||
!Array.isArray(value.signals)
) return null;
const signals = value.signals.map(parseSignal);
if (signals.some((signal) => signal === null)) return null;
const parsedSignals = signals as CareerReportSignal[];
if (
totalRules > 67 ||
presentRules > totalRules ||
parsedSignals.length !== totalRules ||
parsedSignals.filter((signal) => signal.present).length !== presentRules ||
value.active !== (presentRules > 0) ||
new Set(parsedSignals.map((signal) => signal.id)).size !== parsedSignals.length
) return null;
return { id, label, inputIds, weightPercent, score, active: value.active, presentRules, totalRules, signals: parsedSignals };
}
/** Strictly validates the producer-owned canonical display contract without recalculating it. */
export function parseCareerReportDisplay(value: unknown): CareerReportDisplay | null {
if (!isRecord(value) || value.version !== "rq-score-display-v1" || !Array.isArray(value.categories)) return null;
const registeredSignalCount = nonNegativeInteger(value.registered_signal_count);
const activeSignalCount = nonNegativeInteger(value.active_signal_count);
const categories = value.categories.map(parseCategory);
if (registeredSignalCount === null || activeSignalCount === null || categories.some((category) => category === null)) return null;
const parsedCategories = categories as CareerReportCategory[];
const signals = parsedCategories.flatMap((category) => category.signals);
if (
parsedCategories.length !== CANONICAL_CATEGORY_SPECS.length ||
registeredSignalCount !== 67 ||
activeSignalCount > registeredSignalCount ||
signals.length !== registeredSignalCount ||
signals.filter((signal) => signal.present).length !== activeSignalCount ||
new Set(parsedCategories.map((category) => category.id)).size !== parsedCategories.length ||
new Set(signals.map((signal) => signal.id)).size !== signals.length
) return null;
for (const [index, [expectedId, expectedLabel]] of CANONICAL_CATEGORY_SPECS.entries()) {
const category = parsedCategories[index];
if (!category || category.id !== expectedId || category.label !== expectedLabel) return null;
}
const inputIds = parsedCategories.flatMap((category) => category.inputIds);
if (new Set(inputIds).size !== inputIds.length) return null;
return { version: "rq-score-display-v1", registeredSignalCount, activeSignalCount, categories: parsedCategories };
}
function sourceFromProducer(
id: CareerReportSourceId,
label: string,
producer: HomeInsightProducerStatuses[keyof HomeInsightProducerStatuses],
updatedAt: string | null,
): CareerReportSource {
return { id, label, status: producer.status, updatedAt, reason: producer.reason };
}
function sourceStatus(fields: Array<CareerReportField<unknown>>): CareerReportStatus {
if (fields.some((field) => field.status === "ready")) return "ready";
if (fields.some((field) => field.status === "unavailable")) return "unavailable";
if (fields.some((field) => field.status === "unsupported")) return "unsupported";
return "empty";
}
function sourceReason(fields: Array<CareerReportField<unknown>>): string | null {
const status = sourceStatus(fields);
return status === "ready" ? null : fields.find((field) => field.status === status)?.reason ?? null;
}
export function buildCareerReportProjection(input: {
generatedAt?: string;
profile: CareerReportProfileRead;
qscore: QscoreReadResult<QscoreProxyResult>;
qscoreHistory: QscoreReadResult<QscoreHistoryPoint[]>;
snapshots: HomeInsightSnapshots;
producerStatuses: HomeInsightProducerStatuses;
activity: CareerReportActivity;
}): CareerReportResponse {
const generatedAt = input.generatedAt ?? new Date().toISOString();
const profileSource = "user-service.profile";
const onboardingSource = "user-service.preferences.onboarding";
let displayNameField: CareerReportField<string>;
let avatarUrlField: CareerReportField<string>;
let targetRole: CareerReportField<string>;
let targetField: CareerReportField<string>;
let experienceLevel: CareerReportField<string>;
let currentSituation: CareerReportField<string>;
let goals: CareerReportField<string[]>;
let barriers: CareerReportField<string[]>;
if (input.profile.status === "unavailable") {
displayNameField = unavailableField(profileSource, input.profile.reason);
avatarUrlField = unavailableField(profileSource, input.profile.reason);
targetRole = unavailableField(onboardingSource, input.profile.reason);
targetField = unavailableField(onboardingSource, input.profile.reason);
experienceLevel = unavailableField(onboardingSource, input.profile.reason);
currentSituation = unavailableField(onboardingSource, input.profile.reason);
goals = unavailableField(onboardingSource, input.profile.reason);
barriers = unavailableField(onboardingSource, input.profile.reason);
} else {
const profileUpdatedAt = stringValue(input.profile.data.updated_at ?? input.profile.data.updatedAt);
displayNameField = scalarField(profileSource, displayName(input.profile.data), profileUpdatedAt);
avatarUrlField = scalarField(profileSource, avatarUrl(input.profile.data), profileUpdatedAt);
const preferences = isRecord(input.profile.data.preferences) ? input.profile.data.preferences : {};
const onboarding = isRecord(preferences.onboarding) ? preferences.onboarding : {};
const responses = isRecord(onboarding.responses) ? onboarding.responses : {};
const progress = isRecord(onboarding.progress) ? onboarding.progress : {};
const onboardingUpdatedAt = stringValue(progress.updated_at ?? onboarding.completed_at);
targetRole = scalarField(onboardingSource, responses.target_role, onboardingUpdatedAt);
targetField = scalarField(onboardingSource, responses.target_field, onboardingUpdatedAt);
experienceLevel = scalarField(onboardingSource, responses.experience_level, onboardingUpdatedAt);
currentSituation = scalarField(onboardingSource, responses.current_situation, onboardingUpdatedAt);
goals = arrayField(onboardingSource, responses.desired_outcomes, onboardingUpdatedAt);
barriers = arrayField(onboardingSource, responses.career_barriers, onboardingUpdatedAt);
}
let qscoreScore: CareerReportField<number>;
let qscoreDisplay: CareerReportField<CareerReportDisplay>;
if (input.qscore.status === "ready") {
qscoreScore = readyField("qscore.current", input.qscore.data.rq_score, input.qscore.data.calculated_at || null);
const rawDisplay = input.qscore.data.breakdown.display;
if (rawDisplay === undefined || rawDisplay === null) {
qscoreDisplay = unsupportedField("qscore.breakdown.display", "display_contract_missing", input.qscore.data.calculated_at || null);
} else {
const parsedDisplay = parseCareerReportDisplay(rawDisplay);
qscoreDisplay = parsedDisplay
? readyField("qscore.breakdown.display", parsedDisplay, input.qscore.data.calculated_at || null)
: { ...unavailableField<CareerReportDisplay>("qscore.breakdown.display", "display_contract_invalid"), updatedAt: input.qscore.data.calculated_at || null };
}
} else if (input.qscore.status === "empty") {
qscoreScore = emptyField<number>("qscore.current", null, "no_qscore_run");
qscoreDisplay = emptyField<CareerReportDisplay>("qscore.breakdown.display", null, "no_qscore_run");
} else {
qscoreScore = unavailableField("qscore.current", input.qscore.reason);
qscoreDisplay = unavailableField("qscore.breakdown.display", input.qscore.reason);
}
let history: CareerReportField<Array<{ date: string; score: number }>>;
if (input.qscoreHistory.status === "ready") {
const points = input.qscoreHistory.data.map((point) => ({ date: point.date, score: point.rq_score }));
history = points.length ? readyField("qscore.history", points) : emptyField("qscore.history", [], "no_history_points");
} else if (input.qscoreHistory.status === "empty") {
history = emptyField("qscore.history", [], "no_qscore_history");
} else {
history = unavailableField("qscore.history", input.qscoreHistory.reason);
}
const profileFields: Array<CareerReportField<unknown>> = [displayNameField, avatarUrlField];
const onboardingFields: Array<CareerReportField<unknown>> = [targetRole, targetField, experienceLevel, currentSituation, goals, barriers];
const activity = readyField("grow_events", input.activity, generatedAt);
const sources: CareerReportSource[] = [
{ id: "profile", label: "Profile", status: sourceStatus(profileFields), updatedAt: displayNameField.updatedAt ?? avatarUrlField.updatedAt, reason: sourceReason(profileFields) },
{ id: "onboarding", label: "Onboarding", status: sourceStatus(onboardingFields), updatedAt: targetRole.updatedAt, reason: sourceReason(onboardingFields) },
{ id: "qscore", label: "RQ Score", status: input.qscore.status === "ready" ? "ready" : input.qscore.status, updatedAt: qscoreScore.updatedAt, reason: qscoreScore.reason },
sourceFromProducer("resume", "Resume", input.producerStatuses.resume, input.snapshots.resume.source === "resume.state" ? input.snapshots.resume.updatedAt : null),
sourceFromProducer("linkedin", "LinkedIn", input.producerStatuses.linkedin, input.snapshots.socialBrand.source === "social.state" ? input.snapshots.socialBrand.updatedAt : null),
sourceFromProducer("interview", "Interview", input.producerStatuses.interview, input.snapshots.interview.source === "interview.page-state" ? input.snapshots.interview.updatedAt : null),
sourceFromProducer("roleplay", "Roleplay", input.producerStatuses.roleplay, input.snapshots.roleplay.source === "roleplay.page-state" ? input.snapshots.roleplay.updatedAt : null),
sourceFromProducer("matchmaking", "Matchmaking", input.producerStatuses.matchmaking, input.snapshots.opportunity.source === "matchmaking.scout-stats" ? input.snapshots.opportunity.updatedAt : null),
sourceFromProducer("courses", "Courses", input.producerStatuses.courses, input.snapshots.learning.source === "courses.course-state" ? input.snapshots.learning.updatedAt : null),
sourceFromProducer("assessment", "Assessment", input.producerStatuses.assessment, input.snapshots.skillGap.source === "assessment.list" ? input.snapshots.skillGap.updatedAt : null),
{ id: "grow_events", label: "Grow events", status: "ready", updatedAt: generatedAt, reason: null },
];
return {
contractVersion: "career-report-v1",
generatedAt,
subject: { displayName: displayNameField, avatarUrl: avatarUrlField, targetRole, targetField, experienceLevel, currentSituation, goals, barriers },
rqScore: { score: qscoreScore, history, display: qscoreDisplay },
snapshots: input.snapshots,
activity,
sources,
};
}

View File

@@ -0,0 +1,602 @@
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" };
export type HomeInsightProducerStatus = {
status: "ready" | "unavailable";
reason: "producer_unavailable" | "producer_response_invalid" | null;
};
export type HomeInsightProducerStatuses = {
interview: HomeInsightProducerStatus;
roleplay: HomeInsightProducerStatus;
resume: HomeInsightProducerStatus;
linkedin: HomeInsightProducerStatus;
matchmaking: HomeInsightProducerStatus;
courses: HomeInsightProducerStatus;
assessment: HomeInsightProducerStatus;
};
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 resumeStateData(read: ProducerRead): ProducerRead {
if (read.status === "unavailable") return read;
const count = finiteCount(read.data.resume_count);
const completeness = read.data.resume_completeness;
const status = read.data.resume_status;
const completenessValid = completeness === undefined || completeness === null || (
finiteNumber(completeness) !== null && Number(completeness) >= 0 && Number(completeness) <= 100
);
const statusValid = status === undefined || status === null || text(status) !== null;
if (count === null || !completenessValid || !statusValid || (count > 0 && finiteNumber(completeness) === null)) {
return { status: "unavailable", reason: "producer_response_invalid" };
}
return read;
}
function assessmentStateData(read: ProducerRead): ProducerRead {
if (read.status === "unavailable") return read;
return Array.isArray(read.data.items) && finiteCount(read.data.total) !== null
? 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 producerStatus(read: ProducerRead): HomeInsightProducerStatus {
return read.status === "ready"
? { status: "ready", reason: null }
: { status: "unavailable", reason: read.reason };
}
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[];
onProducerStatuses?: (statuses: HomeInsightProducerStatuses) => void;
}, dependencies: ProducerDependencies = defaultDependencies): Promise<HomeInsightSnapshots> {
const [interviewRaw, roleplayRaw, resumeRaw, socialRaw, matchmakingRaw, courseRaw, assessmentRaw] = 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 resumeRead = resumeStateData(resumeRaw);
const socialRead = socialStateData(socialRaw);
const assessmentRead = assessmentStateData(assessmentRaw);
const matchmakingRead = a2aData(matchmakingRaw, "scout_stats");
const courseRead = a2aData(courseRaw, "course_state_loaded");
input.onProducerStatuses?.({
interview: producerStatus(interviewRead),
roleplay: producerStatus(roleplayRead),
resume: producerStatus(resumeRead),
linkedin: producerStatus(socialRead),
matchmaking: producerStatus(matchmakingRead),
courses: producerStatus(courseRead),
assessment: producerStatus(assessmentRead),
});
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");
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");
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 };
}

View File

@@ -141,6 +141,20 @@ export function decideCuratorSprintReconciliation(
};
}
export function shouldRebaseCuratorSprintForOnboardingContext(input: {
existingStartDate?: string;
focusDate: string;
onboardingContextProvided: boolean;
existingCompatible: boolean;
}) {
return Boolean(
input.onboardingContextProvided
&& input.existingStartDate
&& input.existingStartDate !== input.focusDate
&& !input.existingCompatible,
);
}
function accessChoiceFromContext(context: Record<string, unknown> | undefined): "trial" | "full" | undefined {
const preferences = context && context.preferences && typeof context.preferences === "object" && !Array.isArray(context.preferences)
? context.preferences as Record<string, unknown>
@@ -751,7 +765,16 @@ async function loadSprintState(
const existingDuration = latestPayload?.durationDays === 7 ? 7 : latestPayload?.durationDays === 2 ? 2 : undefined;
const existingAccess = latestPayload?.access === "full" ? "full" : latestPayload?.access === "trial" ? "trial" : undefined;
const existingFingerprint = typeof latestPayload?.planFingerprint === "string" ? latestPayload.planFingerprint : undefined;
const rebaseExistingForCompletion = Boolean(onboardingContextOverride && existingStartDate && existingStartDate !== todayDate);
const existingCompatible = Boolean(
existingStartDate && existingVersion === CURATOR_PLAN_VERSION && existingVariant
&& existingDuration === desired.durationDays && existingAccess === desired.access,
);
const rebaseExistingForCompletion = shouldRebaseCuratorSprintForOnboardingContext({
existingStartDate,
focusDate: todayDate,
onboardingContextProvided: Boolean(onboardingContextOverride),
existingCompatible,
});
const reuseDecision = existingStartDate && existingVersion && existingDuration && existingAccess
? resolveAgedTrialReuse({
existing: { startDate: existingStartDate, version: existingVersion, durationDays: existingDuration, access: existingAccess },