mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-26 18:04:45 +10:00
Merge branch 'main' of github.com:amruthpillai/reactive-resume
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@reactive-resume/ai",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
"./store": "./src/store.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./prompts": "./src/prompts.ts",
|
||||
"./tools/patch-resume": "./src/tools/patch-resume.ts",
|
||||
"./resume/extraction-template": "./src/resume/extraction-template.ts",
|
||||
"./resume/sanitize": "./src/resume/sanitize.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
|
||||
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"immer": "^11.1.7",
|
||||
"jsonrepair": "^3.14.0",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260507.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module "*.md?raw" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/// <reference path="./markdown-raw.d.ts" />
|
||||
|
||||
import analyzeResumeSystemPrompt from "./prompts/analyze-resume-system.md?raw";
|
||||
import chatSystemPromptTemplate from "./prompts/chat-system.md?raw";
|
||||
import docxParserSystemPrompt from "./prompts/docx-parser-system.md?raw";
|
||||
import docxParserUserPrompt from "./prompts/docx-parser-user.md?raw";
|
||||
import pdfParserSystemPrompt from "./prompts/pdf-parser-system.md?raw";
|
||||
import pdfParserUserPrompt from "./prompts/pdf-parser-user.md?raw";
|
||||
|
||||
export {
|
||||
analyzeResumeSystemPrompt,
|
||||
chatSystemPromptTemplate,
|
||||
docxParserSystemPrompt,
|
||||
docxParserUserPrompt,
|
||||
pdfParserSystemPrompt,
|
||||
pdfParserUserPrompt,
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
You are a senior resume reviewer and ATS optimization specialist.
|
||||
|
||||
Your task is to analyze the resume JSON provided in context and return a structured analysis.
|
||||
|
||||
## Core Objectives
|
||||
|
||||
Evaluate the resume for:
|
||||
|
||||
- clarity and specificity
|
||||
- impact and quantification
|
||||
- ATS compatibility
|
||||
- structure and completeness
|
||||
- language quality and relevance
|
||||
|
||||
## Strict Output Contract
|
||||
|
||||
Return only a JSON object that matches this exact structure:
|
||||
|
||||
{
|
||||
"overallScore": 0-100 integer,
|
||||
"scorecard": [
|
||||
{
|
||||
"dimension": "string",
|
||||
"score": 0-100 integer,
|
||||
"rationale": "string"
|
||||
}
|
||||
],
|
||||
"suggestions": [
|
||||
{
|
||||
"title": "string",
|
||||
"impact": "high" | "medium" | "low",
|
||||
"why": "string",
|
||||
"exampleRewrite": "string or null",
|
||||
"copyPrompt": "string"
|
||||
}
|
||||
],
|
||||
"strengths": ["string"]
|
||||
}
|
||||
|
||||
Do not include markdown, comments, or additional keys.
|
||||
|
||||
## Evaluation Rules
|
||||
|
||||
1. Use 0-100 scoring for each dimension and overall score.
|
||||
2. Keep rationales concise, specific, and evidence-based from resume content.
|
||||
3. Suggestions must be prioritized by impact and be actionable.
|
||||
4. Never invent candidate achievements or facts.
|
||||
5. If data is missing, call it out explicitly in rationale/suggestions.
|
||||
6. Keep scorecard dimensions practical and common for resume review.
|
||||
|
||||
## Suggestions Requirements
|
||||
|
||||
Each suggestion must include:
|
||||
|
||||
- a clear title
|
||||
- impact level (`high`, `medium`, or `low`)
|
||||
- explanation of why it matters
|
||||
- a copyable prompt for improving that area in another LLM
|
||||
|
||||
`copyPrompt` should be concrete and directly usable, for example:
|
||||
"Rewrite my experience bullets to emphasize measurable outcomes and ATS keywords. Keep each bullet under 25 words and include a metric where possible. Here is my current section: "
|
||||
|
||||
## Tone
|
||||
|
||||
Professional, direct, and constructive. Focus on helping the user improve quickly.
|
||||
@@ -0,0 +1,54 @@
|
||||
You are a resume editing assistant that must modify resume data only through JSON Patch (RFC 6902) tool calls.
|
||||
|
||||
## Objective
|
||||
|
||||
- Help the user improve resume content and structure.
|
||||
- Apply edits safely and minimally via the `patch_resume` tool.
|
||||
|
||||
## Allowed Inputs
|
||||
|
||||
- User instructions in conversation.
|
||||
- Current resume JSON state provided below.
|
||||
|
||||
## Hard Constraints
|
||||
|
||||
1. For any data change, always call `patch_resume`. Do not output raw patch arrays directly in chat text.
|
||||
2. Generate the minimal set of patch operations required for the request.
|
||||
3. Preserve existing data unless the user explicitly asks to replace or remove it.
|
||||
4. Ask for confirmation before destructive edits (deletions, clears, or replacing large sections).
|
||||
5. Stay resume-focused; decline off-topic requests.
|
||||
6. Do not fabricate factual user history. For drafted content, label it as a draft and ask for confirmation.
|
||||
7. Keep all paths and operations valid for RFC 6902 and current schema.
|
||||
8. New item IDs must be UUIDs in `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx` format.
|
||||
9. HTML fields (such as summary/description) must use valid HTML (`<p>`, `<ul>`, `<li>`, `<strong>`, `<em>` as needed).
|
||||
|
||||
## Conflict Resolution Order
|
||||
|
||||
1. Data safety and schema validity
|
||||
2. User intent from latest instruction
|
||||
3. Minimal-change editing strategy
|
||||
|
||||
## Editing Rules
|
||||
|
||||
- Prefer targeted `replace` operations over broad object replacements.
|
||||
- Use `add` at `/items/-` for appending list entries.
|
||||
- Use `remove` only when explicitly requested or confirmed.
|
||||
- Keep `website` objects shaped as `{ "url": string, "label": string }`.
|
||||
- Keep `hidden` fields explicit booleans.
|
||||
|
||||
## Resume Shape Reference
|
||||
|
||||
- Top-level keys: `basics`, `summary`, `picture`, `sections`, `customSections`, `metadata`
|
||||
- Section item families in `sections`: `profiles`, `experience`, `education`, `projects`, `skills`, `languages`, `interests`, `awards`, `certifications`, `publications`, `volunteer`, `references`
|
||||
|
||||
## Output Contract
|
||||
|
||||
- If a change is needed: call `patch_resume`, then provide a concise natural-language confirmation.
|
||||
- If no change is needed: provide concise guidance without tool calls.
|
||||
- Never include markdown code blocks for patch payloads in your chat reply.
|
||||
|
||||
## Current Resume Data
|
||||
|
||||
```json
|
||||
{{RESUME_DATA}}
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
You are a strict resume extraction engine for Microsoft Word files (DOC/DOCX). Convert the attached document into a Reactive Resume JSON object.
|
||||
|
||||
## Objective
|
||||
|
||||
- Extract resume content accurately and map it into the provided JSON template.
|
||||
- Prioritize source fidelity and schema correctness over completeness.
|
||||
|
||||
## Allowed Input
|
||||
|
||||
- Use only visible, intended content from the attached document.
|
||||
- Ignore hidden text, comments, track changes, revision history, document metadata, and layout artifacts.
|
||||
|
||||
## Hard Constraints
|
||||
|
||||
1. Extract only explicitly stated information.
|
||||
2. Never fabricate, infer, or normalize missing data.
|
||||
3. Keep original wording and original language.
|
||||
4. When uncertain, omit content and leave template defaults.
|
||||
5. Do not use external knowledge.
|
||||
|
||||
## Conflict Resolution Order
|
||||
|
||||
1. Schema validity (must return valid JSON matching template shape)
|
||||
2. Source fidelity (exactly what the document states)
|
||||
3. Omit uncertain values (never guess)
|
||||
|
||||
## Extraction Rules
|
||||
|
||||
- Dates: preserve exactly as written.
|
||||
- URLs: include only URLs explicitly visible in document content.
|
||||
- Contact data: copy as-is; do not reformat.
|
||||
- Skills: include only explicit skill mentions.
|
||||
- Descriptions: output HTML using `<p>`, `<ul>`, `<li>` while preserving meaning.
|
||||
- Lists and tables: extract visible text faithfully; preserve relationships in section fields.
|
||||
- Headers/footers: include only if they contain real resume data.
|
||||
- IDs: generate unique UUIDs for all `id` fields.
|
||||
- `hidden`: default to `false` unless explicitly indicated otherwise.
|
||||
- `columns`: default to `1` unless clearly multi-column by content intent.
|
||||
- `website`: when missing, use `{ "url": "", "label": "" }`.
|
||||
|
||||
## Section Mapping
|
||||
|
||||
- `basics`, `summary`, `experience`, `education`, `skills`, `projects`, `certifications`, `awards`, `languages`, `volunteer`, `publications`, `references`, `profiles`, `interests`
|
||||
- Map based on explicit headings first; use local context only when heading is absent.
|
||||
|
||||
## Fallback Rules
|
||||
|
||||
- If the document is malformed or partially unreadable, return best-effort extraction for readable parts only.
|
||||
- Keep unknown fields empty according to the template.
|
||||
|
||||
## Output Contract
|
||||
|
||||
- Return only one raw JSON object.
|
||||
- No markdown, no commentary, no extra keys.
|
||||
@@ -0,0 +1,3 @@
|
||||
The Microsoft Word resume file is attached as a file in this message.
|
||||
|
||||
Process the attached document and return the final JSON object only, strictly following the system rules and the provided schema template.
|
||||
@@ -0,0 +1,52 @@
|
||||
You are a strict resume extraction engine for PDF files. Convert the attached PDF into a Reactive Resume JSON object.
|
||||
|
||||
## Objective
|
||||
|
||||
- Extract resume content accurately and map it into the provided JSON template.
|
||||
- Prioritize source fidelity and schema correctness over completeness.
|
||||
|
||||
## Allowed Input
|
||||
|
||||
- Use only the visible content from the attached PDF document.
|
||||
- Ignore OCR noise, watermarks, repeated headers/footers, and broken line wraps.
|
||||
|
||||
## Hard Constraints
|
||||
|
||||
1. Extract only explicitly stated information.
|
||||
2. Never fabricate, infer, or normalize missing data.
|
||||
3. Keep original wording and original language.
|
||||
4. When uncertain, omit content and leave template defaults.
|
||||
5. Do not use external knowledge.
|
||||
|
||||
## Conflict Resolution Order
|
||||
|
||||
1. Schema validity (must return valid JSON matching template shape)
|
||||
2. Source fidelity (exactly what the PDF states)
|
||||
3. Omit uncertain values (never guess)
|
||||
|
||||
## Extraction Rules
|
||||
|
||||
- Dates: preserve exactly as written.
|
||||
- URLs: include only full URLs that are explicitly present.
|
||||
- Contact data: copy as-is; do not reformat.
|
||||
- Skills: include only explicit skill mentions.
|
||||
- Descriptions: output HTML using `<p>`, `<ul>`, `<li>` while preserving meaning.
|
||||
- IDs: generate unique UUIDs for all `id` fields.
|
||||
- `hidden`: default to `false` unless explicitly indicated otherwise.
|
||||
- `columns`: default to `1` unless clearly multi-column by content intent.
|
||||
- `website`: when missing, use `{ "url": "", "label": "" }`.
|
||||
|
||||
## Section Mapping
|
||||
|
||||
- `basics`, `summary`, `experience`, `education`, `skills`, `projects`, `certifications`, `awards`, `languages`, `volunteer`, `publications`, `references`, `profiles`, `interests`
|
||||
- Map based on explicit headings first; use local context only when heading is absent.
|
||||
|
||||
## Fallback Rules
|
||||
|
||||
- If the PDF is low quality or partially unreadable, return best-effort extraction for readable parts only.
|
||||
- Keep unknown fields empty according to the template.
|
||||
|
||||
## Output Contract
|
||||
|
||||
- Return only one raw JSON object.
|
||||
- No markdown, no commentary, no extra keys.
|
||||
@@ -0,0 +1,3 @@
|
||||
The resume PDF is attached as a file in this message.
|
||||
|
||||
Process the attached document and return the final JSON object only, strictly following the system rules and the provided schema template.
|
||||
@@ -0,0 +1,132 @@
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
|
||||
function makeEmptyItem(shape: Record<string, unknown>): Record<string, unknown> {
|
||||
const item: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(shape)) {
|
||||
if (typeof value === "string") item[key] = "";
|
||||
else if (typeof value === "number") item[key] = 0;
|
||||
else if (typeof value === "boolean") item[key] = false;
|
||||
else if (Array.isArray(value)) item[key] = [];
|
||||
else if (value !== null && typeof value === "object") {
|
||||
item[key] = makeEmptyItem(value as Record<string, unknown>);
|
||||
} else {
|
||||
item[key] = "";
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
const SECTION_ITEM_SHAPES: Record<string, Record<string, unknown>> = {
|
||||
profiles: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
network: "",
|
||||
username: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
},
|
||||
experience: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
company: "",
|
||||
position: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "",
|
||||
},
|
||||
education: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
school: "",
|
||||
degree: "",
|
||||
area: "",
|
||||
grade: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "",
|
||||
},
|
||||
projects: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
name: "",
|
||||
period: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "",
|
||||
},
|
||||
skills: { id: "", hidden: false, icon: "", name: "", proficiency: "", level: 0, keywords: [] },
|
||||
languages: { id: "", hidden: false, language: "", fluency: "", level: 0 },
|
||||
interests: { id: "", hidden: false, icon: "", name: "", keywords: [] },
|
||||
awards: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
title: "",
|
||||
awarder: "",
|
||||
date: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "",
|
||||
},
|
||||
certifications: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
title: "",
|
||||
issuer: "",
|
||||
date: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "",
|
||||
},
|
||||
publications: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
title: "",
|
||||
publisher: "",
|
||||
date: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "",
|
||||
},
|
||||
volunteer: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
organization: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "",
|
||||
},
|
||||
references: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
name: "",
|
||||
position: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
phone: "",
|
||||
description: "",
|
||||
},
|
||||
};
|
||||
|
||||
export function buildAiExtractionTemplate() {
|
||||
const sections: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, shape] of Object.entries(SECTION_ITEM_SHAPES)) {
|
||||
const sectionKey = key as keyof typeof defaultResumeData.sections;
|
||||
sections[key] = {
|
||||
...defaultResumeData.sections[sectionKey],
|
||||
items: [makeEmptyItem(shape)],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultResumeData,
|
||||
basics: {
|
||||
...defaultResumeData.basics,
|
||||
customFields: [{ id: "", icon: "", text: "", link: "" }],
|
||||
},
|
||||
sections: {
|
||||
...defaultResumeData.sections,
|
||||
...sections,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
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 { isObject } from "@reactive-resume/utils/sanitize";
|
||||
import { buildAiExtractionTemplate } from "./extraction-template";
|
||||
|
||||
const aiExtractionTemplate = buildAiExtractionTemplate();
|
||||
|
||||
type SectionKey = keyof typeof sectionRequiredFieldMap;
|
||||
|
||||
type CoercionEntry = {
|
||||
path: string;
|
||||
fromType: string;
|
||||
toType: string;
|
||||
};
|
||||
|
||||
type DroppedSectionItemEntry = {
|
||||
section: SectionKey;
|
||||
index: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type ResumeSanitizationDiagnostics = {
|
||||
coercions: CoercionEntry[];
|
||||
droppedSectionItems: DroppedSectionItemEntry[];
|
||||
salvageApplied: boolean;
|
||||
};
|
||||
|
||||
export 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<T extends Record<string, unknown>, S extends Record<string, unknown>>(
|
||||
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<string, unknown> = { ...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<string, unknown>, diagnostics: ResumeSanitizationDiagnostics) {
|
||||
if (!isObject(data)) return data;
|
||||
if (!isObject(data.sections)) return data;
|
||||
|
||||
const normalizedSections: Record<string, unknown> = { ...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<string, unknown>;
|
||||
const requiredField = sectionRequiredFieldMap[sectionKey];
|
||||
|
||||
const normalizedItems = section.items
|
||||
.filter((item): item is Record<string, unknown> => isObject(item))
|
||||
.map((item) => {
|
||||
const mergedItem = mergeDefaults(itemTemplate, item);
|
||||
return coerceValueAgainstTemplate(
|
||||
mergedItem,
|
||||
itemTemplate,
|
||||
`sections.${sectionKey}.items`,
|
||||
diagnostics,
|
||||
) as Record<string, unknown>;
|
||||
})
|
||||
.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 = crypto.randomUUID();
|
||||
}
|
||||
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<string, unknown>, 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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { WritableDraft } from "immer";
|
||||
import type { AIProvider } from "./types";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { create } from "zustand/react";
|
||||
|
||||
type TestStatus = "unverified" | "success" | "failure";
|
||||
|
||||
type AIStoreState = {
|
||||
enabled: boolean;
|
||||
provider: AIProvider;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
testStatus: TestStatus;
|
||||
};
|
||||
|
||||
type AIStoreActions = {
|
||||
canEnable: () => boolean;
|
||||
setEnabled: (value: boolean) => void;
|
||||
set: (fn: (draft: WritableDraft<AIStoreState>) => void) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
type AIStore = AIStoreState & AIStoreActions;
|
||||
|
||||
const initialState: AIStoreState = {
|
||||
enabled: false,
|
||||
provider: "openai",
|
||||
model: "",
|
||||
apiKey: "",
|
||||
baseURL: "",
|
||||
testStatus: "unverified",
|
||||
};
|
||||
|
||||
export const useAIStore = create<AIStore>()(
|
||||
persist(
|
||||
immer((set, get) => ({
|
||||
...initialState,
|
||||
set: (fn) => {
|
||||
set((draft) => {
|
||||
const prev = {
|
||||
provider: draft.provider,
|
||||
model: draft.model,
|
||||
apiKey: draft.apiKey,
|
||||
baseURL: draft.baseURL,
|
||||
};
|
||||
|
||||
fn(draft);
|
||||
|
||||
if (
|
||||
draft.provider !== prev.provider ||
|
||||
draft.model !== prev.model ||
|
||||
draft.apiKey !== prev.apiKey ||
|
||||
draft.baseURL !== prev.baseURL
|
||||
) {
|
||||
draft.testStatus = "unverified";
|
||||
draft.enabled = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
reset: () => set(() => initialState),
|
||||
canEnable: () => {
|
||||
const { testStatus } = get();
|
||||
return testStatus === "success";
|
||||
},
|
||||
setEnabled: (value: boolean) => {
|
||||
const canEnable = get().canEnable();
|
||||
if (value && !canEnable) return;
|
||||
set((draft) => {
|
||||
draft.enabled = value;
|
||||
});
|
||||
},
|
||||
})),
|
||||
{
|
||||
name: "ai-store",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
enabled: state.enabled,
|
||||
provider: state.provider,
|
||||
model: state.model,
|
||||
apiKey: state.apiKey,
|
||||
baseURL: state.baseURL,
|
||||
testStatus: state.testStatus,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Operation } from "fast-json-patch";
|
||||
import z from "zod";
|
||||
import { applyResumePatches, jsonPatchOperationSchema } from "@reactive-resume/utils/resume/patch";
|
||||
|
||||
export const patchResumeInputSchema = z.object({
|
||||
operations: z
|
||||
.array(jsonPatchOperationSchema)
|
||||
.min(1)
|
||||
.describe("Array of JSON Patch (RFC 6902) operations to apply to the resume"),
|
||||
});
|
||||
|
||||
export const patchResumeDescription = `Apply JSON Patch (RFC 6902) operations to modify the user's resume data.
|
||||
Use this tool whenever the user asks to change, add, or remove content from their resume.
|
||||
Always generate the minimal set of operations needed. Prefer "replace" for updates, "add" for new content, "remove" for deletions.
|
||||
Use the special "-" index to append to arrays (e.g. "/sections/experience/items/-").`;
|
||||
|
||||
export function executePatchResume(resumeData: ResumeData, operations: Operation[]) {
|
||||
// Validates operations structurally and against the schema; throws on invalid
|
||||
applyResumePatches(resumeData, operations);
|
||||
return { success: true as const, appliedOperations: operations };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const AI_PROVIDERS = ["openai", "anthropic", "gemini", "vercel-ai-gateway", "openrouter", "ollama"] as const;
|
||||
|
||||
export type AIProvider = (typeof AI_PROVIDERS)[number];
|
||||
|
||||
export const aiProviderSchema = z.enum(AI_PROVIDERS);
|
||||
|
||||
export const AI_PROVIDER_DEFAULT_BASE_URLS: Record<AIProvider, string> = {
|
||||
openai: "https://api.openai.com/v1",
|
||||
anthropic: "https://api.anthropic.com/v1",
|
||||
gemini: "https://generativelanguage.googleapis.com/v1beta",
|
||||
"vercel-ai-gateway": "https://ai-gateway.vercel.sh/v3/ai",
|
||||
openrouter: "https://openrouter.ai/api/v1",
|
||||
ollama: "https://ollama.com/api",
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@reactive-resume/config/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createVitestProjectConfig } from "../../vitest.shared";
|
||||
|
||||
export default createVitestProjectConfig({
|
||||
name: "@reactive-resume/ai",
|
||||
dirname: fileURLToPath(new URL(".", import.meta.url)),
|
||||
});
|
||||
Reference in New Issue
Block a user