293 lines
18 KiB
Markdown
293 lines
18 KiB
Markdown
# Mode: pdf — ATS-Optimized PDF Generation
|
|
|
|
## Full pipeline
|
|
|
|
1. Read `cv.md` as the source of truth
|
|
2. Ask the user for the JD if it is not in context (text or URL)
|
|
3. Extract 15-20 keywords from the JD
|
|
4. Detect JD language → CV language (EN default)
|
|
5. Detect company location → paper format:
|
|
- US/Canada → `letter`
|
|
- Rest of the world → `a4`
|
|
6. Detect role archetype → adapt framing
|
|
7. Build an internal recruiter-side risk map from the JD using `modes/heuristics/recruiter-side.md`: likely doubts, matching evidence, and which document section should address each doubt
|
|
8. Rewrite Professional Summary by injecting JD keywords + exit narrative bridge ("Built and sold a business. Now applying systems thinking to [JD domain].")
|
|
9. Select top 3-4 most relevant projects for the job
|
|
10. Reorder experience bullets by JD relevance and by the risk map: strongest matching evidence first
|
|
11. Build competency grid from JD requirements (6-8 keyword phrases)
|
|
12. Inject keywords naturally into existing achievements (NEVER invent)
|
|
13. Apply the six-second clarity gate from `modes/heuristics/recruiter-side.md`: top third must make target role, strongest fit, and proof obvious
|
|
14. Read `name` from `config/profile.yml` → normalize to kebab-case lowercase (e.g. "John Doe" → "john-doe") → `{candidate}`
|
|
15. Build the render payload (see the **JSON Input Schema** below) from the tailored content — emit compact structured JSON, **not** full HTML markup — and write it to `/tmp/cv-{candidate}-{company}.json`
|
|
16. Run: `node build-cv-html.mjs /tmp/cv-{candidate}-{company}.json output/cv-{candidate}-{company}.html {template}` — where `{template}` is the path printed by **Selecting the template** below (omit the argument to use the base `cv-template.html`). The script merges the payload into that template, owning every tag, CSS class, and the HTML escaping. Write to `output/` (NOT a temp dir — the recorded HTML is what the dashboard's `D` hotkey regenerates from, so it must survive temp cleanup)
|
|
17. Run the fact gate: `node verify-cv-facts.mjs output/cv-{candidate}-{company}.html`
|
|
- This is a hard gate before PDF rendering.
|
|
- If it fails, stop and fix the generated HTML by removing invented metrics or adding verified evidence to `cv.md`, `article-digest.md`, or `config/cv-facts.json`.
|
|
18. Execute: `node generate-pdf.mjs output/cv-{candidate}-{company}.html output/cv-{candidate}-{company}-{YYYY-MM-DD}.pdf --format={letter|a4} --report={report number}` — `{report number}` is the NNN from the report filename/link (e.g. `008` for `reports/008-acme-….md`), not the tracker `#` column. Pass it whenever the application has (or will have) a report; it records the PDF↔report linkage in `data/pdf-index.tsv` so the dashboard can open and regenerate the exact PDF. Omit it only for one-off CVs with no tracker entry.
|
|
19. Report: PDF path, number of pages, keyword coverage %
|
|
|
|
## ATS Rules (clean parsing)
|
|
|
|
- Single-column layout (no sidebars, no parallel columns)
|
|
- Standard headers: "Professional Summary", "Work Experience", "Education", "Skills", "Certifications", "Projects"
|
|
- No text in images/SVGs
|
|
- No critical info in PDF headers/footers (ATS ignores them)
|
|
- UTF-8, selectable text (not rasterized)
|
|
- No nested tables
|
|
- Distributed JD keywords: Summary (top 5), first bullet of each role, Skills section
|
|
- No hidden text, keyword stuffing, or white-font tricks. Optimize for parseability plus human review.
|
|
|
|
## Recruiter Review Gates
|
|
|
|
- The summary should answer: "What role is this person targeting, and why this one?"
|
|
- The first screen should show 1-2 proof points that map to the JD's highest-risk requirements.
|
|
- Bullets should emphasize outcomes, systems, users, or business effects rather than task history.
|
|
- Logistics such as location, work authorization, salary, and availability belong in the CV only when appropriate for the market and profile; otherwise handle them in form answers or recruiter scripts.
|
|
|
|
## PDF Design
|
|
|
|
- **Fonts**: Space Grotesk (headings, 600-700) + DM Sans (body, 400-500)
|
|
- **Fonts self-hosted**: `fonts/`
|
|
- **Header**: name in Space Grotesk 24px bold + gradient line `linear-gradient(to right, hsl(187,74%,32%), hsl(270,70%,45%))` 2px + contact row
|
|
- **Section headers**: Space Grotesk 13px, uppercase, letter-spacing 0.05em, color cyan primary
|
|
- **Body**: DM Sans 11px, line-height 1.5
|
|
- **Company names**: accent purple color `hsl(270,70%,45%)`
|
|
- **Margins**: 0.6in
|
|
- **Background**: pure white
|
|
|
|
## Section order (optimized "6-second recruiter scan")
|
|
|
|
1. Header (large name, gradient, contact, portfolio link)
|
|
2. Professional Summary (3-4 lines, keyword-dense)
|
|
3. Core Competencies (6-8 keyword phrases in flex-grid)
|
|
4. Work Experience (reverse chronological)
|
|
5. Projects (top 3-4 most relevant)
|
|
6. Education & Certifications
|
|
7. Skills (languages + technical)
|
|
|
|
## Keyword injection strategy (ethical, truth-based)
|
|
|
|
Examples of legitimate reformulation:
|
|
- JD says "RAG pipelines" and CV says "LLM workflows with retrieval" → change to "RAG pipeline design and LLM orchestration workflows"
|
|
- JD says "MLOps" and CV says "observability, evals, error handling" → change to "MLOps and observability: evals, error handling, cost monitoring"
|
|
- JD says "stakeholder management" and CV says "collaborated with team" → change to "stakeholder management across engineering, operations, and business"
|
|
|
|
**NEVER add skills that the candidate does not have. Only reword real experience using the exact JD vocabulary.**
|
|
|
|
## Template HTML
|
|
|
|
**Before generating: read `modes/_custom.md` (if it exists) and apply its formatting/content house rules to every CV in this session — including every item of a batch.** Rules recorded there (date formats, section-order preferences, content to always/never include) are persistent user instructions, not suggestions; if the user corrects the same thing twice in conversation, write it into `modes/_custom.md` so it stops drifting.
|
|
|
|
### Selecting the template
|
|
|
|
Resolve which template to fill with the shared resolver (do not hardcode `cv-template.html`):
|
|
|
|
- If the user named a template this turn (e.g. "use the *modern* template"), run:
|
|
`node cv-templates.mjs resolve cv "<name>"`
|
|
- Otherwise run: `node cv-templates.mjs resolve cv`
|
|
(this returns the `cv.template` default from `config/profile.yml`, or the base `cv-template.html` when unset).
|
|
|
|
The command prints the absolute path of the template to fill; a non-zero exit means the named template is missing or invalid — surface that message to the user instead of silently falling back.
|
|
|
|
To show the user their options (e.g. "what CV templates do I have?"), run `node cv-templates.mjs list cv` and present each `displayName`.
|
|
|
|
`build-cv-html.mjs` fills that resolved template from the JSON payload you build — it owns every tag, CSS class, and the HTML escaping, so you **never emit full HTML markup** and do **not** escape `&`/`<`/`>`/quotes yourself. Pass the resolved path as the third argument (`node build-cv-html.mjs <input.json> <output.html> <template.html>`); omit it to fall back to the base `cv-template.html`. This is the HTML twin of `build-cv-latex.mjs` (see `modes/latex.md`) and cuts the PDF step's output tokens from full markup down to the compact payload below (#557).
|
|
|
|
### JSON Input Schema
|
|
|
|
Write a JSON file with this structure, then run `node build-cv-html.mjs <input.json> <output.html> [template.html]` (the optional third argument is the template path from **Selecting the template**; omit it for the base `cv-template.html`).
|
|
|
|
```json
|
|
{
|
|
"lang": "en",
|
|
"page_format": "letter",
|
|
"candidate": {
|
|
"name": "Jane Smith",
|
|
"phone": "+1 415 555 0100",
|
|
"email": "jane@example.com",
|
|
"linkedin": { "url": "https://linkedin.com/in/janesmith", "display": "linkedin.com/in/janesmith" },
|
|
"portfolio": { "url": "https://janesmith.dev", "display": "janesmith.dev" },
|
|
"location": "San Francisco, CA",
|
|
"photo": ""
|
|
},
|
|
"sections": {
|
|
"summary": "Professional Summary",
|
|
"competencies": "Core Competencies",
|
|
"experience": "Work Experience",
|
|
"projects": "Projects",
|
|
"education": "Education",
|
|
"certifications": "Certifications",
|
|
"skills": "Skills"
|
|
},
|
|
"summary": "Personalized summary with JD keywords injected (honest vs cv.md).",
|
|
"competencies": ["RAG Pipelines", "LLMOps", "Kubernetes & Docker"],
|
|
"experience": [
|
|
{
|
|
"company": "Company Name",
|
|
"role": "Job Title",
|
|
"location": "Remote",
|
|
"dates": "June 2022 - Present",
|
|
"bullets": ["Achievement bullet with JD keywords injected", "Another quantified-impact bullet"]
|
|
}
|
|
],
|
|
"projects": [
|
|
{ "name": "Project Name", "badge": "Open Source", "tech": "Python, FastAPI", "description": "What it does." }
|
|
],
|
|
"education": [
|
|
{ "title": "B.S. Computer Science", "org": "University Name", "year": "2022", "description": "Optional line." }
|
|
],
|
|
"certifications": [
|
|
{ "title": "Certified Kubernetes Administrator", "org": "CNCF", "year": "2024" }
|
|
],
|
|
"skills": [
|
|
{ "category": "Languages", "items": "Python, JavaScript, C++" },
|
|
{ "category": "Frameworks", "items": ["FastAPI", "React", "PyTorch"] }
|
|
]
|
|
}
|
|
```
|
|
|
|
### Field reference
|
|
|
|
| Field | Type | Notes |
|
|
|-------|------|-------|
|
|
| `lang` | string | CV language code (`en`, `es`, `ja`, `ar`). Drives language-specific CSS: `ja` enables a CJK font fallback so Japanese renders instead of tofu (□); `ar` enables RTL + Arabic fonts. Defaults to `en`. |
|
|
| `page_format` | string | `letter` → `8.5in` page width, `a4` → `210mm`. Defaults to `letter`. Pass the SAME value to `generate-pdf.mjs --format`. |
|
|
| `candidate.name` | string | From `profile.yml`. |
|
|
| `candidate.phone` | string | Optional — **omit or leave empty** to drop the `tel:` link and its separator (no empty cell). |
|
|
| `candidate.email` | string | From `profile.yml`. |
|
|
| `candidate.linkedin` | `{url, display}` | Optional — omit to drop the item and its separator. |
|
|
| `candidate.portfolio` | `{url, display}` | Optional — omit to drop the item and its separator. |
|
|
| `candidate.location` | string | From `profile.yml`. |
|
|
| `candidate.photo` | string | Opt-in profile photo (#264): a local path or `data:` URL. Empty/absent emits **no `<img>`**, rendering pixel-for-pixel identical to the photoless layout (US/UK/many-market ATS penalize photos; opt in for DACH/European markets). |
|
|
| `sections` | object | Optional localized section titles; any omitted key falls back to the English default shown above. |
|
|
| `summary` | string | Personalized summary with keywords. |
|
|
| `competencies` | string[] | 6-8 keyword phrases → competency tags. |
|
|
| `experience[]` | object | `company`, `role`, `location` (optional), `dates`, `bullets` (reordered, keyword-injected). |
|
|
| `projects[]` | object | `name`, `badge` (optional), `tech` (optional), `description` (a `bullets` array is also accepted and joined into the description line). |
|
|
| `education[]` | object | `title` (degree), `org` (institution), `year`, `description` (optional). |
|
|
| `certifications[]` | object | `title`, `org`, `year`. |
|
|
| `skills[]` | object | `category` + `items` (comma-separated string or string array). |
|
|
|
|
`build-cv-html.mjs` errors out (non-zero exit) if any template placeholder is left unresolved, so a malformed payload fails loudly instead of shipping a broken CV. Run `node build-cv-html.mjs --test` for a self-test render.
|
|
|
|
### Profile photo (opt-in, market-specific)
|
|
|
|
The `{{PHOTO}}` slot is **off by default** and intentionally market-specific:
|
|
|
|
- **DACH / much of continental Europe** (Germany, Austria, Switzerland): a professional photo is standard and often expected. Opt in by setting `candidate.photo` in `config/profile.yml` (a local file path or a `data:` URL).
|
|
- **US / UK / Canada / Australia and many ATS-first markets**: photos are discouraged and can trip bias-avoidance filters. Leave `candidate.photo` empty — the `{{PHOTO}}` line is dropped entirely, no `<img>` is emitted, and the CV renders **pixel-for-pixel identical** to today's photoless layout.
|
|
|
|
When set, the photo floats into the top corner (mirrored for RTL/Arabic) and the header/summary text wraps beside it; `.cv-photo` in `cv-template.html` controls its size and framing.
|
|
|
|
## Canva CV Generation (optional)
|
|
|
|
If `config/profile.yml` has `cv.canva_resume_design_id` set, offer the user a choice before generating:
|
|
- **"HTML/PDF (fast, ATS-optimized)"** — existing flow above
|
|
- **"Canva CV (visual, design-preserving)"** — new flow below
|
|
|
|
If the user has no `cv.canva_resume_design_id`, skip this prompt and use the HTML/PDF flow.
|
|
|
|
### Canva workflow
|
|
|
|
#### Step 1 — Duplicate the base design
|
|
|
|
a. `export-design` the base design (using `cv.canva_resume_design_id`) as PDF → get download URL
|
|
b. `import-design-from-url` using that download URL → creates a new editable design (the duplicate)
|
|
c. Note the new `design_id` for the duplicate
|
|
|
|
#### Step 2 — Read the design structure
|
|
|
|
a. `get-design-content` on the new design → returns all text elements (richtexts) with their content
|
|
b. Map text elements to CV sections by content matching:
|
|
- Look for the candidate's name → header section
|
|
- Look for "Summary" or "Professional Summary" → summary section
|
|
- Look for company names from cv.md → experience sections
|
|
- Look for degree/school names → education section
|
|
- Look for skill keywords → skills section
|
|
c. If mapping fails, show the user what was found and ask for guidance
|
|
|
|
#### Step 3 — Generate tailored content
|
|
|
|
Same content generation as the HTML flow (Steps 1-11 above):
|
|
- Rewrite Professional Summary with JD keywords + exit narrative
|
|
- Reorder experience bullets by JD relevance
|
|
- Select top competencies from JD requirements
|
|
- Inject keywords naturally (NEVER invent)
|
|
|
|
**IMPORTANT — Character budget rule:** Each replacement text MUST be approximately the same length as the original text it replaces (within ±15% character count). If tailored content is longer, condense it. The Canva design has fixed-size text boxes — longer text causes overlapping with adjacent elements. Count the characters in each original element from Step 2 and enforce this budget when generating replacements.
|
|
|
|
#### Step 4 — Apply edits
|
|
|
|
a. `start-editing-transaction` on the duplicate design
|
|
b. `perform-editing-operations` with `find_and_replace_text` for each section:
|
|
- Replace summary text with tailored summary
|
|
- Replace each experience bullet with reordered/rewritten bullets
|
|
- Replace competency/skills text with JD-matched terms
|
|
- Replace project descriptions with top relevant projects
|
|
c. **Reflow layout after text replacement:**
|
|
After applying all text replacements, the text boxes auto-resize but neighboring elements stay in place. This causes uneven spacing between work experience sections. Fix this:
|
|
1. Read the updated element positions and dimensions from the `perform-editing-operations` response
|
|
2. For each work experience section (top to bottom), calculate where the bullets text box ends: `end_y = top + height`
|
|
3. The next section's header should start at `end_y + consistent_gap` (use the original gap from the template, typically ~30px)
|
|
4. Use `position_element` to move the next section's date, company name, role title, and bullets elements to maintain even spacing
|
|
5. Repeat for all work experience sections
|
|
d. **Verify layout before commit:**
|
|
- `get-design-thumbnail` with the transaction_id and page_index=1
|
|
- Visually inspect the thumbnail for: text overlapping, uneven spacing, text cut off, text too small
|
|
- If issues remain, adjust with `position_element`, `resize_element`, or `format_text`
|
|
- Repeat until layout is clean
|
|
e. Show the user the final preview and ask for approval
|
|
f. `commit-editing-transaction` to save (ONLY after user approval)
|
|
|
|
#### Step 5 — Export and download PDF
|
|
|
|
a. `export-design` the duplicate as PDF (format: a4 or letter based on JD location)
|
|
b. **IMMEDIATELY** download the PDF using Bash:
|
|
```bash
|
|
curl -sL -o "output/cv-{candidate}-{company}-canva-{YYYY-MM-DD}.pdf" "{download_url}"
|
|
```
|
|
The export URL is a pre-signed S3 link that expires in ~2 hours. Download it right away.
|
|
c. Verify the download:
|
|
```bash
|
|
file output/cv-{candidate}-{company}-canva-{YYYY-MM-DD}.pdf
|
|
```
|
|
Must show "PDF document". If it shows XML or HTML, the URL expired — re-export and retry.
|
|
d. Report: PDF path, file size, Canva design URL (for manual tweaking)
|
|
|
|
#### Error handling
|
|
|
|
- If `import-design-from-url` fails → fall back to HTML/PDF pipeline with message
|
|
- If text elements can't be mapped → warn user, show what was found, ask for manual mapping
|
|
- If `find_and_replace_text` finds no matches → try broader substring matching
|
|
- Always provide the Canva design URL so the user can edit manually if auto-edit fails
|
|
|
|
## Cover Letter Sub-flow
|
|
|
|
After generating the CV PDF, offer to generate a cover letter:
|
|
|
|
```text
|
|
CV PDF generated: output/{path}
|
|
|
|
Want a cover letter for this role too?
|
|
- Say "yes" or "cover letter" to generate one now
|
|
- Or run `/career-ops cover {slug}` later
|
|
```
|
|
|
|
Apply `voice-dna.md` (if present) to the cover letter — full guardrail, conversational voice included (Tier 1 + Tier 2). The CV PDF itself stays Tier 1 only (formal ATS register). See `_shared.md` → Voice DNA.
|
|
|
|
If the user says yes, run the full cover letter flow from `modes/cover.md` in slug mode:
|
|
1. Load the existing `## Cover Letter Draft` from the evaluation report as a starting point
|
|
2. Run company research (Step 3 of cover.md)
|
|
3. Present keyword list for confirmation (Step 4)
|
|
4. Surface any gaps (Step 5)
|
|
5. Ask the four prompts: why / problems / approach / tone (Step 6)
|
|
6. Draft in chat, wait for approval (Steps 7-8)
|
|
7. Generate cover letter PDF via `node generate-cover-letter.mjs` (Step 9)
|
|
8. Report both PDF paths
|
|
|
|
Do not auto-generate the cover letter PDF without going through the interactive steps above.
|
|
|
|
## Post-generation
|
|
|
|
Update tracker if the job is already registered: change PDF from ❌ to ✅.
|