Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions orchestrator/src/server/services/design-resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("./index")>()),
...designResumeService,
}));
vi.mock("@server/infra/request-context", () => requestContext);
vi.mock("pdf-parse", () => ({
default: vi.fn().mockResolvedValue({ text: "Jane Doe\nSoftware Engineer" }),
Expand Down
120 changes: 119 additions & 1 deletion orchestrator/src/server/services/design-resume/import-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("./index")>()),
...designResumeService,
}));
vi.mock("@server/infra/request-context", () => requestContext);
vi.mock("@server/services/llm/codex/client", () => ({
CodexClient: MockCodexClientClass,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 10 additions & 3 deletions orchestrator/src/server/services/design-resume/import-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
45 changes: 42 additions & 3 deletions orchestrator/src/server/services/design-resume/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.") {
Expand Down Expand Up @@ -714,12 +751,14 @@ export async function importDesignResumeFromReactiveResume(): Promise<DesignResu
throw badRequest("Reactive Resume base resume is empty or invalid.");
}

const validated = withReactiveResumePictureUrl(
validateIncomingDesignResumeDocument(upstreamResume.data),
const validated = validateIncomingDesignResumeDocument(upstreamResume.data);
const withResolvedPictureUrls = withReactiveResumePictureUrl(
validated,
await resolveReactiveResumePublicBaseUrl(),
);
const withProjectIds = ensureImportedProjectIds(withResolvedPictureUrls);
const imported = await replaceCurrentDesignResumeDocument({
resumeJson: validated,
resumeJson: withProjectIds,
sourceResumeId: resumeId,
sourceMode: "v5",
importedAt: new Date().toISOString(),
Expand Down
Loading