Compare commits
11 Commits
backup/sta
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bade06255 | ||
| ce5be53b96 | |||
|
|
fcbb93ca6a | ||
|
|
461b893fdc | ||
|
|
712a4e57f3 | ||
| d857b60111 | |||
|
|
171495f1e1 | ||
|
|
7caf05f4f5 | ||
|
|
15eb7e033c | ||
|
|
63c9c18494 | ||
|
|
d4332f9f8b |
@@ -13,6 +13,8 @@
|
||||
"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",
|
||||
|
||||
264
scripts/career-report.test.ts
Normal file
264
scripts/career-report.test.ts
Normal 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");
|
||||
@@ -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 },
|
||||
|
||||
32
scripts/deadline-hierarchy.test.ts
Normal file
32
scripts/deadline-hierarchy.test.ts
Normal 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");
|
||||
239
scripts/home-insight-snapshot.test.ts
Normal file
239
scripts/home-insight-snapshot.test.ts
Normal 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");
|
||||
@@ -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 /);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1044,6 +1045,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())));
|
||||
|
||||
@@ -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;
|
||||
@@ -118,6 +118,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) })}`, {
|
||||
@@ -141,6 +142,14 @@ export const roleplayService = {
|
||||
headers: userHeader(userId),
|
||||
}),
|
||||
leaderboard: () => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/leaderboard"),
|
||||
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 +176,44 @@ export const courseService = {
|
||||
health: () => serviceJson(config.coursesServiceUrl, "/health"),
|
||||
task: (payload: { action?: string; params?: JsonObject; user_id: string; user_context?: JsonObject; session_start?: boolean; task_id?: string }) =>
|
||||
serviceJson(config.coursesServiceUrl, "/a2a/tasks", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
summaryState: (userId: string) =>
|
||||
serviceJson(config.coursesServiceUrl, "/a2a/tasks", {
|
||||
body: { user_id: userId, session_start: true, params: { page: "summary" } },
|
||||
timeoutMs: DEFAULT_SERVICE_TIMEOUT_MS,
|
||||
}),
|
||||
};
|
||||
|
||||
export const assessmentService = {
|
||||
health: () => serviceJson(config.assessmentServiceUrl, "/api/v1/health"),
|
||||
listForUser: (userId: string, limit = 50) =>
|
||||
serviceJson(config.assessmentServiceUrl, `/api/v1/assessments?${new URLSearchParams({
|
||||
user_id: userId,
|
||||
limit: String(limit),
|
||||
})}`, {
|
||||
headers: userHeader(userId),
|
||||
}),
|
||||
};
|
||||
|
||||
export const matchmakingService = {
|
||||
health: () => serviceJson(config.matchmakingServiceUrl, "/api/v1/health"),
|
||||
task: (payload: { action: string; params?: JsonObject; user_id: string; user_context?: JsonObject }) =>
|
||||
serviceJson(config.matchmakingServiceUrl, "/a2a/tasks", {
|
||||
body: payload,
|
||||
timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS,
|
||||
}),
|
||||
scoutStats: (userId: string, qscoreUserId: string) =>
|
||||
serviceJson(config.matchmakingServiceUrl, "/a2a/tasks", {
|
||||
body: { action: "get_scout_stats", user_id: userId, params: { user_uuid: qscoreUserId } },
|
||||
timeoutMs: DEFAULT_SERVICE_TIMEOUT_MS,
|
||||
}),
|
||||
};
|
||||
|
||||
export const socialBrandingService = {
|
||||
health: () => serviceJson(config.socialBrandingServiceUrl, "/healthz"),
|
||||
state: (clerkId: string) =>
|
||||
serviceJson(config.socialBrandingServiceUrl, `/api/state/${encodeURIComponent(clerkId)}`, {
|
||||
headers: userHeader(clerkId),
|
||||
}),
|
||||
};
|
||||
|
||||
export const resumeService = {
|
||||
|
||||
@@ -54,6 +54,16 @@ export type QscoreServiceResult = {
|
||||
*/
|
||||
export type QscoreProxyResult = QscoreServiceResult;
|
||||
|
||||
export type QscoreReadResult<T> =
|
||||
| { status: "ready"; data: T }
|
||||
| { status: "empty"; reason: "not_found" }
|
||||
| { status: "unavailable"; reason: "request_failed" | "invalid_response" | `http_${number}` };
|
||||
|
||||
export type QscoreHistoryPoint = {
|
||||
rq_score: number;
|
||||
date: string;
|
||||
};
|
||||
|
||||
export type QscoreLatestSignal = {
|
||||
signal_id: string;
|
||||
source: string | null;
|
||||
@@ -117,6 +127,16 @@ export async function getQscoreFromService(
|
||||
orgId: string = DEFAULT_QSCORE_ORG_ID,
|
||||
timeoutMs: number = QSCORE_PROXY_TIMEOUT_MS,
|
||||
): Promise<QscoreProxyResult | null> {
|
||||
const result = await readQscoreFromService(userId, orgId, timeoutMs);
|
||||
return result.status === "ready" ? result.data : null;
|
||||
}
|
||||
|
||||
/** Status-bearing score read for callers that must not collapse absence and outage. */
|
||||
export async function readQscoreFromService(
|
||||
userId: string,
|
||||
orgId: string = DEFAULT_QSCORE_ORG_ID,
|
||||
timeoutMs: number = QSCORE_PROXY_TIMEOUT_MS,
|
||||
): Promise<QscoreReadResult<QscoreProxyResult>> {
|
||||
const qscoreUserId = toQscoreUserId(userId);
|
||||
const path = `/v1/qscore/${encodeURIComponent(qscoreUserId)}?org_id=${encodeURIComponent(orgId)}`;
|
||||
const url = `${config.qscoreServiceUrl.replace(/\/$/, "")}${path}`;
|
||||
@@ -130,21 +150,64 @@ export async function getQscoreFromService(
|
||||
},
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
// 404 = no run computed yet for this user → treat as null (graceful).
|
||||
if (res.status === 404) return null;
|
||||
// A missing run is distinct from an unavailable service for status-aware callers.
|
||||
if (res.status === 404) return { status: "empty", reason: "not_found" };
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
log.warn(
|
||||
{ status: res.status, body: text, userId, orgId },
|
||||
{ status: res.status, userId, orgId },
|
||||
"qscore_service returned non-success status",
|
||||
);
|
||||
return null;
|
||||
return { status: "unavailable", reason: `http_${res.status}` };
|
||||
}
|
||||
const body = await res.json();
|
||||
return parseResult(body);
|
||||
try {
|
||||
return { status: "ready", data: parseResult(body) };
|
||||
} catch (err) {
|
||||
log.warn({ err, userId, orgId }, "qscore_service returned invalid response");
|
||||
return { status: "unavailable", reason: "invalid_response" };
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ err, userId, orgId }, "qscore_service proxy call failed");
|
||||
return null;
|
||||
return { status: "unavailable", reason: "request_failed" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch daily RQ Score history while preserving empty vs unavailable. */
|
||||
export async function readQscoreHistoryFromService(
|
||||
userId: string,
|
||||
orgId: string = DEFAULT_QSCORE_ORG_ID,
|
||||
points = 10,
|
||||
timeoutMs: number = QSCORE_PROXY_TIMEOUT_MS,
|
||||
): Promise<QscoreReadResult<QscoreHistoryPoint[]>> {
|
||||
const qscoreUserId = toQscoreUserId(userId);
|
||||
const query = new URLSearchParams({ org_id: orgId, points: String(points) });
|
||||
const url = `${config.qscoreServiceUrl.replace(/\/$/, "")}/v1/qscore/${encodeURIComponent(qscoreUserId)}/history?${query}`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (res.status === 404) return { status: "empty", reason: "not_found" };
|
||||
if (!res.ok) {
|
||||
log.warn({ status: res.status, userId, orgId }, "qscore_service history returned non-success status");
|
||||
return { status: "unavailable", reason: `http_${res.status}` };
|
||||
}
|
||||
const body: unknown = await res.json();
|
||||
if (!isRecord(body) || !Array.isArray(body.points)) {
|
||||
return { status: "unavailable", reason: "invalid_response" };
|
||||
}
|
||||
const history = body.points.flatMap((value): QscoreHistoryPoint[] => {
|
||||
if (!isRecord(value) || typeof value.rq_score !== "number" || !Number.isFinite(value.rq_score) || typeof value.date !== "string") return [];
|
||||
return [{ rq_score: value.rq_score, date: value.date }];
|
||||
});
|
||||
if (history.length !== body.points.length) return { status: "unavailable", reason: "invalid_response" };
|
||||
return { status: "ready", data: history };
|
||||
} catch (err) {
|
||||
log.warn({ err, userId, orgId }, "qscore_service history proxy call failed");
|
||||
return { status: "unavailable", reason: "request_failed" };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,3 +8,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.
|
||||
|
||||
@@ -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)));
|
||||
|
||||
397
src/v1/analytics/career-report.ts
Normal file
397
src/v1/analytics/career-report.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
602
src/v1/analytics/home-insight-snapshot.ts
Normal file
602
src/v1/analytics/home-insight-snapshot.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user