Fix curator task handoffs and profile state

This commit is contained in:
Sai-karthik
2026-07-12 23:21:52 +00:00
parent c7c6f1b8cc
commit 5f5622b3a0
5 changed files with 72 additions and 19 deletions
+5 -1
View File
@@ -14,9 +14,10 @@ services:
qscore-service-staging: qscore-service-staging:
matchmaking-v2-staging: matchmaking-v2-staging:
user-service-staging: user-service-staging:
social-branding-staging:
environment: environment:
DATABASE_URL: postgres://growqr:growqr@growqr-postgres:5432/growqr 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 SOCIAL_BRANDING_PUBLIC_URL: https://social-staging.gqr.puter.wtf
PRODUCT_SERVICE_TIMEOUT_MS: 10000 PRODUCT_SERVICE_TIMEOUT_MS: 10000
PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS: 240000 PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS: 240000
@@ -48,3 +49,6 @@ networks:
user-service-staging: user-service-staging:
external: true external: true
name: growqr-app_growqr-net name: growqr-app_growqr-net
social-branding-staging:
external: true
name: social-branding_default
+12 -3
View File
@@ -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 (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.experience_history)) userContext.experience_history = resumeState.experience_history;
if (Array.isArray(resumeState.education)) userContext.education = resumeState.education; 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) { if (socialState) {
@@ -534,7 +537,9 @@ async function resolveGrowUserContext(req: Request, userId: string): Promise<Rec
function hasResumeContext(userContext: Record<string, unknown>): boolean { function hasResumeContext(userContext: Record<string, unknown>): boolean {
return 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) || (Array.isArray(userContext.skills) && userContext.skills.length > 0) ||
getString(userContext.current_role) getString(userContext.current_role)
); );
@@ -556,6 +561,9 @@ function composeCandidateProfile(userContext: Record<string, unknown>): string {
if (currentRole) lines.push(`Current role: ${currentRole}`); if (currentRole) lines.push(`Current role: ${currentRole}`);
if (headline && headline.toLowerCase() !== currentRole?.toLowerCase()) lines.push(`LinkedIn headline: ${headline}`); 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); const skills = stringArray(userContext.skills);
if (skills.length) lines.push(`Key skills: ${skills.slice(0, 12).join(", ")}`); 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("; ")}`); if (education.length) lines.push(`Education: ${education.slice(0, 3).join("; ")}`);
const summary = getString(userContext.linkedin_summary); 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"); const text = lines.join("\n");
return text.length > 1200 ? `${text.slice(0, 1200).trimEnd()}…` : text; 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", 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); const candidateProfile = composeCandidateProfile(userContext);
if (candidateProfile) context.candidate_profile = candidateProfile; if (candidateProfile) context.candidate_profile = candidateProfile;
} }
+7 -3
View File
@@ -962,11 +962,15 @@ export function buildCuratorServiceRoute(input: CuratorRouteInput) {
if (input.taskType) state.taskType = input.taskType; if (input.taskType) state.taskType = input.taskType;
if (service.id === "interview-service") { if (service.id === "interview-service") {
state.role = input.targetRole?.trim() || "Product Manager"; const taskText = `${input.taskTitle ?? ""} ${input.taskSubtitle ?? ""}`.toLowerCase();
state.type = "behavioral"; 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.persona = input.personaId ?? "payal";
state.duration = input.durationMinutes ?? 5; state.duration = input.durationMinutes ?? 5;
state.difficulty = input.difficulty ?? "medium"; state.difficulty = input.difficulty ?? (isIntroductionTask ? "easy" : "medium");
state.media = input.requestedMode ?? "video"; state.media = input.requestedMode ?? "video";
} }
+15 -9
View File
@@ -5,7 +5,7 @@ import { listMissionDefinitions } from "../../missions/registry.js";
import { listServiceCapabilities } from "../../workflows/service-capabilities.js"; import { listServiceCapabilities } from "../../workflows/service-capabilities.js";
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js"; import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
import { emitCuratorEvent } from "./curator-events.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 { isCuratorIcpId, type CuratorIcpId } from "./icp-registry.js";
import type { import type {
CuratorPlan, CuratorPlan,
@@ -1155,8 +1155,9 @@ function buildTask(
recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>, recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>,
focusDate: string, focusDate: string,
durationDays: number, durationDays: number,
targetRole?: string, userContext: CuratorUserContext,
): CuratorTask { ): CuratorTask {
const targetRole = userContext.targetRole ?? undefined;
seedTask = { seedTask = {
...normalizeActiveCuratorTaskSeed(seedTask), ...normalizeActiveCuratorTaskSeed(seedTask),
isTrialTask: seedTask.isTrialTask, isTrialTask: seedTask.isTrialTask,
@@ -1211,10 +1212,15 @@ function buildTask(
isPaidTask: seedTask.isPaidTask !== false, isPaidTask: seedTask.isPaidTask !== false,
isRecoveryPractice: Boolean(seedTask.isRecoveryPractice), 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 = { const taskWithRoute = {
...task, ...task,
route: buildCuratorTaskDeepLink(task, targetRole), route: buildCuratorTaskDeepLink(routeTask, targetRole),
status: classifyTaskStatus({ task, focusDate, completionRows, recentRows }), status: profileComplete ? "completed" as const : classifyTaskStatus({ task, focusDate, completionRows, recentRows }),
} satisfies CuratorTask; } satisfies CuratorTask;
return taskWithRoute; return taskWithRoute;
} }
@@ -1227,7 +1233,7 @@ function buildTasksForPlanDay(
recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>, recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>,
focusDate: string, focusDate: string,
durationDays: number, durationDays: number,
targetRole?: string, userContext: CuratorUserContext,
) { ) {
return planDay.plannedTasks.map((task) => ( return planDay.plannedTasks.map((task) => (
buildTask( buildTask(
@@ -1242,7 +1248,7 @@ function buildTasksForPlanDay(
recentRows, recentRows,
focusDate, focusDate,
durationDays, durationDays,
targetRole, userContext,
) )
)); ));
} }
@@ -1266,7 +1272,7 @@ export async function buildCuratorTasks(userId: string, date = todayIso()): Prom
); );
const planDay = adaptedPlanDays[dayIndex - 1]; const planDay = adaptedPlanDays[dayIndex - 1];
if (!planDay) return []; 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> { export async function buildCuratorStreak(userId: string): Promise<CuratorStreak> {
@@ -1333,7 +1339,7 @@ async function buildCuratorSprintInternal(
const date = addDaysIso(sprintStartDate, index); const date = addDaysIso(sprintStartDate, index);
const planDay = planDays[index]!; const planDay = planDays[index]!;
const tasks = date <= focusDate 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 completedCount = tasks.filter((task) => task.status === "completed").length;
const unlockState = focusDate > date ? "completed" : focusDate === date ? "active" : "upcoming"; const unlockState = focusDate > date ? "completed" : focusDate === date ? "active" : "upcoming";
@@ -1616,7 +1622,7 @@ export async function buildServiceCurationPreview(input: ServiceCurationPreviewI
return { return {
...planDay, ...planDay,
date, 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),
}; };
}); });
+33 -3
View File
@@ -3,6 +3,7 @@ import { db } from "../../db/client.js";
import { growEvents, onboarding } from "../../db/schema.js"; import { growEvents, onboarding } from "../../db/schema.js";
import { asRecord, getString } from "../../events/envelope.js"; import { asRecord, getString } from "../../events/envelope.js";
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js"; import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
import { config } from "../../config.js";
import type { CuratorTask } from "./curator-types.js"; import type { CuratorTask } from "./curator-types.js";
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
@@ -26,6 +27,10 @@ export type CuratorUserContext = {
latestSummary?: string; latestSummary?: string;
skills: string[]; skills: string[];
}; };
profile: {
resumeAvailable: boolean;
linkedinAvailable: boolean;
};
goals: string[]; goals: string[];
pastActivity: { pastActivity: {
recentEventCount: number; recentEventCount: number;
@@ -173,7 +178,11 @@ export async function buildCuratorUserContext(userId: string): Promise<CuratorUs
.orderBy(desc(growEvents.occurredAt)) .orderBy(desc(growEvents.occurredAt))
.limit(80); .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 breakdown = qscoreResult?.breakdown ?? {};
const breakdownSignals = Array.isArray(breakdown.signals) ? breakdown.signals : []; 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)), ...rows.flatMap((row) => goalsFromPayload(row.payload)),
]); ]);
const skills = uniqueStrings(rows.flatMap((row) => stringArray(asRecord(row.payload).skills))).slice(0, 12); 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)) .map((row) => resumeSummaryFromPayload(row.payload))
.find(Boolean); .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 { return {
userId, userId,
targetRole, targetRole,
experienceLevel: experienceLevelFromOnboarding(onboardingPayload) ?? inferExperienceLevel(corpus), experienceLevel: experienceLevelFromOnboarding(onboardingPayload) ?? inferExperienceLevel(corpus),
resume: { resume: {
available: Boolean(latestResume || /\bresume|cv|linkedin\b/i.test(corpus)), available: resumeAvailable,
latestSummary: latestResume, latestSummary: latestResume,
skills, skills,
}, },
profile: { resumeAvailable, linkedinAvailable },
goals: goals.length ? goals.slice(0, 8) : targetRole ? [targetRole] : [], goals: goals.length ? goals.slice(0, 8) : targetRole ? [targetRole] : [],
pastActivity: { pastActivity: {
recentEventCount: rows.length, 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 { function experienceLevelFromOnboarding(payload: Record<string, unknown>): CuratorUserContext["experienceLevel"] | null {
const icp = getString(payload.onboarding_icp); const icp = getString(payload.onboarding_icp);
if (icp === "student") return "student"; if (icp === "student") return "student";