Compare commits

...

8 Commits

Author SHA1 Message Date
Sai-karthik
e444d1be15 Fix compressed resume proxy responses 2026-07-13 06:52:59 +00:00
Sai-karthik
fdf4f6468d Cover task-aware curator handoffs 2026-07-13 06:10:15 +00:00
Sai-karthik
8617be7e7c Preserve task details on unavailable handoffs 2026-07-13 01:07:43 +00:00
Sai-karthik
8cb5413766 Keep presentation handoffs task-focused 2026-07-13 00:21:57 +00:00
Sai-karthik
2410a4800b Expose profile asset state in Day 1 tasks 2026-07-13 00:10:38 +00:00
Sai-karthik
f848f45429 Remove invented interview role fallback 2026-07-12 23:51:44 +00:00
Sai-karthik
5f5622b3a0 Fix curator task handoffs and profile state 2026-07-12 23:21:52 +00:00
-Puter
c7c6f1b8cc fix curator sprint ICP reconciliation 2026-07-13 03:31:02 +05:30
12 changed files with 549 additions and 109 deletions

View File

@@ -14,9 +14,10 @@ services:
qscore-service-staging:
matchmaking-v2-staging:
user-service-staging:
social-branding-staging:
environment:
DATABASE_URL: postgres://growqr:growqr@growqr-postgres:5432/growqr
SOCIAL_BRANDING_SERVICE_URL: http://host.docker.internal:18015
SOCIAL_BRANDING_SERVICE_URL: http://social-branding-api-1:8011
SOCIAL_BRANDING_PUBLIC_URL: https://social-staging.gqr.puter.wtf
PRODUCT_SERVICE_TIMEOUT_MS: 10000
PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS: 240000
@@ -48,3 +49,6 @@ networks:
user-service-staging:
external: true
name: growqr-app_growqr-net
social-branding-staging:
external: true
name: social-branding_default

View File

@@ -10,6 +10,7 @@
"test:missions": "tsx scripts/mission-lifecycle.test.ts",
"test:passive-actions": "tsx scripts/passive-actions.test.ts",
"test:curator-static": "tsx scripts/curator-static-registry.test.ts",
"test:curator-reconcile": "tsx scripts/curator-persisted-reconcile.test.ts",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"workflows:smoke": "tsx src/workflows/smoke-test.ts",

View File

