feat: expose live Career Report data contract #20

Merged
Sai-karthik merged 3 commits from feat/career-report-data into staging 2026-07-15 20:07:08 +00:00
7 changed files with 854 additions and 4 deletions

View File

@@ -14,6 +14,7 @@
"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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -68,6 +68,21 @@ 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;
@@ -247,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)) {
@@ -258,6 +295,12 @@ function a2aData(read: ProducerRead, action: string): ProducerRead {
: { 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];
@@ -293,8 +336,9 @@ export async function buildHomeInsightSnapshots(input: {
qscore: QscoreReadResult<QscoreProxyResult>;
qscoreHistory: QscoreReadResult<QscoreHistoryPoint[]>;
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)),
@@ -305,7 +349,20 @@ 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?.({
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"
@@ -376,7 +433,6 @@ export async function buildHomeInsightSnapshots(input: {
}
const opportunityFallback = qscoreCategorySnapshot(input.qscore, "matching_activity", "Opportunities matched");
const matchmakingRead = a2aData(matchmakingRaw, "scout_stats");
let opportunity = unavailableWhenNoUsableFallback(matchmakingRead, opportunityFallback, "matchmaking.scout-stats", "Opportunities matched", "matches");
if (matchmakingRead.status === "ready") {
const matches = finiteCount(matchmakingRead.data.matchesFound);
@@ -469,7 +525,6 @@ export async function buildHomeInsightSnapshots(input: {
}
const learningFallback = qscoreCategorySnapshot(input.qscore, "learning_credentials", "Learning progress");
const courseRead = a2aData(courseRaw, "course_state_loaded");
let learning = unavailableWhenNoUsableFallback(courseRead, learningFallback, "courses.course-state", "Learning progress", "videos");
if (courseRead.status === "ready") {
const hasProfile = courseRead.data.has_interest_profile === true;