import type { ResumeData } from "@reactive-resume/schema/resume/data"; import { deepmergeCustom } from "deepmerge-ts"; import { jsonrepair } from "jsonrepair"; import { flattenError, ZodError } from "zod"; import { resumeDataSchema } from "@reactive-resume/schema/resume/data"; import { defaultResumeData } from "@reactive-resume/schema/resume/default"; import { generateId } from "@reactive-resume/utils/string"; import { buildAiExtractionTemplate } from "./extraction-template"; function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } const aiExtractionTemplate = buildAiExtractionTemplate(); type SectionKey = keyof typeof sectionRequiredFieldMap; type CoercionEntry = { path: string; fromType: string; toType: string; }; type DroppedSectionItemEntry = { section: SectionKey; index: number; reason: string; }; type ResumeSanitizationDiagnostics = { coercions: CoercionEntry[]; droppedSectionItems: DroppedSectionItemEntry[]; salvageApplied: boolean; }; type ResumeSanitizationResult = { data: ResumeData; diagnostics: ResumeSanitizationDiagnostics; }; const mergeDefaultsDeep = deepmergeCustom({ filterValues: (values) => values.filter((value) => value !== undefined && value !== null), mergeArrays: false, }); const sectionRequiredFieldMap = { profiles: "network", experience: "company", education: "school", projects: "name", skills: "name", languages: "language", interests: "name", awards: "title", certifications: "title", publications: "title", volunteer: "organization", references: "name", } as const; function getValueType(value: unknown): string { if (value === null) return "null"; if (Array.isArray(value)) return "array"; return typeof value; } function mergeDefaults, S extends Record>( target: T, source: S, ): T & S { if (!isObject(target) || !isObject(source)) { return (source !== undefined && source !== null ? source : target) as T & S; } return mergeDefaultsDeep(target, source) as T & S; } function coerceBoolean(value: unknown): boolean | undefined { if (typeof value === "boolean") return value; if (typeof value === "number") { if (value === 1) return true; if (value === 0) return false; return; } if (typeof value !== "string") return; const normalized = value.trim().toLowerCase(); if (normalized === "true" || normalized === "1") return true; if (normalized === "false" || normalized === "0") return false; return; } function coerceNumber(value: unknown): number | undefined { if (typeof value === "number") return Number.isFinite(value) ? value : undefined; if (typeof value !== "string") return; const normalized = value.trim(); if (normalized.length === 0) return; const coerced = Number(normalized); return Number.isFinite(coerced) ? coerced : undefined; } function coerceValueAgainstTemplate( value: unknown, template: unknown, path: string, diagnostics: ResumeSanitizationDiagnostics, ): unknown { if (value === undefined || value === null) return value; if (template === undefined || template === null) return value; if (typeof template === "boolean") { const coerced = coerceBoolean(value); if (coerced === undefined) return value; if (coerced === value) return value; diagnostics.coercions.push({ path, fromType: getValueType(value), toType: "boolean" }); return coerced; } if (typeof template === "number") { const coerced = coerceNumber(value); if (coerced === undefined) return value; if (coerced === value) return value; diagnostics.coercions.push({ path, fromType: getValueType(value), toType: "number" }); return coerced; } if (Array.isArray(template) && Array.isArray(value)) { const itemTemplate = template[0]; if (itemTemplate === undefined) return value; return value.map((entry, index) => coerceValueAgainstTemplate(entry, itemTemplate, `${path}[${index}]`, diagnostics), ); } if (!isObject(template) || !isObject(value)) return value; const output: Record = { ...value }; for (const key of Object.keys(template)) { if (!(key in output)) continue; const nextPath = path.length > 0 ? `${path}.${key}` : key; output[key] = coerceValueAgainstTemplate(output[key], template[key], nextPath, diagnostics); } return output; } function getJsonBoundaryIndices(value: string): { first: number; last: number } { const firstCurly = value.indexOf("{"); const firstSquare = value.indexOf("["); const lastCurly = value.lastIndexOf("}"); const lastSquare = value.lastIndexOf("]"); let first = -1; if (firstCurly !== -1 && firstSquare !== -1) { first = Math.min(firstCurly, firstSquare); } else { first = Math.max(firstCurly, firstSquare); } return { first, last: Math.max(lastCurly, lastSquare) }; } function normalizeResumeDataForSchema(data: Record, diagnostics: ResumeSanitizationDiagnostics) { if (!isObject(data)) return data; if (!isObject(data.sections)) return data; const normalizedSections: Record = { ...data.sections }; for (const sectionKey of Object.keys(sectionRequiredFieldMap) as SectionKey[]) { const section = normalizedSections[sectionKey]; if (!isObject(section)) continue; if (!Array.isArray(section.items)) continue; const itemTemplate = aiExtractionTemplate.sections[sectionKey].items[0] as Record; const requiredField = sectionRequiredFieldMap[sectionKey]; const normalizedItems = section.items .filter((item): item is Record => isObject(item)) .map((item) => { const mergedItem = mergeDefaults(itemTemplate, item); return coerceValueAgainstTemplate( mergedItem, itemTemplate, `sections.${sectionKey}.items`, diagnostics, ) as Record; }) .filter((item, index) => { const requiredValue = item[requiredField]; if (typeof requiredValue !== "string" || requiredValue.trim().length === 0) { diagnostics.salvageApplied = true; diagnostics.droppedSectionItems.push({ section: sectionKey, index, reason: `missing required "${requiredField}"`, }); return false; } return true; }) .map((item) => { const normalizedItem = { ...item }; if (typeof normalizedItem.id !== "string" || normalizedItem.id.trim().length === 0) { diagnostics.salvageApplied = true; normalizedItem.id = generateId(); } if (typeof normalizedItem.hidden !== "boolean") { diagnostics.salvageApplied = true; normalizedItem.hidden = false; } return normalizedItem; }); normalizedSections[sectionKey] = { ...section, items: normalizedItems }; } return { ...data, sections: normalizedSections }; } export function sanitizeAndParseResumeJson(resultText: string): ResumeSanitizationResult { let jsonString = resultText; const { first, last } = getJsonBoundaryIndices(jsonString); if (first !== -1 && last !== -1 && last >= first) { jsonString = jsonString.substring(first, last + 1); } try { const diagnostics: ResumeSanitizationDiagnostics = { coercions: [], droppedSectionItems: [], salvageApplied: false, }; const repairedJson = jsonrepair(jsonString); const parsedJson = JSON.parse(repairedJson); const mergedData = mergeDefaults(defaultResumeData, parsedJson); const coercedData = coerceValueAgainstTemplate(mergedData, defaultResumeData, "", diagnostics); const normalizedData = normalizeResumeDataForSchema(coercedData as Record, diagnostics); const data = resumeDataSchema.parse({ ...normalizedData, customSections: [], picture: defaultResumeData.picture, metadata: defaultResumeData.metadata, }); return { data, diagnostics }; } catch (error: unknown) { if (error instanceof ZodError) { console.error("Zod validation failed during resume parsing:", flattenError(error)); throw error; } console.error("Unknown error during resume data validation:", error); throw new Error("An unknown error occurred while validating the merged resume data."); } }