diff --git a/src/routes/services.ts b/src/routes/services.ts index a8fca25..7576795 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -119,18 +119,183 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) { }); } -async function getUserServicePreferences(req: Request): Promise | undefined> { +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : []; +} + +async function getUserServiceProfile(req: Request): Promise<{ userProfile?: Record; preferences?: Record }> { const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, "")); const headers = new Headers(req.headers); headers.delete("host"); headers.delete("cookie"); const res = await fetch(target, { method: "GET", headers }); - if (!res.ok) return undefined; + if (!res.ok) return {}; const userProfile = await res.json().catch(() => null) as Record | null; const preferences = userProfile?.preferences; - return preferences && typeof preferences === "object" && !Array.isArray(preferences) - ? preferences as Record - : undefined; + return { + userProfile: userProfile ?? undefined, + preferences: isRecord(preferences) ? preferences : {}, + }; +} + +async function getUserServicePreferences(req: Request): Promise | undefined> { + return (await getUserServiceProfile(req)).preferences; +} + +async function getServiceState(baseUrl: string, path: string): Promise | undefined> { + const target = new URL(path, baseUrl.replace(/\/$/, "")); + const res = await fetch(target, { + method: "GET", + headers: { + ...(config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : {}), + }, + }).catch(() => null); + if (!res?.ok) return undefined; + const json = await res.json().catch(() => null); + return isRecord(json) ? json : undefined; +} + +function mergeUniqueSkills(existing: unknown, incoming: unknown): string[] { + const seen = new Set(); + const merged: string[] = []; + for (const skill of [...stringArray(existing), ...stringArray(incoming)]) { + const key = skill.toLowerCase(); + if (!key || seen.has(key)) continue; + seen.add(key); + merged.push(skill); + } + return merged; +} + +async function resolveGrowUserContext(req: Request, userId: string): Promise> { + const { userProfile } = await getUserServiceProfile(req); + const userContext: Record = { ...(userProfile ?? {}) }; + userContext.clerk_id = String(userContext.clerk_id ?? userId); + + const clerkId = String(userContext.clerk_id || userId); + const [resumeState, socialState] = await Promise.all([ + getServiceState(config.resumeServiceUrl, `/api/state/${encodeURIComponent(clerkId)}`), + getServiceState(config.socialBrandingServiceUrl, `/api/state/${encodeURIComponent(clerkId)}`), + ]); + + if (resumeState) { + const resumeSkills = resumeState.skills ?? [ + ...stringArray(resumeState.technical_skills), + ...stringArray(resumeState.soft_skills), + ]; + userContext.skills = mergeUniqueSkills(userContext.skills, resumeSkills); + if (resumeState.current_role && !userContext.current_role) userContext.current_role = resumeState.current_role; + 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; + } + + if (socialState) { + userContext.skills = mergeUniqueSkills(userContext.skills, socialState.skills ?? socialState.linkedin_skills); + if (socialState.headline) userContext.linkedin_headline = socialState.headline; + if (socialState.summary) userContext.linkedin_summary = socialState.summary; + if (Array.isArray(socialState.experience)) userContext.linkedin_experience = socialState.experience; + userContext.linkedin_connected = Boolean(socialState.linkedin_connected); + } + + return userContext; +} + +function hasResumeContext(userContext: Record): boolean { + return Boolean( + (Array.isArray(userContext.experience_history) && userContext.experience_history.length > 0) || + (Array.isArray(userContext.skills) && userContext.skills.length > 0) || + getString(userContext.current_role) + ); +} + +function hasLinkedInContext(userContext: Record): boolean { + return Boolean( + userContext.linkedin_connected || + getString(userContext.linkedin_headline) || + getString(userContext.linkedin_summary) || + (Array.isArray(userContext.linkedin_experience) && userContext.linkedin_experience.length > 0) + ); +} + +function composeCandidateProfile(userContext: Record): string { + const lines: string[] = []; + const currentRole = getString(userContext.current_role); + const headline = getString(userContext.linkedin_headline); + if (currentRole) lines.push(`Current role: ${currentRole}`); + if (headline && headline.toLowerCase() !== currentRole?.toLowerCase()) lines.push(`LinkedIn headline: ${headline}`); + + const skills = stringArray(userContext.skills); + if (skills.length) lines.push(`Key skills: ${skills.slice(0, 12).join(", ")}`); + + const roles: string[] = []; + const seen = new Set(); + const addRole = (title?: unknown, company?: unknown) => { + const roleTitle = String(title ?? "").trim(); + const roleCompany = String(company ?? "").trim(); + if (!roleTitle && !roleCompany) return; + const key = `${roleTitle.toLowerCase()}|${roleCompany.toLowerCase()}`; + if (seen.has(key)) return; + seen.add(key); + roles.push(roleTitle && roleCompany ? `${roleTitle} at ${roleCompany}` : roleTitle || roleCompany); + }; + for (const item of Array.isArray(userContext.experience_history) ? userContext.experience_history : []) { + if (isRecord(item)) addRole(item.position ?? item.title, item.company); + } + for (const item of Array.isArray(userContext.linkedin_experience) ? userContext.linkedin_experience : []) { + if (isRecord(item)) addRole(item.title ?? item.position, item.company); + } + if (roles.length) lines.push(`Experience: ${roles.slice(0, 6).join("; ")}`); + + const education: string[] = []; + for (const item of Array.isArray(userContext.education) ? userContext.education : []) { + if (!isRecord(item)) continue; + const degree = getString(item.degree); + const field = getString(item.field_of_study); + const institution = getString(item.institution ?? item.school); + let piece = [degree, field].filter(Boolean).join(" ").trim(); + if (institution) piece = piece ? `${piece} — ${institution}` : institution; + if (piece) education.push(piece); + } + 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}`); + + const text = lines.join("\n"); + return text.length > 1200 ? `${text.slice(0, 1200).trimEnd()}…` : text; +} + +async function buildPersonalizedConfigurePayload(req: Request, body: JsonObject, userId: string): Promise { + const userContext = await resolveGrowUserContext(req, userId).catch((err) => { + log.warn({ err, userId }, "failed to resolve Grow user context for interview configure"); + return {} as Record; + }); + const incomingContext = isRecord(body.context) ? body.context : {}; + const context: Record = { + ...incomingContext, + candidate_name: getString(incomingContext.candidate_name) ?? getString(userContext.first_name) ?? "", + target_role: getString(incomingContext.target_role) ?? "", + company_name: getString(incomingContext.company_name) ?? "", + job_description: getString(incomingContext.job_description) ?? "", + difficulty: getString(incomingContext.difficulty) ?? "medium", + }; + + if (incomingContext.personalize) { + const candidateProfile = composeCandidateProfile(userContext); + if (candidateProfile) context.candidate_profile = candidateProfile; + } + + return { + ...body, + user_id: String(body.user_id ?? userId), + org_id: String(body.org_id ?? "growqr"), + context, + }; } async function proxySocialRequest(req: Request, rest: string, userId: string) { @@ -273,15 +438,25 @@ export function serviceRoutes() { }); }); - app.get("/interview/page-state", async (c) => c.json(await interviewService.pageState(c.get("userId")))); + app.get("/interview/page-state", async (c) => { + const userId = c.get("userId"); + const [state, userContext] = await Promise.all([ + interviewService.pageState(userId), + resolveGrowUserContext(c.req.raw, userId).catch((err) => { + log.warn({ err, userId }, "failed to resolve Grow user context for interview page-state"); + return {} as Record; + }), + ]); + return c.json({ + ...state, + resume_available: hasResumeContext(userContext), + linkedin_available: hasLinkedInContext(userContext), + }); + }); app.post("/interview/configure", async (c) => { const userId = c.get("userId"); const body = await c.req.json(); - const payload = { - ...body, - user_id: String(body.user_id ?? userId), - org_id: String(body.org_id ?? "growqr"), - } satisfies JsonObject; + const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, userId); const result = await interviewService.configure(payload); const resultObj = result as Record; await recordGatewayEvent({ @@ -320,8 +495,8 @@ export function serviceRoutes() { }); app.get("/interview/leaderboard", async (c) => c.json(await interviewService.leaderboard())); app.get("/interview/artifacts/:sessionId/:artifactType", async (c) => c.json(await interviewService.artifact(c.req.param("sessionId"), c.req.param("artifactType")))); - app.post("/interview/sessions/:sessionId/video/upload-url", async (c) => c.json(await interviewService.createVideoUploadUrl(c.req.param("sessionId"), await c.req.json()))); - app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId"), await c.req.json()))); + app.post("/interview/sessions/:sessionId/video/upload-url", async (c) => c.json(await interviewService.createVideoUploadUrl(c.req.param("sessionId")))); + app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId")))); app.get("/roleplay/page-state", async (c) => c.json(await roleplayService.pageState(c.get("userId")))); app.post("/roleplay/configure", async (c) => { @@ -371,8 +546,8 @@ export function serviceRoutes() { }); app.get("/roleplay/leaderboard", async (c) => c.json(await roleplayService.leaderboard())); app.get("/roleplay/artifacts/:sessionId/:artifactType", async (c) => c.json(await roleplayService.artifact(c.req.param("sessionId"), c.req.param("artifactType")))); - app.post("/roleplay/sessions/:sessionId/video/upload-url", async (c) => c.json(await roleplayService.createVideoUploadUrl(c.req.param("sessionId"), await c.req.json()))); - app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"), await c.req.json()))); + app.post("/roleplay/sessions/:sessionId/video/upload-url", async (c) => c.json(await roleplayService.createVideoUploadUrl(c.req.param("sessionId")))); + app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId")))); app.get("/resume/state/:clerkId", async (c) => c.json(await resumeService.state(c.req.param("clerkId")))); app.post("/resume/tasks", async (c) => { diff --git a/src/services/product-service-clients.ts b/src/services/product-service-clients.ts index 0f3cdf7..cd8978f 100644 --- a/src/services/product-service-clients.ts +++ b/src/services/product-service-clients.ts @@ -48,10 +48,16 @@ export const interviewService = { leaderboard: () => serviceJson(config.interviewServiceUrl, "/api/v1/leaderboard"), artifact: (sessionId: string, artifactType: string) => serviceJson(config.interviewServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`), - createVideoUploadUrl: (sessionId: string, payload: JsonObject) => - serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, { body: payload }), - markVideoUploaded: (sessionId: string, payload: JsonObject) => - serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, { body: payload }), + createVideoUploadUrl: (sessionId: string, payload?: JsonObject) => + serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, { + method: "POST", + ...(payload === undefined ? {} : { body: payload }), + }), + markVideoUploaded: (sessionId: string, payload?: JsonObject) => + serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, { + method: "POST", + ...(payload === undefined ? {} : { body: payload }), + }), }; export const roleplayService = { @@ -75,10 +81,16 @@ export const roleplayService = { leaderboard: () => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/leaderboard"), artifact: (sessionId: string, artifactType: string) => serviceJson(config.roleplayServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`), - createVideoUploadUrl: (sessionId: string, payload: JsonObject) => - serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, { body: payload }), - markVideoUploaded: (sessionId: string, payload: JsonObject) => - serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, { body: payload }), + createVideoUploadUrl: (sessionId: string, payload?: JsonObject) => + serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, { + method: "POST", + ...(payload === undefined ? {} : { body: payload }), + }), + markVideoUploaded: (sessionId: string, payload?: JsonObject) => + serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, { + method: "POST", + ...(payload === undefined ? {} : { body: payload }), + }), }; export const resumeService = {