Skip to content

Commit be852fe

Browse files
St4r4xclaude
andcommitted
feat: harden CV pipeline with ATS lint, page-fit, and self-check
- lint_cv.py: hard-gate deterministic ATS pre-flight checks (contact fields, experience/education completeness) before rendering - generate_pdf(): enforce the 1-page constraint by checking WeasyPrint's real page count, retrying once with a compact CSS override, and warning on stderr if still overflowing instead of shipping silently - fit_to_one_page(): given optional content (certifications/projects/ hobbies) ranked least-to-most relevant, automatically drop and restore it to fit 1 page without manual trial and error - screenshot_cv() + --screenshot: render a PNG self-check next to the PDF via Playwright, so the tailoring agent can visually QA layout - cv-fr/cv-en templates: hide Experience/Skills/Education sections when empty, consistent with the other optional sections - modes/generate-cv.md: document the real Python-based tailoring workflow (CLI alone never exposed summary/bullet/droppable overrides) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 289a3c5 commit be852fe

9 files changed

Lines changed: 511 additions & 23 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
99

1010
### Added
1111
- `frontend/components/candidatures/candidatures-client.tsx` — "Masquer les offres abandonnées" checkbox above the offer list, checked by default; hides `Abandonnée` offers when no explicit status filter is set (selecting "Abandonnée" in the status dropdown still shows them)
12+
- `scripts/lint_cv.py` — deterministic ATS pre-flight lint (`lint_cv_context()`): flags missing contact fields (name/email/phone) and experience/education entries missing title/company/period or degree/school/period. Called as a hard gate in `generate_pdf()` before rendering — raises `ValueError` listing every issue instead of silently shipping a broken CV
13+
- `scripts/generate_pdf.py` — `screenshot_cv()` + `--screenshot` CLI flag: renders the CV HTML (CSS inlined, no file/base-url needed) to a full-page A4 PNG via Playwright, saved next to the PDF, so `modes/generate-cv.md`'s LLM agent can visually self-check layout before finalizing (long names wrapping, stack-tags overflowing, etc.)
14+
- `scripts/generate_pdf.py` — `fit_to_one_page()` + `generate_pdf(..., droppable=...)`: given optional content (certifications/projects/hobbies) ranked least-to-most relevant to the target offer, automatically drops everything droppable, then restores it back most-relevant-first as far as 1 page allows. Industrializes the manual trim-then-refill process used to fit a real CV (8 certs, 3 projects, hobbies) onto 1 page for the TREKEA offer test — verified to reproduce the same hand-tuned result automatically
1215

