diff --git a/scripts/career-report.test.ts b/scripts/career-report.test.ts index dd82fc3..df6c019 100644 --- a/scripts/career-report.test.ts +++ b/scripts/career-report.test.ts @@ -8,29 +8,29 @@ import { import type { HomeInsightSnapshots, InsightSnapshot } from "../src/v1/analytics/home-insight-snapshot.js"; import type { QscoreProxyResult } from "../src/services/qscore-proxy.js"; -const categoryIds = [ - "learning_credentials", - "resume_presence", - "linkedin_presence", - "matching_activity", - "interview_performance", - "pathway_progress", - "roleplay_performance", - "engagement_consistency", - "goals_readiness", +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 = categoryIds.map((id, categoryIndex) => { - const count = categoryIndex === categoryIds.length - 1 ? 3 : 8; + 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: id, + label, input_ids: [`input_${categoryIndex}`], weight_percent: 10, score: signals.some((signal) => signal.present) ? 50 : 0, @@ -162,6 +162,73 @@ assert.equal(report.sources.find((source) => source.id === "grow_events")?.statu 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 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" }, diff --git a/scripts/home-insight-snapshot.test.ts b/scripts/home-insight-snapshot.test.ts index 61bccc7..9407ed9 100644 --- a/scripts/home-insight-snapshot.test.ts +++ b/scripts/home-insight-snapshot.test.ts @@ -1,6 +1,7 @@ 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 { @@ -218,4 +219,21 @@ assert.equal(malformedA2a.opportunity.status, "ready", "valid qscore category is 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"); diff --git a/src/v1/analytics/README.md b/src/v1/analytics/README.md index 6f8d4d8..c2a9d5e 100644 --- a/src/v1/analytics/README.md +++ b/src/v1/analytics/README.md @@ -98,6 +98,13 @@ means a successful read confirmed no value or an empty array/count; 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, diff --git a/src/v1/analytics/analytics-routes.ts b/src/v1/analytics/analytics-routes.ts index fb8ada7..3bc66c9 100644 --- a/src/v1/analytics/analytics-routes.ts +++ b/src/v1/analytics/analytics-routes.ts @@ -164,7 +164,7 @@ export function v1AnalyticsRoutes() { ? { 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] = await Promise.all([ + const [qscoreRead, qscoreHistory, profile, recentEvents, counts, sourceCounts] = await Promise.all([ readQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID), readQscoreHistoryFromService(userId, DEFAULT_QSCORE_ORG_ID), profilePromise, @@ -182,6 +182,14 @@ export function v1AnalyticsRoutes() { }) .from(growEvents) .where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14)))), + db + .select({ + source: growEvents.source, + count: sql`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({ @@ -193,9 +201,9 @@ export function v1AnalyticsRoutes() { }); if (!producerStatuses) throw new Error("career report producer status collection failed"); const serviceCounts = new Map(); - for (const event of recentEvents) { - const bucket = sourceBucket(event.source); - serviceCounts.set(bucket, (serviceCounts.get(bucket) ?? 0) + 1); + 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 = { @@ -204,7 +212,7 @@ export function v1AnalyticsRoutes() { completedEvents: aggregate?.completed ?? 0, openedEvents: aggregate?.opened ?? 0, services: Array.from(serviceCounts.entries()).map(([service, count]) => ({ service, count })), - opportunityEvents: recentEvents.filter((event) => sourceBucket(event.source) === "opportunities").length, + opportunityEvents: serviceCounts.get("opportunities") ?? 0, }; return c.json(buildCareerReportProjection({ diff --git a/src/v1/analytics/career-report.ts b/src/v1/analytics/career-report.ts index 849f29e..2a7387b 100644 --- a/src/v1/analytics/career-report.ts +++ b/src/v1/analytics/career-report.ts @@ -102,6 +102,18 @@ export type CareerReportResponse = { 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 { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -115,6 +127,11 @@ function nonNegativeInteger(value: unknown): number | null { 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; } @@ -140,18 +157,22 @@ function unavailableField(source: string, reason: string): CareerReportField< return { status: "unavailable", source, value: null, updatedAt: null, reason }; } -function unsupportedField(source: string, reason: string, updatedAt: string | null = null): CareerReportField { - return { status: "unsupported", source, value: null, updatedAt, reason }; -} - function scalarField(source: string, value: unknown, updatedAt: string | null = null): CareerReportField { + 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) : { ...emptyField(source, null, "field_not_present"), updatedAt }; + return parsed + ? readyField(source, parsed, updatedAt) + : { ...unavailableField(source, "producer_response_invalid"), updatedAt }; } function arrayField(source: string, value: unknown, updatedAt: string | null = null): CareerReportField { + if (value === undefined || value === null) { + return { ...emptyField(source, [], "field_not_present"), updatedAt }; + } const parsed = stringArray(value); - if (parsed === null) return { ...emptyField(source, [], "field_not_present"), updatedAt }; + if (parsed === null) return { ...unavailableField(source, "producer_response_invalid"), updatedAt }; return parsed.length ? readyField(source, parsed, updatedAt) : { ...emptyField(source, [], "no_values"), updatedAt }; } @@ -177,7 +198,7 @@ function parseSignal(value: unknown): CareerReportSignal | null { if (!isRecord(value)) return null; const id = stringValue(value.id); const label = stringValue(value.label); - const score = finiteNumber(value.score); + 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 }; } @@ -187,8 +208,8 @@ function parseCategory(value: unknown): CareerReportCategory | null { const id = stringValue(value.id); const label = stringValue(value.label); const inputIds = stringArray(value.input_ids); - const weightPercent = finiteNumber(value.weight_percent); - const score = finiteNumber(value.score); + 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 ( @@ -206,6 +227,8 @@ function parseCategory(value: unknown): CareerReportCategory | null { 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) || @@ -224,13 +247,20 @@ export function parseCareerReportDisplay(value: unknown): CareerReportDisplay | const parsedCategories = categories as CareerReportCategory[]; const signals = parsedCategories.flatMap((category) => category.signals); if ( - parsedCategories.length !== 9 || + 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 }; } @@ -310,7 +340,7 @@ export function buildCareerReportProjection(input: { const parsedDisplay = parseCareerReportDisplay(input.qscore.data.breakdown.display); qscoreDisplay = parsedDisplay ? readyField("qscore.breakdown.display", parsedDisplay, input.qscore.data.calculated_at || null) - : unsupportedField("qscore.breakdown.display", "display_contract_missing", input.qscore.data.calculated_at || null); + : { ...unavailableField("qscore.breakdown.display", "display_contract_invalid"), updatedAt: input.qscore.data.calculated_at || null }; } else if (input.qscore.status === "empty") { qscoreScore = emptyField("qscore.current", null, "no_qscore_run"); qscoreDisplay = emptyField("qscore.breakdown.display", null, "no_qscore_run"); diff --git a/src/v1/analytics/home-insight-snapshot.ts b/src/v1/analytics/home-insight-snapshot.ts index b0ae809..925a1d7 100644 --- a/src/v1/analytics/home-insight-snapshot.ts +++ b/src/v1/analytics/home-insight-snapshot.ts @@ -262,6 +262,28 @@ function socialStateData(read: ProducerRead): ProducerRead { : { 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)) { @@ -316,7 +338,7 @@ export async function buildHomeInsightSnapshots(input: { recentEvents: InsightEvent[]; onProducerStatuses?: (statuses: HomeInsightProducerStatuses) => void; }, dependencies: ProducerDependencies = defaultDependencies): Promise { - const [interviewRaw, roleplayRaw, resumeRead, socialRaw, matchmakingRaw, courseRaw, assessmentRead] = await Promise.all([ + 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)), @@ -327,7 +349,9 @@ export async function buildHomeInsightSnapshots(input: { ]); 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?.({