@@ -1,10 +1,13 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
curatorSprintPlanFingerprint,
curatorSprintTaskPlan,
decideCuratorSprintReconciliation,
resolveCuratorSprintAccess,
shouldReconcileCuratorSprint,
} from "../src/v1/curator/curator-store.js";
import { templateSetFor, trialDaysFor } from "../src/v1/curator/task-registry.js";
const usersRoute = readFileSync(new URL("../src/routes/users.ts", import.meta.url), "utf8");
const ledgerSource = readFileSync(new URL("../src/events/onboarding-ledger.ts", import.meta.url), "utf8");
@@ -44,4 +47,31 @@ const unauthorizedFull = resolveCuratorSprintAccess({ plan: "free", accessChoice
assert.deepEqual(unauthorizedFull, { access: "trial", durationDays: 2 });
assert.equal(shouldReconcileCuratorSprint(existingTrial, unauthorizedFull), false);
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.notEqual(studentFingerprint, experiencedFingerprint, "ICP changes must change the compatibility fingerprint");
assert.equal(shouldReconcileCuratorSprint(
{ access: "trial", durationDays: 2, variantId: "student_recent_grad", planFingerprint: studentFingerprint },
{ access: "trial", durationDays: 2, variantId: "experienced_professional", planFingerprint: experiencedFingerprint },
), true, "student started/ready lineage must reconcile to experienced trial");
const experiencedTrial = trialDaysFor("experienced_professional");
assert.deepEqual(experiencedPlan[0]?.tasks.map((task) => task.title), [
experiencedTrial[0]!.socialTitle,
experiencedTrial[0]!.measurementTitle,
experiencedTrial[0]!.proofTitle,
experiencedTrial[0]!.practiceTitle,
]);
assert.deepEqual(experiencedPlan[1]?.tasks.map((task) => task.title), [
experiencedTrial[1]!.socialTitle,
experiencedTrial[1]!.measurementTitle,
experiencedTrial[1]!.proofTitle,
experiencedTrial[1]!.practiceTitle,
]);
assert.equal(experiencedPlan[0]?.tasks.length, 4);
assert.equal(experiencedPlan[1]?.tasks.length, 4);
assert.deepEqual(experiencedPlan[0]?.tasks.map((task) => task.id), experiencedPlan[0]?.tasks.map((task) => task.id));
assert.equal(templateSetFor("experienced_professional").id, "experienced_professional");
console.log("curator onboarding reconciliation tests passed");

View File

@@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import { eq, sql } from "drizzle-orm";
import { db } from "../src/db/client.js";
import { growEvents, onboarding, users } from "../src/db/schema.js";
import { buildCuratorSprint, curatorSprintTaskPlan } from "../src/v1/curator/curator-store.js";
import { runCuratorOnboardingLoop } from "../src/v1/curator/curator-onboarding-loop.js";
import { CURATOR_TRIAL_TASK_REGISTRY } from "../src/v1/curator/task-registry.js";
let databaseAvailable = Boolean(process.env.DATABASE_URL);
if (databaseAvailable) {
try {
await db.execute(sql`select 1`);
} catch {
databaseAvailable = false;
}
}
if (!databaseAvailable) {
console.log("curator persisted reconciliation integration skipped: DATABASE_URL is not reachable");
} else {
const userId = `curator-reconcile-regression-${Date.now()}`;
const startDate = new Date().toISOString().slice(0, 10);
const sprintId = `curator-sprint:icp-v10-static:${startDate}`;
const staleFingerprint = JSON.stringify({ variantId: "student_recent_grad", taskIds: [] });
const staleReadyPayload = {
sprintId,
startDate,
durationDays: 2,
access: "trial",
variantId: "student_recent_grad",
planFingerprint: staleFingerprint,
};
try {
await db.insert(users).values({ id: userId, email: `${userId}@example.test`, plan: "pro" });
await db.insert(onboarding).values({
userId,
data: {
status: "completed",
access_choice: "trial",
profile: { icp: "experienced" },
},
payload: {
onboarding_icp: "experienced",
curator_registry_icp: "experienced_professional",
},
});
await db.insert(growEvents).values([
{
userId,
source: "curator-v1",
type: "curator.sprint.started",
category: "mission",
occurredAt: new Date(),
dedupeKey: `${userId}:curator.sprint.started:stale`,
payload: {
sprintId,
startDate,
durationDays: 2,
access: "trial",
version: "icp-v10-static",
variantId: "student_recent_grad",
},
},
{
userId,
source: "curator-v1",
type: "curator.onboarding_plan.ready",
category: "mission",
occurredAt: new Date(),
dedupeKey: `${userId}:curator.onboarding_plan.ready:stale`,
payload: staleReadyPayload,
},
]);
const context = {
onboarding: { status: "completed", access_choice: "trial", profile: { icp: "experienced" } },
preferences: { onboarding: { status: "completed", access_choice: "trial", profile: { icp: "experienced" } } },
};
const first = await runCuratorOnboardingLoop({ userId, completedAt: `${startDate}T09:00:00.000Z`, context });
assert.equal(first.status, "ready", "stale ready event must be replaced");
const day1 = await buildCuratorSprint(userId, startDate);
assert.deepEqual(day1.plan.days[0]?.tasks.map(({ id, title }) => ({ id, title })),
expectedTasks(startDate, 1), "Day 1 must expose experienced trial tasks");
assert.deepEqual(day1.plan.days[1]?.tasks, [], "Day 1 must hide future Day 2 tasks");
const day2 = await buildCuratorSprint(userId, addDays(startDate, 1));
assert.deepEqual(day2.plan.days[1]?.tasks.map(({ id, title }) => ({ id, title })),
expectedTasks(startDate, 2), "Day 2 must expose experienced trial tasks after advance");
const second = await runCuratorOnboardingLoop({ userId, completedAt: `${startDate}T09:00:00.000Z`, context });
assert.equal(second.status, "already_ready", "compatible replacement ready event must be reused");
const rows = await db.select({ type: growEvents.type, payload: growEvents.payload })
.from(growEvents).where(eq(growEvents.userId, userId));
assert.equal(rows.filter((row) => row.type === "curator.sprint.invalidated").length, 1);
assert.equal(rows.filter((row) => row.type === "curator.onboarding_plan.invalidated").length, 1);
const starts = rows.filter((row) => row.type === "curator.sprint.started");
const readies = rows.filter((row) => row.type === "curator.onboarding_plan.ready");
assert.equal(starts.length, 2, "one stale and one replacement started lineage");
assert.equal(readies.length, 2, "one stale and one replacement ready lineage");
const replacementStart = starts.find((row) => row.payload?.variantId === "experienced_professional");
const replacementReady = readies.find((row) => row.payload?.planFingerprint === day1.plan.planFingerprint);
assert.ok(replacementStart, "experienced replacement started event must be persisted");
assert.ok(replacementReady, "experienced replacement ready event must be persisted");
console.log("curator persisted reconciliation integration passed");
} finally {
await db.delete(users).where(eq(users.id, userId));
await db.execute(sql`select 1`);
}
}
function addDays(date: string, days: number) {
const value = new Date(`${date}T00:00:00.000Z`);
value.setUTCDate(value.getUTCDate() + days);
return value.toISOString().slice(0, 10);
}
function expectedTasks(startDate: string, dayIndex: 1 | 2) {
const plan = curatorSprintTaskPlan({
startDate,
variantId: "experienced_professional",
durationDays: 2,
access: "trial",
});
const registry = CURATOR_TRIAL_TASK_REGISTRY.experienced_professional[dayIndex - 1]!;
return plan[dayIndex - 1]!.tasks.map(({ id }, index) => ({
id,
title: [registry.socialTitle, registry.measurementTitle, registry.proofTitle, registry.practiceTitle][index]!,
}));
}

View File

@@ -13,10 +13,12 @@ const ICP_IDS: CuratorIcpId[] = [
const LIVE_ROUTE_PREFIX: Record<string, string> = {
"courses-service": "/agents/courses",
"qscore-service": "/agents/qscore",
"resume-service": "/agents/resume",
"interview-service": "/agents/interview",
"roleplay-service": "/agents/roleplay",
"matchmaking-service": "/agents/matchmaking",
"social-branding-service": "/agents/social-branding",
};
const EXPECTED_FOCUS: Record<CuratorIcpId, string[]> = {
@@ -60,14 +62,12 @@ async function main() {
});
try {
assert(preview.version === "icp-v7-static", `${icpId}: expected static plan version`);
assert(preview.idempotency.promptHash === "static-task-registry", `${icpId}: prompt hash should prove static registry mode`);
assert(preview.plan.days.length === 30, `${icpId}: expected 30 generated days`);
assert(preview.plan.calendarWeeks.length === 5, `${icpId}: expected 5 sprint-relative calendar weeks`);
assert(preview.version === "icp-v10-static", `${icpId}: expected static plan version`);
assert(preview.plan.days.length === 7, `${icpId}: expected 7 generated days`);
assert(preview.plan.calendarWeeks.length === 1, `${icpId}: expected one sprint-relative calendar week`);
const firstSevenFocus = preview.plan.days.slice(0, 7).map((day) => day.focus);
assert(JSON.stringify(firstSevenFocus) === JSON.stringify(EXPECTED_FOCUS[icpId]), `${icpId}: first 7 focus labels must match 7-day sprint doc`);
assert(JSON.stringify(firstSevenFocus) === JSON.stringify(preview.plan.days.slice(7, 14).map((day) => day.focus)), `${icpId}: 7-day sprint should repeat after day 7 for now`);
const dayOneTitles = preview.plan.days[0]?.tasks.map((task) => task.title);
assert(JSON.stringify(dayOneTitles) === JSON.stringify(EXPECTED_DAY_ONE_TITLES[icpId]), `${icpId}: day 1 task titles must match docs`);
@@ -84,7 +84,7 @@ async function main() {
for (const task of day.tasks) {
assert(task.missionId === "curator-sprint", `${icpId} ${task.id}: missionId mismatch`);
assert(task.missionInstanceId?.startsWith("curator-sprint:icp-v7-static:"), `${icpId} ${task.id}: missionInstanceId should use static version`);
assert(task.missionInstanceId?.startsWith("curator-sprint:icp-v10-static:"), `${icpId} ${task.id}: missionInstanceId should use static version`);
assert(task.stageId?.includes(`${task.serviceId?.replace("-service", "")}`), `${icpId} ${task.id}: stageId should include service segment`);
assert(task.id.includes(`${task.serviceId?.replace("-service", "")}`), `${icpId} ${task.id}: taskId should include service segment`);
assert(task.actorName !== "Vera" && task.actorName !== "Kai" && task.actorName !== "Mira" && task.actorName !== "Lyra", `${icpId} ${task.id}: agent persona leaked into actorName`);
@@ -102,8 +102,20 @@ async function main() {
assert(url.searchParams.get("missionId") === task.missionId, `${icpId} ${task.id}: route missionId mismatch`);
assert(url.searchParams.get("missionInstanceId") === task.missionInstanceId, `${icpId} ${task.id}: route missionInstanceId mismatch`);
assert(url.searchParams.get("stageId") === task.stageId, `${icpId} ${task.id}: route stageId mismatch`);
assert(url.searchParams.get("taskTitle") === task.title, `${icpId} ${task.id}: route taskTitle mismatch`);
assert(url.searchParams.get("taskSubtitle") === task.subtitle, `${icpId} ${task.id}: route taskSubtitle mismatch`);
assert(url.searchParams.get("taskType") === task.taskType, `${icpId} ${task.id}: route taskType mismatch`);
if (task.serviceId === "interview-service" || task.serviceId === "roleplay-service") {
if (task.serviceId === "interview-service") {
const taskText = `${task.title} ${task.subtitle}`.toLowerCase();
const isIntroductionTask = /(?:60|90)[ -]?second|\bintroduction\b|\bpitch\b|\bpresentation\b/.test(taskText);
assert(url.searchParams.get("type") === (isIntroductionTask ? "warm_up" : "behavioral"), `${icpId} ${task.id}: interview type must match task intent`);
assert(url.searchParams.get("role") === (isIntroductionTask ? null : "Product Manager"), `${icpId} ${task.id}: interview role must match task intent`);
assert(url.searchParams.get("difficulty") === (isIntroductionTask ? "easy" : "medium"), `${icpId} ${task.id}: interview difficulty must match task intent`);
assert(url.searchParams.get("duration") === "5", `${icpId} ${task.id}: interview route should carry duration`);
}
if (task.serviceId === "roleplay-service") {
assert(url.searchParams.get("role") === "Product Manager", `${icpId} ${task.id}: practice route should preserve target role`);
assert(url.searchParams.get("duration") === "5", `${icpId} ${task.id}: practice route should carry duration`);
}
@@ -119,7 +131,7 @@ async function main() {
process.exit(1);
}
console.log(`curator-static-registry tests passed for ${ICP_IDS.length} ICPs, 30 days each, 4 doc-backed tasks per day`);
console.log(`curator-static-registry tests passed for ${ICP_IDS.length} ICPs, 7 days each, 4 doc-backed tasks per day`);
}
main().catch((err) => {

View File

@@ -396,6 +396,17 @@ function parseJsonBody(body: ArrayBuffer | undefined, headers: Headers): JsonObj
}
}
function decodedProxyHeaders(headers: Headers) {
const forwarded = new Headers(headers);
// Node fetch transparently decodes gzip/br bodies but retains the upstream
// encoding and byte-length headers. Forwarding those stale headers makes the
// next proxy/browser decode an already-decoded body and fail.
forwarded.delete("content-encoding");
forwarded.delete("content-length");
forwarded.delete("transfer-encoding");
return forwarded;
}
async function proxyResumeRequest(req: Request, rest: string, userId: string) {
const incoming = new URL(req.url);
const normalizedRest = rest
@@ -419,6 +430,7 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
headers.delete("cookie");
headers.set("authorization", `Bearer ${config.a2aAllowedKey}`);
headers.set("x-growqr-user", userId);
headers.set("accept-encoding", "identity");
const method = req.method.toUpperCase();
const body = ["GET", "HEAD"].includes(method) ? undefined : await req.arrayBuffer();
@@ -436,7 +448,11 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
});
if (method === "GET" || method === "HEAD") {
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: res.headers });
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: decodedProxyHeaders(res.headers),
});
}
const responseBuffer = await res.arrayBuffer();
@@ -461,7 +477,7 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
return new Response(responseBuffer, {
status: res.status,
statusText: res.statusText,
headers: res.headers,
headers: decodedProxyHeaders(res.headers),
});
}
@@ -519,6 +535,9 @@ async function resolveGrowUserContext(req: Request, userId: string): Promise<Rec
if (resumeState.current_company && !userContext.current_company) userContext.current_company = resumeState.current_company;
if (Array.isArray(resumeState.experience_history)) userContext.experience_history = resumeState.experience_history;
if (Array.isArray(resumeState.education)) userContext.education = resumeState.education;
userContext.resume_available = Number(resumeState.resume_count ?? 0) > 0;
const resumeSummary = getString(resumeState.resume_summary ?? resumeState.resumeSummary);
if (resumeSummary) userContext.resume_summary = resumeSummary;
}
if (socialState) {
@@ -534,7 +553,9 @@ async function resolveGrowUserContext(req: Request, userId: string): Promise<Rec
function hasResumeContext(userContext: Record<string, unknown>): boolean {
return Boolean(
(Array.isArray(userContext.experience_history) && userContext.experience_history.length > 0) ||
userContext.resume_available ||
getString(userContext.resume_summary) ||
(Array.isArray(userContext.experience_history) && userContext.experience_history.length > 0) ||
(Array.isArray(userContext.skills) && userContext.skills.length > 0) ||
getString(userContext.current_role)
);
@@ -556,6 +577,9 @@ function composeCandidateProfile(userContext: Record<string, unknown>): string {
if (currentRole) lines.push(`Current role: ${currentRole}`);
if (headline && headline.toLowerCase() !== currentRole?.toLowerCase()) lines.push(`LinkedIn headline: ${headline}`);
const resumeSummary = getString(userContext.resume_summary);
if (resumeSummary) lines.push(`Primary resume summary: ${resumeSummary.length > 600 ? `${resumeSummary.slice(0, 600).trimEnd()}` : resumeSummary}`);
const skills = stringArray(userContext.skills);
if (skills.length) lines.push(`Key skills: ${skills.slice(0, 12).join(", ")}`);
@@ -591,7 +615,7 @@ function composeCandidateProfile(userContext: Record<string, unknown>): string {
if (education.length) lines.push(`Education: ${education.slice(0, 3).join("; ")}`);
const summary = getString(userContext.linkedin_summary);
if (summary) lines.push(`Profile summary: ${summary.length > 300 ? `${summary.slice(0, 300).trimEnd()}` : summary}`);
if (summary && summary !== resumeSummary) lines.push(`LinkedIn summary: ${summary.length > 300 ? `${summary.slice(0, 300).trimEnd()}` : summary}`);
const text = lines.join("\n");
return text.length > 1200 ? `${text.slice(0, 1200).trimEnd()}` : text;
@@ -613,7 +637,8 @@ async function buildPersonalizedConfigurePayload(req: Request, body: JsonObject,
difficulty: getString(incomingContext.difficulty) ?? "medium",
};
if (incomingContext.personalize) {
const curatorHandoff = getString(incomingContext.source) === "curator-v1" || Boolean(getString(incomingContext.curatorTaskId));
if (incomingContext.personalize || curatorHandoff) {
const candidateProfile = composeCandidateProfile(userContext);
if (candidateProfile) context.candidate_profile = candidateProfile;
}

View File

@@ -947,6 +947,9 @@ function unavailableServiceRoute(input: CuratorRouteInput) {
return appendQuery("/agents/service-unavailable", {
...curatorBaseParams(input),
serviceId: input.serviceId,
taskTitle: input.taskTitle,
taskSubtitle: input.taskSubtitle,
taskType: input.taskType,
});
}
@@ -962,11 +965,15 @@ export function buildCuratorServiceRoute(input: CuratorRouteInput) {
if (input.taskType) state.taskType = input.taskType;
if (service.id === "interview-service") {
state.role = input.targetRole?.trim() || "Product Manager";
state.type = "behavioral";
const taskText = `${input.taskTitle ?? ""} ${input.taskSubtitle ?? ""}`.toLowerCase();
const isIntroductionTask = /(?:60|90)[ -]?second|\bintroduction\b|\bpitch\b|\bpresentation\b/.test(taskText);
// Curator already resolves this from onboarding/user context. Never invent a
// role here: the task title/subtitle carry the exercise intent separately.
state.role = isIntroductionTask ? undefined : input.targetRole?.trim();
state.type = isIntroductionTask ? "warm_up" : "behavioral";
state.persona = input.personaId ?? "payal";
state.duration = input.durationMinutes ?? 5;
state.difficulty = input.difficulty ?? "medium";
state.difficulty = input.difficulty ?? (isIntroductionTask ? "easy" : "medium");
state.media = input.requestedMode ?? "video";
}

View File

@@ -7,13 +7,16 @@ function curatorDedupeKey(input: {
payload?: Record<string, unknown>;
}) {
const payload = input.payload ?? {};
const stableId =
payload.taskId ??
payload.sprintId ??
payload.startDate ??
payload.sourceEventId ??
payload.eventId ??
payload.date;
const stableId = input.type.includes("invalidated")
? `${payload.staleSprintEventId ?? payload.staleReadyEventId ?? payload.staleEventId ?? "unknown"}:${payload.planFingerprint ?? "legacy"}`
: payload.planFingerprint ??
payload.taskPlanFingerprint ??
payload.taskId ??
payload.sprintId ??
payload.startDate ??
payload.sourceEventId ??
payload.eventId ??
payload.date;
return `${input.userId}:${input.type}:${stableId ?? Date.now()}`;
}

View File

@@ -276,23 +276,48 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom
const existing = await findExistingReadyEvent(userId);
if (existing) {
const existingDuration = existing.payload?.durationDays === 7 ? 7 : existing.payload?.durationDays === 2 ? 2 : undefined;
if (existingDuration !== sprint.plan.durationDays) {
const existingFingerprint = typeof existing.payload?.planFingerprint === "string" ? existing.payload.planFingerprint : undefined;
const existingIcpId = typeof existing.payload?.icpId === "string"
? existing.payload.icpId
: typeof existing.payload?.variantId === "string" ? existing.payload.variantId : undefined;
const compatible = existingFingerprint === sprint.plan.planFingerprint && existingIcpId === sprint.plan.icpId;
if (compatible) return { status: "already_ready", readyEventId: existing.id, sprint };
const invalidations = await db
.select({ payload: growEvents.payload })
.from(growEvents)
.where(and(
eq(growEvents.userId, userId),
eq(growEvents.source, CURATOR_SOURCE),
eq(growEvents.type, "curator.onboarding_plan.invalidated"),
))
.orderBy(desc(growEvents.occurredAt))
.limit(20);
const alreadyInvalidated = invalidations.some((row) => (
row.payload?.staleReadyEventId === existing.id
&& row.payload?.planFingerprint === sprint.plan.planFingerprint
));
if (!alreadyInvalidated) {
await emitCuratorEvent({
userId,
type: "curator.onboarding_plan.invalidated",
payload: {
sprintId: sprint.sprintId,
staleReadyEventId: existing.id,
previousDurationDays: existingDuration,
access: sprint.plan.durationDays === 7 ? "full" : "trial",
previousIcpId: existingIcpId,
previousDurationDays: existing.payload?.durationDays,
previousPlanFingerprint: existingFingerprint,
icpId: sprint.plan.icpId,
variantId: sprint.plan.icpId,
access: sprint.plan.access,
durationDays: sprint.plan.durationDays,
planFingerprint: sprint.plan.planFingerprint,
taskPlan: sprint.plan.taskPlan,
dayOneTasks: dayOneTasks(sprint),
reason: "onboarding_access_choice_changed",
reason: "onboarding_plan_changed",
},
});
}
return { status: "already_ready", readyEventId: existing.id, sprint };
}
const event = await emitCuratorEvent({
@@ -307,11 +332,16 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom
sprintId: sprint.sprintId,
planId: sprint.plan.id,
durationDays: sprint.plan.durationDays,
access: sprint.plan.durationDays === 7 ? "full" : "trial",
access: sprint.plan.access,
icpId: sprint.plan.icpId,
variantId: sprint.plan.icpId,
planFingerprint: sprint.plan.planFingerprint,
taskPlan: sprint.plan.taskPlan,
weekCount: sprint.plan.weeks.length,
dayCount: sprint.plan.days.length,
activeDayIndex: sprint.activeDayIndex,
weeklyThemes: weeklyThemes(sprint),
// Keep the future-day visibility contract: this is Day 1 only.
dayOneTasks: dayOneTasks(sprint),
notificationId: `curator:onboarding-plan-ready:${userId}`,
},

View File

@@ -5,7 +5,7 @@ import { listMissionDefinitions } from "../../missions/registry.js";
import { listServiceCapabilities } from "../../workflows/service-capabilities.js";
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
import { emitCuratorEvent } from "./curator-events.js";
import { buildCuratorUserContext } from "./curator-user-context.js";
import { buildCuratorUserContext, type CuratorUserContext } from "./curator-user-context.js";
import { isCuratorIcpId, type CuratorIcpId } from "./icp-registry.js";
import type {
CuratorPlan,
@@ -75,6 +75,11 @@ export type CuratorSprintAccess = {
durationDays: 2 | 7;
};
export type CuratorSprintCompatibility = CuratorSprintAccess & {
variantId?: CuratorIcpId;
planFingerprint?: string;
};
type CuratorSprintEntitlementInput = {
plan?: unknown;
accessChoice?: unknown;
@@ -96,23 +101,31 @@ export function resolveCuratorSprintAccess(input: CuratorSprintEntitlementInput)
}
export function shouldReconcileCuratorSprint(
existing: Pick<CuratorSprintAccess, "access" | "durationDays"> | undefined,
desired: CuratorSprintAccess,
existing: CuratorSprintCompatibility | undefined,
desired: CuratorSprintCompatibility,
) {
return !existing || existing.access !== desired.access || existing.durationDays !== desired.durationDays;
if (!existing || existing.access !== desired.access || existing.durationDays !== desired.durationDays) return true;
if (desired.variantId !== undefined && existing.variantId !== desired.variantId) return true;
if (desired.planFingerprint !== undefined && existing.planFingerprint !== desired.planFingerprint) return true;
return false;
}
export type CuratorSprintReconciliation =
| { action: "reuse"; access: "trial" | "full"; durationDays: 2 | 7 }
| { action: "invalidateAndRebuild"; access: "trial" | "full"; durationDays: 2 | 7 };
| ({ action: "reuse" } & CuratorSprintAccess & Partial<Pick<CuratorSprintCompatibility, "variantId" | "planFingerprint">>)
| ({ action: "invalidateAndRebuild" } & CuratorSprintAccess & Partial<Pick<CuratorSprintCompatibility, "variantId" | "planFingerprint">>);
export function decideCuratorSprintReconciliation(
existing: Pick<CuratorSprintAccess, "access" | "durationDays"> | undefined,
desired: CuratorSprintAccess,
existing: CuratorSprintCompatibility | undefined,
desired: CuratorSprintCompatibility,
): CuratorSprintReconciliation {
return shouldReconcileCuratorSprint(existing, desired)
? { action: "invalidateAndRebuild", access: desired.access, durationDays: desired.durationDays }
: { action: "reuse", access: desired.access, durationDays: desired.durationDays };
const action = shouldReconcileCuratorSprint(existing, desired) ? "invalidateAndRebuild" : "reuse";
return {
action,
access: desired.access,
durationDays: desired.durationDays,
...(desired.variantId === undefined ? {} : { variantId: desired.variantId }),
...(desired.planFingerprint === undefined ? {} : { planFingerprint: desired.planFingerprint }),
};
}
function accessChoiceFromContext(context: Record<string, unknown> | undefined): "trial" | "full" | undefined {
@@ -473,6 +486,47 @@ export function planSeedsForVariant(
return planDays;
}
export type CuratorSprintTaskPlan = {
dayIndex: number;
tasks: Array<{ id: string; title: string }>;
};
function taskPlanForSeeds(startDate: string, planDays: PlanDaySeed[]): CuratorSprintTaskPlan[] {
return planDays.map((day) => ({
dayIndex: day.dayIndex,
tasks: day.plannedTasks.map((task) => ({
id: taskIdFor(startDate, day.dayIndex, task.taskType, task.serviceId),
title: task.title,
})),
}));
}
export function curatorSprintTaskPlan(input: {
startDate: string;
variantId: CuratorIcpId;
durationDays: 2 | 7;
access: "trial" | "full";
}): CuratorSprintTaskPlan[] {
return taskPlanForSeeds(
input.startDate,
planSeedsForVariant(templateSetFor(input.variantId), input.startDate, input.durationDays, input.access),
);
}
/** Stable compatibility key for the complete static sprint task plan. */
export function curatorSprintPlanFingerprint(input: {
startDate: string;
variantId: CuratorIcpId;
durationDays: 2 | 7;
access: "trial" | "full";
}): string {
const taskPlan = curatorSprintTaskPlan(input);
return JSON.stringify({
variantId: input.variantId,
taskIds: taskPlan.flatMap((day) => day.tasks.map((task) => task.id)),
});
}
function performanceLabel(percent: number): CuratorWeek["performance"] {
if (percent >= 75) return "Excelling";
if (percent >= 50) return "Avg";
@@ -533,10 +587,61 @@ function eventText(value: unknown): string {
return "";
}
async function inferIcpVariant(userId: string): Promise<CuratorIcpId> {
const CURATOR_ICP_BY_ONBOARDING_ICP: Record<string, CuratorIcpId> = {
student: "student_recent_grad",
intern: "intern",
fresher: "fresher_early_professional",
experienced: "experienced_professional",
freelancer: "freelancer",
founder: "founder",
};
function curatorIcpFromContext(context: Record<string, unknown> | undefined): CuratorIcpId | undefined {
if (!context) return undefined;
const preferences = context.preferences && typeof context.preferences === "object" && !Array.isArray(context.preferences)
? context.preferences as Record<string, unknown>
: undefined;
const onboardingContext = context.onboarding && typeof context.onboarding === "object" && !Array.isArray(context.onboarding)
? context.onboarding as Record<string, unknown>
: undefined;
const preferenceOnboarding = preferences?.onboarding && typeof preferences.onboarding === "object" && !Array.isArray(preferences.onboarding)
? preferences.onboarding as Record<string, unknown>
: undefined;
const profile = onboardingContext?.profile && typeof onboardingContext.profile === "object" && !Array.isArray(onboardingContext.profile)
? onboardingContext.profile as Record<string, unknown>
: preferenceOnboarding?.profile && typeof preferenceOnboarding.profile === "object" && !Array.isArray(preferenceOnboarding.profile)
? preferenceOnboarding.profile as Record<string, unknown>
: undefined;
const candidates = [
context.curator_registry_icp,
onboardingContext?.curator_registry_icp,
preferences?.curator_registry_icp,
context.onboarding_icp,
onboardingContext?.onboarding_icp,
profile?.icp,
];
for (const candidate of candidates) {
if (typeof candidate !== "string") continue;
if (isCuratorIcpId(candidate)) return candidate;
const mapped = CURATOR_ICP_BY_ONBOARDING_ICP[candidate.toLowerCase()];
if (mapped) return mapped;
}
return undefined;
}
async function inferIcpVariant(userId: string, context?: Record<string, unknown>, preferContext = false): Promise<CuratorIcpId> {
const fromContext = curatorIcpFromContext(context);
const saved = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, userId) });
const canonical = saved?.payload && typeof saved.payload === "object" ? saved.payload.curator_registry_icp : undefined;
if (typeof canonical === "string" && isCuratorIcpId(canonical)) return canonical;
if (preferContext && fromContext) return fromContext;
const savedPayload = saved?.payload && typeof saved.payload === "object" ? saved.payload : undefined;
const savedData = saved?.data && typeof saved.data === "object" ? saved.data : undefined;
const fromSaved = curatorIcpFromContext({
curator_registry_icp: savedPayload?.curator_registry_icp,
onboarding_icp: savedPayload?.onboarding_icp,
onboarding: savedData,
});
if (fromSaved) return fromSaved;
if (fromContext) return fromContext;
const rows = await db
.select({ type: growEvents.type, payload: growEvents.payload })
@@ -602,14 +707,15 @@ async function loadSprintState(
? persistedContext.onboarding as Record<string, unknown>
: undefined;
const contextCompleted = contextOnboarding?.status === "completed" || typeof contextOnboarding?.completed_at === "string";
if (!dbCompleted && !contextCompleted) {
throw new Error("onboarding_incomplete");
}
if (!dbCompleted && !contextCompleted) throw new Error("onboarding_incomplete");
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
const dbChoice = dbData?.access_choice;
const accessChoice = accessChoiceFromContext(persistedContext) ?? dbChoice;
const accessChoice = onboardingContextOverride
? accessChoiceFromContext(persistedContext) ?? dbChoice
: dbChoice ?? accessChoiceFromContext(persistedContext);
const desired = resolveCuratorSprintAccess({ plan: user?.plan, accessChoice });
const desiredVariant = await inferIcpVariant(userId, persistedContext, Boolean(onboardingContextOverride));
const latest = await db
.select({ id: growEvents.id, payload: growEvents.payload })
@@ -625,79 +731,107 @@ async function loadSprintState(
const latestPayload = latest[0]?.payload;
const existingStartDate = typeof latestPayload?.startDate === "string" ? latestPayload.startDate : undefined;
const existingVersion = typeof latestPayload?.version === "string" ? latestPayload.version : undefined;
const existingVariant = typeof latestPayload?.variantId === "string" ? latestPayload.variantId : undefined;
const existingVariant = typeof latestPayload?.variantId === "string" && isCuratorIcpId(latestPayload.variantId)
? latestPayload.variantId
: undefined;
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 activeExisting = Boolean(
!rebaseExistingForCompletion && existingStartDate && existingVersion === CURATOR_PLAN_VERSION && existingVariant && isCuratorIcpId(existingVariant)
&& existingDuration && calendarDiffDays(existingStartDate, todayDate) >= 0 && calendarDiffDays(existingStartDate, todayDate) < existingDuration,
!rebaseExistingForCompletion && existingStartDate && existingVersion === CURATOR_PLAN_VERSION && existingVariant
&& existingDuration && existingAccess && calendarDiffDays(existingStartDate, todayDate) >= 0 && calendarDiffDays(existingStartDate, todayDate) < existingDuration,
);
let replacementStartDate = todayDate;
if (activeExisting && existingStartDate && existingVariant && existingDuration && existingAccess) {
const templateSet = templateSetFor(existingVariant as CuratorIcpId);
const reconciliation = decideCuratorSprintReconciliation({ access: existingAccess, durationDays: existingDuration }, desired);
if (reconciliation.action === "invalidateAndRebuild") {
const invalidations = await db
.select({ payload: growEvents.payload })
.from(growEvents)
.where(and(
eq(growEvents.userId, userId),
eq(growEvents.source, CURATOR_SOURCE),
eq(growEvents.type, "curator.sprint.invalidated"),
))
.orderBy(desc(growEvents.occurredAt))
.limit(20);
const alreadyInvalidated = invalidations.some((row) => (
row.payload?.staleSprintEventId === latest[0]?.id
&& row.payload?.access === desired.access
&& row.payload?.durationDays === desired.durationDays
));
if (!alreadyInvalidated) {
await emitCuratorEvent({
userId,
type: "curator.sprint.invalidated",
payload: {
sprintId: sprintIdFor(existingStartDate),
staleSprintEventId: latest[0]?.id,
staleSprintId: sprintIdFor(existingStartDate),
startDate: existingStartDate,
reason: "onboarding_access_choice_changed",
previousAccess: existingAccess,
previousDurationDays: existingDuration,
access: desired.access,
durationDays: desired.durationDays,
version: CURATOR_PLAN_VERSION,
},
});
}
}
return {
const desiredFingerprint = curatorSprintPlanFingerprint({
startDate: existingStartDate,
variantId: existingVariant as CuratorIcpId,
variantId: desiredVariant,
durationDays: desired.durationDays,
access: desired.access,
planDays: planSeedsForVariant(templateSet, existingStartDate, desired.durationDays, desired.access),
};
});
const reconciliation = decideCuratorSprintReconciliation(
{ access: existingAccess, durationDays: existingDuration, variantId: existingVariant, planFingerprint: existingFingerprint },
{ ...desired, variantId: desiredVariant, planFingerprint: desiredFingerprint },
);
if (reconciliation.action === "reuse") {
return {
startDate: existingStartDate,
variantId: existingVariant,
durationDays: desired.durationDays,
access: desired.access,
planDays: planSeedsForVariant(templateSetFor(existingVariant), existingStartDate, desired.durationDays, desired.access),
};
}
const invalidations = await db
.select({ payload: growEvents.payload })
.from(growEvents)
.where(and(
eq(growEvents.userId, userId),
eq(growEvents.source, CURATOR_SOURCE),
eq(growEvents.type, "curator.sprint.invalidated"),
))
.orderBy(desc(growEvents.occurredAt))
.limit(20);
const alreadyInvalidated = invalidations.some((row) => (
row.payload?.staleSprintEventId === latest[0]?.id
&& row.payload?.planFingerprint === desiredFingerprint
));
if (!alreadyInvalidated) {
await emitCuratorEvent({
userId,
type: "curator.sprint.invalidated",
payload: {
sprintId: sprintIdFor(existingStartDate),
staleSprintEventId: latest[0]?.id,
staleSprintId: sprintIdFor(existingStartDate),
startDate: existingStartDate,
reason: "onboarding_plan_changed",
previousVariantId: existingVariant,
previousAccess: existingAccess,
previousDurationDays: existingDuration,
previousPlanFingerprint: existingFingerprint,
variantId: desiredVariant,
access: desired.access,
durationDays: desired.durationDays,
planFingerprint: desiredFingerprint,
version: CURATOR_PLAN_VERSION,
},
});
}
// Keep the same calendar sprint identity while replacing its incompatible lineage.
replacementStartDate = existingStartDate;
}
const variantId = await inferIcpVariant(userId);
const variantId = desiredVariant;
const templateSet = templateSetFor(variantId);
const planDays = planSeedsForVariant(templateSet, todayDate, desired.durationDays, desired.access);
const planDays = planSeedsForVariant(templateSet, replacementStartDate, desired.durationDays, desired.access);
const planFingerprint = curatorSprintPlanFingerprint({
startDate: replacementStartDate,
variantId,
durationDays: desired.durationDays,
access: desired.access,
});
await emitCuratorEvent({
userId,
type: "curator.sprint.started",
payload: {
startDate: todayDate,
sprintId: sprintIdFor(todayDate),
startDate: replacementStartDate,
sprintId: sprintIdFor(replacementStartDate),
durationDays: desired.durationDays,
access: desired.access,
version: CURATOR_PLAN_VERSION,
variantId,
icpId: variantId,
icpLabel: templateSet.label,
planFingerprint,
taskPlan: taskPlanForSeeds(replacementStartDate, planDays),
},
});
return {
startDate: todayDate,
startDate: replacementStartDate,
variantId,
durationDays: desired.durationDays,
access: desired.access,
@@ -1021,8 +1155,9 @@ function buildTask(
recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>,
focusDate: string,
durationDays: number,
targetRole?: string,
userContext: CuratorUserContext,
): CuratorTask {
const targetRole = userContext.targetRole ?? undefined;
seedTask = {
...normalizeActiveCuratorTaskSeed(seedTask),
isTrialTask: seedTask.isTrialTask,
@@ -1035,6 +1170,7 @@ function buildTask(
const id = taskIdFor(sprintStartDate, dayIndex, seedTask.taskType, seedTask.serviceId);
const missionInstanceId = sprintIdFor(sprintStartDate);
const stageId = stageIdFor(dayIndex, weekIndex, seedTask.taskType, seedTask.serviceId);
const isProfileTask = /complete (?:your )?(?:growqr |professional |executive |freelancer |founder |employer )?profile/i.test(`${seedTask.title} ${seedTask.subtitle}`);
const task: CuratorTask = {
id,
date,
@@ -1066,6 +1202,10 @@ function buildTask(
{ label: "Task type", value: seedTask.taskType },
{ label: "Service handoff", value: serviceName(seedTask.serviceId) },
...(targetRole ? [{ label: "Target role", value: targetRole }] : [{ label: "Target role", value: "Complete onboarding to set your role" }]),
...(isProfileTask ? [
{ label: "Resume", value: userContext.profile.resumeAvailable ? "Added" : "Missing" },
{ label: "LinkedIn", value: userContext.profile.linkedinAvailable ? "Connected" : "Missing" },
] : []),
{ label: "Sprint day", value: `Day ${dayIndex}/${durationDays}` },
],
contextNarrative: contextNarrative(seedTask, weekTheme, weekSummary),
@@ -1077,10 +1217,14 @@ function buildTask(
isPaidTask: seedTask.isPaidTask !== false,
isRecoveryPractice: Boolean(seedTask.isRecoveryPractice),
};
const profileComplete = isProfileTask && (userContext.profile.resumeAvailable || userContext.profile.linkedinAvailable);
const routeTask = isProfileTask && userContext.profile.linkedinAvailable && !userContext.profile.resumeAvailable
? { ...task, serviceId: "resume-service" as const }
: task;
const taskWithRoute = {
...task,
route: buildCuratorTaskDeepLink(task, targetRole),
status: classifyTaskStatus({ task, focusDate, completionRows, recentRows }),
route: buildCuratorTaskDeepLink(routeTask, targetRole),
status: profileComplete ? "completed" as const : classifyTaskStatus({ task, focusDate, completionRows, recentRows }),
} satisfies CuratorTask;
return taskWithRoute;
}
@@ -1093,7 +1237,7 @@ function buildTasksForPlanDay(
recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>,
focusDate: string,
durationDays: number,
targetRole?: string,
userContext: CuratorUserContext,
) {
return planDay.plannedTasks.map((task) => (
buildTask(
@@ -1108,7 +1252,7 @@ function buildTasksForPlanDay(
recentRows,
focusDate,
durationDays,
targetRole,
userContext,
)
));
}
@@ -1132,7 +1276,7 @@ export async function buildCuratorTasks(userId: string, date = todayIso()): Prom
);
const planDay = adaptedPlanDays[dayIndex - 1];
if (!planDay) return [];
return buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, date, sprintState.durationDays, userContext.targetRole ?? undefined);
return buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, date, sprintState.durationDays, userContext);
}
export async function buildCuratorStreak(userId: string): Promise<CuratorStreak> {
@@ -1199,7 +1343,7 @@ async function buildCuratorSprintInternal(
const date = addDaysIso(sprintStartDate, index);
const planDay = planDays[index]!;
const tasks = date <= focusDate
? buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, focusDate, sprintState.durationDays, userContext.targetRole ?? undefined)
? buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, focusDate, sprintState.durationDays, userContext)
: [];
const completedCount = tasks.filter((task) => task.status === "completed").length;
const unlockState = focusDate > date ? "completed" : focusDate === date ? "active" : "upcoming";
@@ -1263,6 +1407,20 @@ async function buildCuratorSprintInternal(
goals: [templateSet.sprintTheme, templateSet.goal],
generatedAt: new Date().toISOString(),
durationDays: sprintState.durationDays,
access: sprintState.access,
icpId: sprintState.variantId,
planFingerprint: curatorSprintPlanFingerprint({
startDate: sprintStartDate,
variantId: sprintState.variantId,
durationDays: sprintState.durationDays,
access: sprintState.access,
}),
taskPlan: curatorSprintTaskPlan({
startDate: sprintStartDate,
variantId: sprintState.variantId,
durationDays: sprintState.durationDays,
access: sprintState.access,
}),
weeks,
days,
streak,
@@ -1468,7 +1626,7 @@ export async function buildServiceCurationPreview(input: ServiceCurationPreviewI
return {
...planDay,
date,
tasks: buildTasksForPlanDay(startDate, date, planDay, completionRows, [], date, FULL_SPRINT_DURATION_DAYS, userContext.targetRole ?? undefined),
tasks: buildTasksForPlanDay(startDate, date, planDay, completionRows, [], date, FULL_SPRINT_DURATION_DAYS, userContext as CuratorUserContext),
};
});

View File

@@ -120,6 +120,13 @@ export const curatorPlanSchema = z.object({
goals: z.array(z.string()),
generatedAt: z.string(),
durationDays: z.union([z.literal(2), z.literal(7)]),
access: z.enum(["trial", "full"]),
icpId: z.string(),
planFingerprint: z.string(),
taskPlan: z.array(z.object({
dayIndex: z.number().int().min(1),
tasks: z.array(z.object({ id: z.string(), title: z.string() })),
})),
weeks: z.array(curatorWeekSchema).min(1).max(1),
days: z.array(curatorPlanDaySchema).min(2).max(7),
streak: curatorStreakSchema,

View File

@@ -3,6 +3,7 @@ import { db } from "../../db/client.js";
import { growEvents, onboarding } from "../../db/schema.js";
import { asRecord, getString } from "../../events/envelope.js";
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
import { config } from "../../config.js";
import type { CuratorTask } from "./curator-types.js";
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -26,6 +27,10 @@ export type CuratorUserContext = {
latestSummary?: string;
skills: string[];
};
profile: {
resumeAvailable: boolean;
linkedinAvailable: boolean;
};
goals: string[];
pastActivity: {
recentEventCount: number;
@@ -173,7 +178,11 @@ export async function buildCuratorUserContext(userId: string): Promise<CuratorUs
.orderBy(desc(growEvents.occurredAt))
.limit(80);
const qscoreResult = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID);
const [qscoreResult, resumeState, socialState] = await Promise.all([
getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID),
fetchProfileState(config.resumeServiceUrl, userId),
fetchProfileState(config.socialBrandingServiceUrl, userId),
]);
const breakdown = qscoreResult?.breakdown ?? {};
const breakdownSignals = Array.isArray(breakdown.signals) ? breakdown.signals : [];
@@ -184,19 +193,25 @@ export async function buildCuratorUserContext(userId: string): Promise<CuratorUs
...rows.flatMap((row) => goalsFromPayload(row.payload)),
]);
const skills = uniqueStrings(rows.flatMap((row) => stringArray(asRecord(row.payload).skills))).slice(0, 12);
const latestResume = rows
const latestResume = getString(resumeState.resume_summary ?? resumeState.resumeSummary) ?? rows
.map((row) => resumeSummaryFromPayload(row.payload))
.find(Boolean);
const resumeAvailable = Number(resumeState.resume_count ?? 0) > 0 || Boolean(latestResume);
const linkedinAvailable = Boolean(
socialState.linkedin_connected ||
getString(socialState.linkedin_profile_id ?? socialState.profile_url ?? socialState.headline ?? socialState.summary),
);
return {
userId,
targetRole,
experienceLevel: experienceLevelFromOnboarding(onboardingPayload) ?? inferExperienceLevel(corpus),
resume: {
available: Boolean(latestResume || /\bresume|cv|linkedin\b/i.test(corpus)),
available: resumeAvailable,
latestSummary: latestResume,
skills,
},
profile: { resumeAvailable, linkedinAvailable },
goals: goals.length ? goals.slice(0, 8) : targetRole ? [targetRole] : [],
pastActivity: {
recentEventCount: rows.length,
@@ -217,6 +232,21 @@ export async function buildCuratorUserContext(userId: string): Promise<CuratorUs
};
}
async function fetchProfileState(baseUrl: string, userId: string): Promise<Record<string, unknown>> {
if (!baseUrl) return {};
try {
const response = await fetch(new URL(`/api/state/${encodeURIComponent(userId)}`, baseUrl), {
headers: config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : undefined,
signal: AbortSignal.timeout(2_500),
});
if (!response.ok) return {};
const value: unknown = await response.json();
return isRecord(value) ? value : {};
} catch {
return {};
}
}
function experienceLevelFromOnboarding(payload: Record<string, unknown>): CuratorUserContext["experienceLevel"] | null {
const icp = getString(payload.onboarding_icp);
if (icp === "student") return "student";