Fix curator task handoffs and profile state
This commit is contained in:
+12
-3
@@ -519,6 +519,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 +537,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 +561,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 +599,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 +621,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;
|
||||
}
|
||||
|
||||
@@ -962,11 +962,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|self[ -]?introduction|professional introduction|client pitch|executive introduction/.test(taskText);
|
||||
state.role = isIntroductionTask
|
||||
? `${input.targetRole?.trim() || "Professional"} self-introduction`
|
||||
: input.targetRole?.trim() || "General professional";
|
||||
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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -1155,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,
|
||||
@@ -1211,10 +1212,15 @@ function buildTask(
|
||||
isPaidTask: seedTask.isPaidTask !== false,
|
||||
isRecoveryPractice: Boolean(seedTask.isRecoveryPractice),
|
||||
};
|
||||
const isProfileTask = /complete (?:your )?(?:growqr |professional |executive |freelancer )?profile/i.test(`${task.title} ${task.subtitle}`);
|
||||
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;
|
||||
}
|
||||
@@ -1227,7 +1233,7 @@ function buildTasksForPlanDay(
|
||||
recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>,
|
||||
focusDate: string,
|
||||
durationDays: number,
|
||||
targetRole?: string,
|
||||
userContext: CuratorUserContext,
|
||||
) {
|
||||
return planDay.plannedTasks.map((task) => (
|
||||
buildTask(
|
||||
@@ -1242,7 +1248,7 @@ function buildTasksForPlanDay(
|
||||
recentRows,
|
||||
focusDate,
|
||||
durationDays,
|
||||
targetRole,
|
||||
userContext,
|
||||
)
|
||||
));
|
||||
}
|
||||
@@ -1266,7 +1272,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> {
|
||||
@@ -1333,7 +1339,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";
|
||||
@@ -1616,7 +1622,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),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user