fix: harden career report producer contracts
This commit is contained in:
@@ -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" },
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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({
|
||||
@@ -193,9 +201,9 @@ export function v1AnalyticsRoutes() {
|
||||
});
|
||||
if (!producerStatuses) throw new Error("career report producer status collection failed");
|
||||
const serviceCounts = new Map<string, number>();
|
||||
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({
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<T>(source: string, reason: string): CareerReportField<
|
||||
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) : { ...emptyField(source, null, "field_not_present"), updatedAt };
|
||||
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 { ...emptyField(source, [], "field_not_present"), updatedAt };
|
||||
if (parsed === null) return { ...unavailableField<string[]>(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<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");
|
||||
|
||||
@@ -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<HomeInsightSnapshots> {
|
||||
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?.({
|
||||
|
||||
Reference in New Issue
Block a user