diff --git a/packages/ai/src/prompts.ts b/packages/ai/src/prompts.ts
index ac45857bd..52b84558d 100644
--- a/packages/ai/src/prompts.ts
+++ b/packages/ai/src/prompts.ts
@@ -4,11 +4,46 @@ const readPrompt = (filename: string) => {
return readFileSync(new URL(`./prompts/${filename}`, import.meta.url), "utf-8");
};
+// ponytail: single template with per-source substitutions; produced text is identical per source type
+const parserTemplate = readPrompt("parser-system.md");
+
+type ParserVars = {
+ FORMAT_HEADER: string;
+ FORMAT_NOUN: string;
+ ALLOWED_INPUT: string;
+ URL_CLAUSE: string;
+ EXTRA_RULES: string;
+ FALLBACK_CLAUSE: string;
+};
+
+function makeParserPrompt(vars: ParserVars): string {
+ return Object.entries(vars).reduce((acc, [k, v]) => acc.replaceAll(`{{${k}}}`, v), parserTemplate);
+}
+
+const pdfParserSystemPrompt = makeParserPrompt({
+ FORMAT_HEADER: "PDF files",
+ FORMAT_NOUN: "PDF",
+ ALLOWED_INPUT:
+ "- Use only the visible content from the attached PDF document.\n- Ignore OCR noise, watermarks, repeated headers/footers, and broken line wraps.",
+ URL_CLAUSE: "full URLs that are explicitly present",
+ EXTRA_RULES: "",
+ FALLBACK_CLAUSE: "PDF is low quality or partially unreadable",
+});
+
+const docxParserSystemPrompt = makeParserPrompt({
+ FORMAT_HEADER: "Microsoft Word files (DOC/DOCX)",
+ FORMAT_NOUN: "document",
+ ALLOWED_INPUT:
+ "- Use only visible, intended content from the attached document.\n- Ignore hidden text, comments, track changes, revision history, document metadata, and layout artifacts.",
+ URL_CLAUSE: "URLs explicitly visible in document content",
+ EXTRA_RULES:
+ "- Lists and tables: extract visible text faithfully; preserve relationships in section fields.\n- Headers/footers: include only if they contain real resume data.\n",
+ FALLBACK_CLAUSE: "document is malformed or partially unreadable",
+});
+
const analyzeResumeSystemPrompt = readPrompt("analyze-resume-system.md");
const chatSystemPromptTemplate = readPrompt("chat-system.md");
-const docxParserSystemPrompt = readPrompt("docx-parser-system.md");
const docxParserUserPrompt = readPrompt("docx-parser-user.md");
-const pdfParserSystemPrompt = readPrompt("pdf-parser-system.md");
const pdfParserUserPrompt = readPrompt("pdf-parser-user.md");
export {
diff --git a/packages/ai/src/prompts/docx-parser-system.md b/packages/ai/src/prompts/docx-parser-system.md
deleted file mode 100644
index 5f2a91fc2..000000000
--- a/packages/ai/src/prompts/docx-parser-system.md
+++ /dev/null
@@ -1,54 +0,0 @@
-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 `
`, `
`, `- ` 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.
diff --git a/packages/ai/src/prompts/pdf-parser-system.md b/packages/ai/src/prompts/parser-system.md
similarity index 73%
rename from packages/ai/src/prompts/pdf-parser-system.md
rename to packages/ai/src/prompts/parser-system.md
index fb42255a5..3b51dd8e1 100644
--- a/packages/ai/src/prompts/pdf-parser-system.md
+++ b/packages/ai/src/prompts/parser-system.md
@@ -1,4 +1,4 @@
-You are a strict resume extraction engine for PDF files. Convert the attached PDF into a Reactive Resume JSON object.
+You are a strict resume extraction engine for {{FORMAT_HEADER}}. Convert the attached {{FORMAT_NOUN}} into a Reactive Resume JSON object.
## Objective
@@ -7,8 +7,7 @@ You are a strict resume extraction engine for PDF files. Convert the attached PD
## Allowed Input
-- Use only the visible content from the attached PDF document.
-- Ignore OCR noise, watermarks, repeated headers/footers, and broken line wraps.
+{{ALLOWED_INPUT}}
## Hard Constraints
@@ -21,17 +20,17 @@ You are a strict resume extraction engine for PDF files. Convert the attached PD
## Conflict Resolution Order
1. Schema validity (must return valid JSON matching template shape)
-2. Source fidelity (exactly what the PDF states)
+2. Source fidelity (exactly what the {{FORMAT_NOUN}} states)
3. Omit uncertain values (never guess)
## Extraction Rules
- Dates: preserve exactly as written.
-- URLs: include only full URLs that are explicitly present.
+- URLs: include only {{URL_CLAUSE}}.
- Contact data: copy as-is; do not reformat.
- Skills: include only explicit skill mentions.
- Descriptions: output HTML using `
`, `
`, `- ` while preserving meaning.
-- IDs: generate unique UUIDs for all `id` fields.
+{{EXTRA_RULES}}- 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": "" }`.
@@ -43,7 +42,7 @@ You are a strict resume extraction engine for PDF files. Convert the attached PD
## Fallback Rules
-- If the PDF is low quality or partially unreadable, return best-effort extraction for readable parts only.
+- If the {{FALLBACK_CLAUSE}}, return best-effort extraction for readable parts only.
- Keep unknown fields empty according to the template.
## Output Contract
diff --git a/packages/ai/src/resume/extraction-template.ts b/packages/ai/src/resume/extraction-template.ts
index 004cdc8b4..1a9caaaf8 100644
--- a/packages/ai/src/resume/extraction-template.ts
+++ b/packages/ai/src/resume/extraction-template.ts
@@ -1,23 +1,5 @@
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
-function makeEmptyItem(shape: Record): Record {
- const item: Record = {};
-
- 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);
- } else {
- item[key] = "";
- }
- }
-
- return item;
-}
-
const SECTION_ITEM_SHAPES: Record> = {
profiles: {
id: "",
@@ -114,7 +96,7 @@ export function buildAiExtractionTemplate() {
const sectionKey = key as keyof typeof defaultResumeData.sections;
sections[key] = {
...defaultResumeData.sections[sectionKey],
- items: [makeEmptyItem(shape)],
+ items: [structuredClone(shape)],
};
}