fix: enrich interview service context

This commit is contained in:
-Puter
2026-06-06 01:22:44 +05:30
parent c47e6de526
commit aa8f2853b2
2 changed files with 210 additions and 23 deletions

View File

@@ -119,18 +119,183 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
});
}
async function getUserServicePreferences(req: Request): Promise<Record<string, unknown> | undefined> {
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>; preferences?: Record<string, unknown> }> {
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<string, unknown> | null;
const preferences = userProfile?.preferences;
return preferences && typeof preferences === "object" && !Array.isArray(preferences)
? preferences as Record<string, unknown>
: undefined;
return {
userProfile: userProfile ?? undefined,
preferences: isRecord(preferences) ? preferences : {},
};
}
async function getUserServicePreferences(req: Request): Promise<Record<string, unknown> | undefined> {
return (await getUserServiceProfile(req)).preferences;
}
async function getServiceState(baseUrl: string, path: string): Promise<Record<string, unknown> | 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<string>();
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<Record<string, unknown>> {
const { userProfile } = await getUserServiceProfile(req);
const userContext: Record<string, unknown> = { ...(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<string, unknown>): 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<string, unknown>): 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, unknown>): 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<string>();
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<JsonObject> {
const userContext = await resolveGrowUserContext(req, userId).catch((err) => {
log.warn({ err, userId }, "failed to resolve Grow user context for interview configure");
return {} as Record<string, unknown>;
});
const incomingContext = isRecord(body.context) ? body.context : {};
const context: Record<string, unknown> = {
...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<string, unknown>;
}),
]);
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<JsonObject>();
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<string, unknown>;
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<JsonObject>())));
app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId"), await c.req.json<JsonObject>())));
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<JsonObject>())));
app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"), await c.req.json<JsonObject>())));
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) => {

View File

@@ -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 = {