1316
### Changed
17+
- `scripts/generate_pdf.py` — `generate_pdf()` now renders via WeasyPrint's `HTML(...).render()` first and checks the resulting page count; a CV that overflows to 2+ pages is automatically re-rendered once with a compact CSS override (tighter margins/line-height/spacing) before falling back to a stderr warning if still over 1 page. Previously nothing enforced the "must stay 1 page" constraint — WeasyPrint would silently write a multi-page PDF
18+
- `scripts/generate_pdf.py`'s `build_cv_context()` now drops skill categories with an empty skill list, so post-tailoring content never leaves an empty category behind
19+
- `templates/cv-fr/cv.html.j2`, `templates/cv-en/cv.html.j2` — Experience, Skills, and Education sections are now wrapped in `{% if %}` guards, consistent with Summary/Certifications/Projects/Hobbies, instead of always rendering a section header even when empty
20+
- `modes/generate-cv.md` — step 4 now passes `--screenshot` and step 5 has the agent read the generated PNG before confirming; documents the new lint/page-count failure modes
1421
- `frontend/components/theme-toggle.tsx` — theme toggle button now shows a `Moon`/`Sun` icon (lucide-react) instead of "Mode sombre"/"Mode clair" text, with an `aria-label` carrying the same text for accessibility
1522
- `frontend/package.json` — bumped `next`/`eslint-config-next` `16.2.10` → `16.3.0`, clearing 9 GHSA advisories against `16.2.10` (middleware/proxy bypass, Server Actions DoS/SSRF, cache confusion, image-optimization DoS, internal endpoint disclosure); `npm audit` no longer lists `next` at all
1623
- `requirements.txt` — bumped `cryptography` `42.0.8` → `50.0.0`, `PyJWT` `2.9.0` → `2.13.0`, `mistune` `3.0.2` → `3.3.4`, `jinja2` `3.1.4` → `3.1.6`, `python-multipart` `0.0.29` → `0.0.32`, `python-dotenv` `1.0.1` → `1.2.2`, `weasyprint` `62.3.0` → `69.0` (dropped the `pydyf==0.11.0` compatibility pin now that weasyprint 69 pulls a compatible `pydyf` on its own — resolved to `0.12.1`); `pip-audit -r requirements.txt` no longer lists any of these. All three PDF generators (`generate_pdf.py`/`generate_cover_letter.py`/`generate_prep_sheet.py`) smoke-tested end-to-end (real WeasyPrint render, not mocked) to confirm the weasyprint/pydyf/jinja2 bump didn't break PDF output. `fastapi` `0.115.12` → `0.141.1` and an explicit `starlette==1.6.0` pin (previously an unpinned transitive of `fastapi`) close the remaining advisories; `pip-audit -r requirements.txt` now reports zero findings. `pytest`/`ruff` re-verified clean after the bump (one new dev-only warning: `starlette.testclient` deprecates `httpx` in favor of `httpx2`, not acted on here)

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,8 @@ scripts/
266266
dedup.py Accent-insensitive deduplication
267267
description_parser.py Parse raw job description text into structured fields per portal
268268
daily_report.py Markdown digest generation
269-
generate_pdf.py CV PDF generation (WeasyPrint + Jinja2, FR/EN)
269+
generate_pdf.py CV PDF generation (WeasyPrint + Jinja2, FR/EN); --screenshot renders a PNG self-check alongside the PDF
270+
lint_cv.py Deterministic ATS pre-flight lint, called by generate_pdf.py before rendering
270271
generate_cover_letter.py Cover letter PDF generation
271272
generate_prep_sheet.py Interview prep sheet PDF generation
272273
models.py Shared data models

‎modes/generate-cv.md‎

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,40 @@ Generate a tailored CV PDF for a specific job offer. Called from Claude Code CLI
1818
- `highlighted_skills`: list of skills from profile.md that match the offer keywords exactly
1919
- `summary`: rewrite the 2-sentence summary to mirror the offer's language (role title, key domain)
2020
- `experience.bullets`: for the NeuralVision AI/ML Engineer role, reorder bullets to lead with the most relevant ones for this offer. Do NOT invent new bullets.
21-
4. Run the PDF generation script:
21+
4. Summary rewrite and bullet reordering aren't exposed as CLI flags — call the Python functions directly instead of the bare CLI:
2222

