fix: enrich interview service context
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user