Fix curator task handoffs and profile state
This commit is contained in:
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user