diff --git a/orchestrator/src/server/services/design-resume.test.ts b/orchestrator/src/server/services/design-resume.test.ts index 1c733596..644bfb06 100644 --- a/orchestrator/src/server/services/design-resume.test.ts +++ b/orchestrator/src/server/services/design-resume.test.ts @@ -229,6 +229,80 @@ describe("design resume service", () => { ); }); + it("generates missing project ids when importing from Reactive Resume", async () => { + const upstreamResume = makeValidResumeJson({ + sections: { + ...(buildDefaultReactiveResumeDocument().sections as Record< + string, + unknown + >), + projects: { + title: "", + columns: 1, + hidden: false, + items: [ + { + id: "", + hidden: false, + name: "Blank ID", + period: "2024", + website: { url: "", label: "" }, + description: "Blank ID project", + options: { showLinkInTitle: false }, + }, + { + id: " ", + hidden: false, + name: "Whitespace ID", + period: "2025", + website: { url: "", label: "" }, + description: "Whitespace ID project", + options: { showLinkInTitle: false }, + }, + { + id: "project-keep", + hidden: false, + name: "Existing ID", + period: "2026", + website: { url: "", label: "" }, + description: "Existing ID project", + options: { showLinkInTitle: false }, + }, + ], + }, + }, + }); + vi.mocked(getResume).mockResolvedValueOnce({ + id: "rx-1", + mode: "v5", + data: upstreamResume, + } as never); + + const result = await importDesignResumeFromReactiveResume(); + const projectIds = result.resumeJson.sections.projects.items.map( + (project) => project.id, + ); + + expect(projectIds[0]).toEqual(expect.any(String)); + expect(projectIds[0]?.trim()).not.toBe(""); + expect(projectIds[1]).toEqual(expect.any(String)); + expect(projectIds[1]?.trim()).not.toBe(""); + expect(projectIds[2]).toBe("project-keep"); + expect(repo.upsertDesignResumeDocument).toHaveBeenCalledWith( + expect.objectContaining({ + resumeJson: expect.objectContaining({ + sections: expect.objectContaining({ + projects: expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ id: "project-keep" }), + ]), + }), + }), + }), + }), + ); + }); + it("preserves custom field titles through stored document validation", async () => { const resumeJson = makeValidResumeJson({ basics: { diff --git a/orchestrator/src/server/services/design-resume/import-file.gemini-cli.test.ts b/orchestrator/src/server/services/design-resume/import-file.gemini-cli.test.ts index 452de7a6..fcc99f52 100644 --- a/orchestrator/src/server/services/design-resume/import-file.gemini-cli.test.ts +++ b/orchestrator/src/server/services/design-resume/import-file.gemini-cli.test.ts @@ -24,7 +24,10 @@ const { callJsonMock, MockGeminiCliClass } = vi.hoisted(() => { }); vi.mock("@server/services/modelSelection", () => modelSelection); -vi.mock("./index", () => designResumeService); +vi.mock("./index", async (importOriginal) => ({ + ...(await importOriginal()), + ...designResumeService, +})); vi.mock("@server/infra/request-context", () => requestContext); vi.mock("pdf-parse", () => ({ default: vi.fn().mockResolvedValue({ text: "Jane Doe\nSoftware Engineer" }), diff --git a/orchestrator/src/server/services/design-resume/import-file.test.ts b/orchestrator/src/server/services/design-resume/import-file.test.ts index e2ab91eb..873fa886 100644 --- a/orchestrator/src/server/services/design-resume/import-file.test.ts +++ b/orchestrator/src/server/services/design-resume/import-file.test.ts @@ -25,7 +25,10 @@ const { codexCallJsonMock, MockCodexClientClass } = vi.hoisted(() => { }); vi.mock("@server/services/modelSelection", () => modelSelection); -vi.mock("./index", () => designResumeService); +vi.mock("./index", async (importOriginal) => ({ + ...(await importOriginal()), + ...designResumeService, +})); vi.mock("@server/infra/request-context", () => requestContext); vi.mock("@server/services/llm/codex/client", () => ({ CodexClient: MockCodexClientClass, @@ -167,6 +170,57 @@ describe("importDesignResumeFromFile", () => { ); }); + it("generates missing project ids when importing Reactive Resume JSON", async () => { + const resumeJson = buildDefaultReactiveResumeDocument() as DesignResumeJson; + resumeJson.sections.projects.items = [ + { + id: "", + hidden: false, + name: "Blank ID", + period: "2024", + website: { url: "", label: "" }, + description: "Blank ID project", + options: { showLinkInTitle: false }, + }, + { + id: " ", + hidden: false, + name: "Whitespace ID", + period: "2025", + website: { url: "", label: "" }, + description: "Whitespace ID project", + options: { showLinkInTitle: false }, + }, + { + id: "project-keep", + hidden: false, + name: "Existing ID", + period: "2026", + website: { url: "", label: "" }, + description: "Existing ID project", + options: { showLinkInTitle: false }, + }, + ]; + + const result = await importDesignResumeFromFile({ + fileName: "resume.json", + mediaType: "application/json", + dataBase64: Buffer.from(JSON.stringify(resumeJson), "utf8").toString( + "base64", + ), + }); + + const projectIds = result.resumeJson.sections.projects.items.map( + (project) => project.id, + ); + + expect(projectIds[0]).toEqual(expect.any(String)); + expect(projectIds[0]?.trim()).not.toBe(""); + expect(projectIds[1]).toEqual(expect.any(String)); + expect(projectIds[1]?.trim()).not.toBe(""); + expect(projectIds[2]).toBe("project-keep"); + }); + it("rejects non Reactive Resume JSON files", async () => { await expect( importDesignResumeFromFile({ @@ -259,6 +313,70 @@ describe("importDesignResumeFromFile", () => { expect(result.title).toBe("Taylor Resume"); }); + it("generates missing project ids when importing AI-extracted resume files", async () => { + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + output_text: `{ + "basics": { "name": "Taylor Quinn" }, + "sections": { + "projects": { + "items": [ + { + "id": "", + "name": "Blank ID", + "period": "2024", + "description": "Blank ID project" + }, + { + "id": " ", + "name": "Whitespace ID", + "period": "2025", + "description": "Whitespace ID project" + }, + { + "name": "Missing ID", + "period": "2026", + "description": "Missing ID project" + }, + { + "id": "project-keep", + "name": "Existing ID", + "period": "2027", + "description": "Existing ID project" + } + ] + } + } +}`, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + + const result = await importDesignResumeFromFile({ + fileName: "resume.pdf", + mediaType: "application/pdf", + dataBase64: Buffer.from("pdf-data").toString("base64"), + }); + + const projectIds = result.resumeJson.sections.projects.items.map( + (project) => project.id, + ); + + expect(projectIds).toHaveLength(4); + expect(projectIds[0]).toEqual(expect.any(String)); + expect(projectIds[0]?.trim()).not.toBe(""); + expect(projectIds[1]).toEqual(expect.any(String)); + expect(projectIds[1]?.trim()).not.toBe(""); + expect(projectIds[2]).toEqual(expect.any(String)); + expect(projectIds[2]?.trim()).not.toBe(""); + expect(projectIds[3]).toBe("project-keep"); + }); + it("extracts DOCX text locally before sending it to Gemini", async () => { modelSelection.resolveLlmRuntimeSettings.mockResolvedValueOnce({ provider: "gemini", diff --git a/orchestrator/src/server/services/design-resume/import-file.ts b/orchestrator/src/server/services/design-resume/import-file.ts index b630377b..ecc15303 100644 --- a/orchestrator/src/server/services/design-resume/import-file.ts +++ b/orchestrator/src/server/services/design-resume/import-file.ts @@ -28,7 +28,10 @@ import type { DesignResumeDocument, DesignResumeJson } from "@shared/types"; import { jsonrepair } from "jsonrepair"; import { buildHeaders, getResponseDetail, joinUrl } from "../llm/utils/http"; import { parseErrorMessage, truncate } from "../llm/utils/string"; -import { replaceCurrentDesignResumeDocument } from "./index"; +import { + ensureImportedProjectIds, + replaceCurrentDesignResumeDocument, +} from "./index"; type SupportedImportMediaType = | "application/pdf" @@ -1758,7 +1761,9 @@ export async function importDesignResumeFromFile( }); try { - const resumeJson = parseReactiveResumeJsonFile(decoded.toString("utf8")); + const resumeJson = ensureImportedProjectIds( + parseReactiveResumeJsonFile(decoded.toString("utf8")), + ); const saved = await replaceCurrentDesignResumeDocument({ importedAt: new Date().toISOString(), resumeJson, @@ -1949,7 +1954,9 @@ export async function importDesignResumeFromFile( totalElapsedMs: elapsedMs(importStartedAt), }); const normalizeStartedAt = Date.now(); - const normalized = sanitizeNormalizedResume(parsed); + const normalized = ensureImportedProjectIds( + sanitizeNormalizedResume(parsed), + ); logger.info("Design resume file import normalized", { requestId: requestId ?? null, provider, diff --git a/orchestrator/src/server/services/design-resume/index.ts b/orchestrator/src/server/services/design-resume/index.ts index d9069b7a..a9681132 100644 --- a/orchestrator/src/server/services/design-resume/index.ts +++ b/orchestrator/src/server/services/design-resume/index.ts @@ -84,6 +84,43 @@ function asArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } +export function ensureImportedProjectIds( + resumeJson: DesignResumeJson, +): DesignResumeJson { + const sections = asRecord(resumeJson.sections); + const projects = asRecord(sections?.projects); + const items = asArray(projects?.items); + if (!sections || !projects || items.length === 0) return resumeJson; + + let changed = false; + const nextItems = items.map((item) => { + const record = asRecord(item); + if (!record) return item; + + const id = toText(record.id).trim(); + if (id) return item; + + changed = true; + return { + ...record, + id: createId(), + }; + }); + + if (!changed) return resumeJson; + + return { + ...resumeJson, + sections: { + ...sections, + projects: { + ...projects, + items: nextItems, + }, + } as DesignResumeJson["sections"], + }; +} + function formatValidationMessage(prefix: string, error: unknown): string { const detail = getResumeSchemaValidationMessage(error); if (!detail || detail === "Resume schema validation failed.") { @@ -714,12 +751,14 @@ export async function importDesignResumeFromReactiveResume(): Promise