23-
```bash
24-
python scripts/generate_pdf.py \
25-
--offer "<company-slug>" \
26-
--date "<YYYY-MM-DD>" \
27-
--highlighted <skill1> <skill2> ...
23+
```python
24+
import sys
25+
sys.path.insert(0, ".")
26+
from scripts.generate_pdf import default_context, generate_pdf
27+
28+
ctx = default_context(lang="fr") # or "en"
29+
ctx["summary"] = "..." # tailored summary
30+
ctx["experience"][0]["bullets"] = [...] # reordered, never invented
31+
ctx["highlighted_skills"] = ["<skill1>", "<skill2>", ...]
32+
33+
path = generate_pdf(ctx, offer="<company-slug>", output_date="<YYYY-MM-DD>", lang="fr", screenshot=True)
34+
```
35+
36+
5. If stderr warns the CV still exceeds 1 page after the automatic compact-layout retry, rank the optional content (certifications, projects, hobbies) least-to-most relevant to this offer and pass it as `droppable` to `generate_pdf()` — it trims from the least relevant end and restores as much as still fits 1 page, before falling back to the compact-CSS retry:
37+
38+
```python
39+
droppable = [
40+
("certification", "<least relevant cert name>"),
41+
("certification", "<next least relevant cert name>"),
42+
("project", "<least relevant project name>"),
43+
("hobbies", None), # dropped as a whole unit, tried last (most relevant to keep)
44+
]
45+
path = generate_pdf(ctx, offer="<slug>", output_date="<date>", lang="fr", screenshot=True, droppable=droppable)
2846
```
2947

30-
5. Confirm the PDF was created at `output/cv-<company-slug>-<date>.pdf`
48+
6. Read the generated `output/<slug>-<date>/cv-<slug>-<date>.png` screenshot (View/Read tool) and check for: text overflowing 1 page, long company/skill names wrapping badly, stack-tags spilling onto a new line. Re-tailor content and re-run if something looks broken.
49+
7. Confirm the PDF was created at `output/<slug>-<date>/cv-<slug>-<date>.pdf`
3150

3251
## Output
3352

34-
Print: `CV generated: output/cv-<slug>-<date>.pdf`
35-
Remind the user to open and visually verify the PDF before sending.
53+
Print: `CV generated: output/<slug>-<date>/cv-<slug>-<date>.pdf`
54+
The script raises on ATS pre-flight lint failures (missing contact fields, missing experience/education fields) and warns on stderr if the CV still exceeds 1 page after the automatic compact-layout retry — do not report success if that warning appears without fixing the content first.
3655

3756
## Constraints
3857

‎scripts/generate_pdf.py‎

Lines changed: 137 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33

44
import argparse
55
import re as _re
6+
import sys
67
from datetime import date
78
from pathlib import Path
89

910
import yaml
1011
from jinja2 import Environment, FileSystemLoader
11-
from weasyprint import HTML
12+
from weasyprint import CSS, HTML
1213

14+
from scripts.lint_cv import lint_cv_context
1315

1416
_ATS_REPLACEMENTS: list[tuple[str, str]] = [
1517
("—", "--"), # em-dash
@@ -18,7 +20,7 @@
1820
("’", "'"), # right single quote
1921
("“", '"'), # left double quote
2022
("”", '"'), # right double quote
21-
(" ", " "), # non-breaking space
23+
(" ", " "), # non-breaking space
2224
("​", ""), # zero-width space
2325
("‌", ""), # zero-width non-joiner
2426
("", ""), # BOM
@@ -29,6 +31,18 @@
2931
_re.DOTALL | _re.IGNORECASE,
3032
)
3133

34+
# ponytail: single compact retry, not an iterative shrink loop — escalate to
35+
# multiple compact levels only if one retry proves insufficient in practice.
36+
_COMPACT_CSS = CSS(
37+
string="""
38+
@page { margin: 5mm 10mm; }
39+
body { font-size: 8.7pt; line-height: 1.15; }
40+
.section { margin-bottom: 7px; }
41+
li { margin-bottom: 0; }
42+
.job { margin-bottom: 2px; }
43+
"""
44+
)
45+
3246

3347
def _normalize_for_ats(html: str) -> str:
3448
"""Replace typographic characters that break ATS parsers, preserving style/script blocks."""
@@ -96,7 +110,7 @@ def build_cv_context(
96110
"github": github,
97111
"summary": summary,
98112
"experience": experience,
99-
"skill_categories": skill_categories,
113+
"skill_categories": {k: v for k, v in skill_categories.items() if v},
100114
"highlighted_skills": highlighted_skills,
101115
"education": education,
102116
"languages": languages,
@@ -113,9 +127,103 @@ def render_html(context: dict, lang: str = "fr") -> str:
113127
return template.render(**context)
114128

115129

116-
def generate_pdf(context: dict, offer: str, output_date: str, lang: str = "fr") -> Path:
130+
def screenshot_cv(html_content: str, css_path: Path, output_path: Path) -> Path:
131+
"""Render CV HTML (CSS inlined) to a full-page A4 PNG for visual self-check."""
132+
from playwright.sync_api import sync_playwright
133+
134+
inlined = html_content.replace(
135+
'<link rel="stylesheet" href="cv.css">',
136+
f"<style>{css_path.read_text(encoding='utf-8')}</style>",
137+
)
138+
with sync_playwright() as pw:
139+
browser = pw.chromium.launch()
140+
page = browser.new_page(viewport={"width": 794, "height": 1123})
141+
page.set_content(inlined)
142+
page.screenshot(path=str(output_path), full_page=True)
143+
browser.close()
144+
return output_path
145+
146+
147+
_DROPPABLE_KINDS = {"certification", "project", "hobbies"}
148+
149+
150+
def _page_count(context: dict, lang: str) -> int:
151+
template_dir = TEMPLATE_DIR_EN if lang == "en" else TEMPLATE_DIR_FR
152+
html = _normalize_for_ats(render_html(context, lang=lang))
153+
return len(
154+
HTML(string=html, base_url=str(template_dir)).render(
155+
stylesheets=[str(template_dir / "cv.css")]
156+
).pages
157+
)
158+
159+
160+
def _apply_cuts(context: dict, cuts: list[tuple[str, str | None]]) -> dict:
161+
"""Return a copy of context with each (kind, name) cut removed."""
162+
ctx = dict(context)
163+
drop_certs = {name for kind, name in cuts if kind == "certification"}
164+
drop_projects = {name for kind, name in cuts if kind == "project"}
165+
if drop_certs:
166+
ctx["certifications"] = [
167+
c for c in (ctx.get("certifications") or []) if c["name"] not in drop_certs
168+
]
169+
if drop_projects:
170+
ctx["projects"] = [p for p in (ctx.get("projects") or []) if p["name"] not in drop_projects]
171+
if any(kind == "hobbies" for kind, _ in cuts):
172+
ctx["hobbies"] = []
173+
return ctx
174+
175+
176+
def fit_to_one_page(
177+
context: dict, lang: str, droppable: list[tuple[str, str | None]]
178+
) -> tuple[dict, list[str]]:
179+
"""Trim optional content to fit the CV on 1 page, keeping as much as possible.
180+
181+
`droppable` ranks removable content least-to-most relevant to the target offer, as
182+
(kind, name) pairs where kind is "certification" | "project" | "hobbies" (name is
183+
ignored for "hobbies", which drops the whole section as one unit).
184+
185+
ponytail: only handles whole-unit cuts (certs/projects/hobbies) -- experience bullets
186+
and skills are ranked by hand when building `context` (modes/generate-cv.md already
187+
does this). Extend with a 4th kind if bullet-level trimming proves necessary in practice.
188+
189+
Returns the resulting context (input is never mutated) and the labels still cut.
190+
"""
191+
for kind, _ in droppable:
192+
if kind not in _DROPPABLE_KINDS:
193+
raise ValueError(f"unknown droppable kind: {kind!r}")
194+
195+
if _page_count(context, lang) <= 1:
196+
return context, []
197+
198+
kept_cuts = list(droppable) # start by cutting everything droppable
199+
for cut in reversed(droppable): # try restoring most-relevant-first
200+
candidate_cuts = [c for c in kept_cuts if c != cut]
201+
if _page_count(_apply_cuts(context, candidate_cuts), lang) <= 1:
202+
kept_cuts = candidate_cuts
203+
204+
final = _apply_cuts(context, kept_cuts)
205+
return final, [f"{kind}:{name}" for kind, name in kept_cuts]
206+
207+
208+
def generate_pdf(
209+
context: dict,
210+
offer: str,
211+
output_date: str,
212+
lang: str = "fr",
213+
screenshot: bool = False,
214+
droppable: list[tuple[str, str | None]] | None = None,
215+
) -> Path:
117216
from scripts.models import safe_slug
118217

218+
issues = lint_cv_context(context)
219+
if issues:
220+
raise ValueError(
221+
"CV failed ATS pre-flight lint:\n" + "\n".join(f"- {issue}" for issue in issues)
222+
)
223+
224+
if droppable:
225+
context, _ = fit_to_one_page(context, lang, droppable)
226+
119227
template_dir = TEMPLATE_DIR_EN if lang == "en" else TEMPLATE_DIR_FR
120228
slug = safe_slug(offer)
121229
out_dir = (OUTPUT_DIR / f"{slug}-{output_date}").resolve()
@@ -126,10 +234,24 @@ def generate_pdf(context: dict, offer: str, output_date: str, lang: str = "fr")
126234
output_path = out_dir / f"cv-{slug}-{output_date}{suffix}.pdf"
127235
html_content = _normalize_for_ats(render_html(context, lang=lang))
128236
css_path = template_dir / "cv.css"
129-
HTML(string=html_content, base_url=str(template_dir)).write_pdf(
130-
str(output_path),
131-
stylesheets=[str(css_path)],
237+
238+
document = HTML(string=html_content, base_url=str(template_dir)).render(
239+
stylesheets=[str(css_path)]
132240
)
241+
if len(document.pages) > 1:
242+
document = HTML(string=html_content, base_url=str(template_dir)).render(
243+
stylesheets=[str(css_path), _COMPACT_CSS]
244+
)
245+
if len(document.pages) > 1:
246+
print(
247+
f"WARNING: CV is {len(document.pages)} pages after compact retry — trim content.",
248+
file=sys.stderr,
249+
)
250+
document.write_pdf(str(output_path))
251+
252+
if screenshot:
253+
screenshot_cv(html_content, css_path, output_path.with_suffix(".png"))
254+
133255
return output_path
134256

135257

@@ -187,9 +309,16 @@ def default_context_en() -> dict:
187309
default="fr",
188310
help="CV language: fr (default) or en",
189311
)
312+
parser.add_argument(
313+
"--screenshot",
314+
action="store_true",
315+
help="Also render a PNG screenshot next to the PDF for visual self-check",
316+
)
190317
args = parser.parse_args()
191318

192319
ctx = default_context(lang=args.lang)
193320
ctx["highlighted_skills"] = args.highlighted
194-
path = generate_pdf(ctx, offer=args.offer, output_date=args.date, lang=args.lang)
321+
path = generate_pdf(
322+
ctx, offer=args.offer, output_date=args.date, lang=args.lang, screenshot=args.screenshot
323+
)
195324
print(f"PDF generated: {path}")

‎scripts/lint_cv.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/usr/bin/env python3
2+
"""Deterministic pre-flight checks on CV content, run before PDF generation."""
3+
4+
5+
def lint_cv_context(context: dict) -> list[str]:
6+
"""Return ATS-parseability issues found in a CV context dict (empty list = clean)."""
7+
issues: list[str] = []
8+
9+
for field in ("name", "email", "phone"):
10+
if not context.get(field):
11+
issues.append(f"missing required contact field: {field}")
12+
13+
for i, job in enumerate(context.get("experience", [])):
14+
for field in ("title", "company", "period"):
15+
if not job.get(field):
16+
issues.append(f"experience[{i}] missing '{field}'")
17+
18+
for i, edu in enumerate(context.get("education", [])):
19+
for field in ("degree", "school", "period"):
20+
if not edu.get(field):
21+
issues.append(f"education[{i}] missing '{field}'")
22+
23+
return issues

‎templates/cv-en/cv.html.j2‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
</div>
2424
{% endif %}
2525

26+
{% if experience %}
2627
<div class="section">
2728
<div class="section-title">Experience</div>
2829
{% for job in experience %}
@@ -47,7 +48,9 @@
4748
</div>
4849
{% endfor %}
4950
</div>
51+
{% endif %}
5052

53+
{% if skill_categories %}
5154
<div class="section">
5255
<div class="section-title">Skills</div>
5356
{% for category, items in skill_categories.items() %}
@@ -59,6 +62,7 @@
5962
</div>
6063
{% endfor %}
6164
</div>
65+
{% endif %}
6266

6367
{% if certifications %}
6468
<div class="section">
@@ -84,6 +88,7 @@
8488
</div>
8589
{% endif %}
8690

91+
{% if education %}
8792
<div class="section">
8893
<div class="section-title">Education</div>
8994
{% for edu in education %}
@@ -96,6 +101,7 @@
96101
</div>
97102
{% endfor %}
98103
</div>
104+
{% endif %}
99105

100106
{% if languages %}
101107
<div class="section">

‎templates/cv-fr/cv.html.j2‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
</div>
2424
{% endif %}
2525

26+
{% if experience %}
2627
<div class="section">
2728
<div class="section-title">Expérience</div>
2829
{% for job in experience %}
@@ -47,7 +48,9 @@
4748
</div>
4849
{% endfor %}
4950
</div>
51+
{% endif %}
5052

53+
{% if skill_categories %}
5154
<div class="section">
5255
<div class="section-title">Compétences</div>
5356
{% for category, items in skill_categories.items() %}
@@ -59,6 +62,7 @@
5962
</div>
6063
{% endfor %}
6164
</div>
65+
{% endif %}
6266

6367
{% if certifications %}
6468
<div class="section">
@@ -84,6 +88,7 @@
8488
</div>
8589
{% endif %}
8690

91+
{% if education %}
8792
<div class="section">
8893
<div class="section-title">Formation</div>
8994
{% for edu in education %}
@@ -96,6 +101,7 @@
96101
</div>
97102
{% endfor %}
98103
</div>
104+
{% endif %}
99105

100106
{% if languages %}
101107
<div class="section">

0 commit comments

Comments
 (0)