mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
fix(latex): brace bracket-leading bullets, document escapes, pin pdftotext encoding
Three findings from the 2026-08-19 review (F9, F31, F34):
- F9: every placeholder bullet written as \item [text] let LaTeX parse
the bracketed text as the item's optional label, rendering it clipped
off the left page edge and absent from the PDF text layer ("Achievement"
appeared 9 times in cv/main_example.tex and 0 times in the extraction,
with a clean compile and green CI). Bullets are now braced as
\item {[text]} in the example CV and in the template
06-cover-letter-templates.md teaches, and CI's stock PDF assertions
additionally require "Achievement" to survive pdftotext.
- F31: 05-cv-templates.md gains a "LaTeX Special Characters" section and
06's is completed beyond \_ and \&. The load-bearing case is an
unescaped % in a quantified achievement bullet: it starts a LaTeX
comment and silently deletes the rest of the line from the PDF.
- F34: the documented ATS extraction commands (apply.md,
05-cv-templates.md, CLAUDE.md) now carry -enc UTF-8. Xpdf-based
pdftotext builds default to Latin-1 output, so a correct non-ASCII CV
failed the replacement-character parseability check.
framework_version: 05-cv-templates.md 1.4.1 -> 1.4.2,
06-cover-letter-templates.md 1.0.1 -> 1.0.2. All three pinned by the new
tests/test_latex_guidance.py (9 tests; suite now 261).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
40022dd8b9
commit
b2545d5121
@@ -254,12 +254,12 @@ Do not proceed to Step 6 until both PDFs pass inspection.
|
|||||||
|
|
||||||
An ATS parser reads the PDF's embedded **text layer**, not the rendered page — a CV that passed visual inspection can still extract as garbage (icon glyphs where the contact details should be, scrambled reading order in multi-column layouts). This step verifies what a parser actually sees. It applies to the **CV only**; cover letters rarely go through keyword screening.
|
An ATS parser reads the PDF's embedded **text layer**, not the rendered page — a CV that passed visual inspection can still extract as garbage (icon glyphs where the contact details should be, scrambled reading order in multi-column layouts). This step verifies what a parser actually sees. It applies to the **CV only**; cover letters rarely go through keyword screening.
|
||||||
|
|
||||||
**Availability check:** run `pdftotext -v`. `pdftotext` (poppler) is an optional dependency, not part of TeX distributions. If it is missing, print a one-line warning that the mechanical parse check is skipped, do the keyword-coverage check (item 3 below) against your visual Read of the PDF instead, and note the degraded mode in the Step 6 report. Same graceful-skip pattern as the salary lookup.
|
**Availability check:** run `pdftotext -v`. `pdftotext` (poppler) is an optional dependency, not part of TeX distributions. If it is missing, print a one-line warning that the mechanical parse check is skipped, do the keyword-coverage check (item 3 below) against your visual Read of the PDF instead, and note the degraded mode in the Step 6 report. Same graceful-skip pattern as the salary lookup. Keep the `-enc UTF-8` flag: Xpdf-based builds default to Latin-1 output, and without it a correct non-ASCII CV fails the replacement-character check below.
|
||||||
|
|
||||||
**1. Extract the text layer:**
|
**1. Extract the text layer:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd cv && pdftotext -layout main_<company>_<role>.pdf main_<company>_<role>.txt
|
cd cv && pdftotext -layout -enc UTF-8 main_<company>_<role>.pdf main_<company>_<role>.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Read the `.txt` file.
|
Read the `.txt` file.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
framework_version: 1.4.1
|
framework_version: 1.4.2
|
||||||
---
|
---
|
||||||
|
|
||||||
# CV Templates and Tailoring Guide
|
# CV Templates and Tailoring Guide
|
||||||
@@ -211,6 +211,27 @@ Wherever the CV names a verifiable artifact - a public project, a hackathon entr
|
|||||||
- End with: "More references are available upon request."
|
- End with: "More references are available upon request."
|
||||||
- **Do not attach reference letters** - employers typically contact references directly
|
- **Do not attach reference letters** - employers typically contact references directly
|
||||||
|
|
||||||
|
### LaTeX Special Characters (important)
|
||||||
|
|
||||||
|
Postings and profile data arrive as plain text; the CV is LaTeX. Escape these wherever they land in body text - company names, achievement bullets, skill lists:
|
||||||
|
|
||||||
|
| Character | Write | Typical trigger |
|
||||||
|
|---|---|---|
|
||||||
|
| `&` | `\&` | company names: Bang \& Olufsen, Brüel \& Kjær, H\&M |
|
||||||
|
| `%` | `\%` | quantified achievements: "cut latency by 40\%" |
|
||||||
|
| `$` | `\$` | salary and cost figures |
|
||||||
|
| `#` | `\#` | "ranked \#1", C\# |
|
||||||
|
| `_` | `\_` | file names, code identifiers |
|
||||||
|
| `~` | `\textasciitilde{}` | URLs, "approx. 5 years" tildes |
|
||||||
|
| `^` | `\textasciicircum{}` | version strings, math |
|
||||||
|
|
||||||
|
Two failure modes deserve special care:
|
||||||
|
|
||||||
|
- **`%` fails silently.** An unescaped `%` starts a LaTeX comment: the compile succeeds with zero errors, and everything after the `%` on that line vanishes from the PDF. `Cut inference latency by 40% and saved DKK 2M annually` renders as "Cut inference latency by 40" - the bullet keeps its impressive-looking fragment and loses the actual result. Quantified achievement bullets are exactly where the guidance steers you ("use numbers where possible"), so check every `%` in every bullet before compiling.
|
||||||
|
- **`&` fails loudly** inside `\cventry` (alignment-tab errors, `Missing } inserted`). The compile loop catches it, but escape employer names up front rather than debugging the compile.
|
||||||
|
|
||||||
|
Related trap: a bullet whose text begins with a literal `[` must be braced - `\item {[text]}` - or LaTeX parses the bracketed text as `\item`'s optional label and renders it clipped off the left page edge with a clean compile. The example CV's placeholder bullets are braced for exactly this reason.
|
||||||
|
|
||||||
## Compile-and-Inspect Loop (MANDATORY)
|
## Compile-and-Inspect Loop (MANDATORY)
|
||||||
|
|
||||||
After writing the CV and before presenting to the user, always compile and visually inspect the PDF. Iterate until the layout is clean. Workflow:
|
After writing the CV and before presenting to the user, always compile and visually inspect the PDF. Iterate until the layout is clean. Workflow:
|
||||||
@@ -246,10 +267,10 @@ Restore the highest-relevance item that was previously cut — a CV that ends mi
|
|||||||
Most employers run CVs through an ATS before a human sees them, and the ATS reads the PDF's embedded **text layer**, not the rendered page. A CV can pass visual inspection and still extract as garbage. After the layout passes the compile-and-inspect loop, verify the text layer:
|
Most employers run CVs through an ATS before a human sees them, and the ATS reads the PDF's embedded **text layer**, not the rendered page. A CV can pass visual inspection and still extract as garbage. After the layout passes the compile-and-inspect loop, verify the text layer:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd cv && pdftotext -layout main_<company>_<role>.pdf main_<company>_<role>.txt
|
cd cv && pdftotext -layout -enc UTF-8 main_<company>_<role>.pdf main_<company>_<role>.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
`pdftotext` comes from [poppler](https://poppler.freedesktop.org/), not the TeX distribution - it is an **optional** dependency. If it is not installed, skip the mechanical check with a warning and rely on the visual PDF read for keyword coverage.
|
`pdftotext` comes from [poppler](https://poppler.freedesktop.org/), not the TeX distribution - it is an **optional** dependency. The `-enc UTF-8` flag is not optional: Xpdf-based `pdftotext` builds default to Latin-1 output, which makes every non-ASCII character in a perfectly good CV read back as a replacement character and fail the parseability check below for no real reason. If it is not installed, skip the mechanical check with a warning and rely on the visual PDF read for keyword coverage.
|
||||||
|
|
||||||
What to check in the extraction:
|
What to check in the extraction:
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
framework_version: 1.0.1
|
framework_version: 1.0.2
|
||||||
---
|
---
|
||||||
|
|
||||||
# Cover Letter Templates and Tailoring Guide
|
# Cover Letter Templates and Tailoring Guide
|
||||||
@@ -92,9 +92,9 @@ The font wrapper is mandatory — if you just move `\begin{itemize}` outside `\l
|
|||||||
|
|
||||||
{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont
|
{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont
|
||||||
\begin{itemize}
|
\begin{itemize}
|
||||||
\item [Concrete achievement/skill 1]
|
\item {[Concrete achievement/skill 1]}
|
||||||
\item [Concrete achievement/skill 2]
|
\item {[Concrete achievement/skill 2]}
|
||||||
\item [Concrete achievement/skill 3]
|
\item {[Concrete achievement/skill 3]}
|
||||||
\end{itemize}\par}
|
\end{itemize}\par}
|
||||||
|
|
||||||
\lettercontent{[Connection to company - why this role, why this company specifically]}
|
\lettercontent{[Connection to company - why this role, why this company specifically]}
|
||||||
@@ -146,10 +146,14 @@ The font wrapper is mandatory — if you just move `\begin{itemize}` outside `\l
|
|||||||
- 3-5 bullets is ideal
|
- 3-5 bullets is ideal
|
||||||
- Start each bullet with bold label or action verb
|
- Start each bullet with bold label or action verb
|
||||||
- Use `\textbf{Label:}` for category-style bullets
|
- Use `\textbf{Label:}` for category-style bullets
|
||||||
|
- A bullet whose text begins with a literal `[` must be braced: `\item {[text]}`. Unbraced, LaTeX parses `[text]` as `\item`'s optional label and renders it off the left page edge, missing from the PDF text layer entirely
|
||||||
|
|
||||||
### LaTeX Special Characters
|
### LaTeX Special Characters
|
||||||
- Underscore: `\_`
|
Escape these wherever they appear in body text:
|
||||||
- Ampersand: `\&`
|
- Ampersand: `\&` (company names: Brüel \& Kjær, H\&M) - unescaped, the compile fails loudly
|
||||||
|
- Percent: `\%` ("grew revenue 30\%") - unescaped, it does **not** fail: everything after the `%` on that line is silently eaten as a LaTeX comment
|
||||||
|
- Dollar: `\$`, hash: `\#`, underscore: `\_`
|
||||||
|
- Tilde: `\textasciitilde{}`, caret: `\textasciicircum{}`, backslash: `\textbackslash{}`
|
||||||
|
|
||||||
### Non-English Cover Letters
|
### Non-English Cover Letters
|
||||||
- Same template structure, just write content in the posting's language
|
- Same template structure, just write content in the posting's language
|
||||||
|
|||||||
@@ -144,7 +144,8 @@ jobs:
|
|||||||
python3 tools/verify_pdf.py cv/main_example.pdf \
|
python3 tools/verify_pdf.py cv/main_example.pdf \
|
||||||
--pages 2 \
|
--pages 2 \
|
||||||
--contains '[your.email@example.com]' \
|
--contains '[your.email@example.com]' \
|
||||||
--contains 'Professional Experience'
|
--contains 'Professional Experience' \
|
||||||
|
--contains 'Achievement'
|
||||||
python3 tools/verify_pdf.py cover_letters/cover_example.pdf \
|
python3 tools/verify_pdf.py cover_letters/cover_example.pdf \
|
||||||
--pages 1 \
|
--pages 1 \
|
||||||
--contains 'your.email@example.com' \
|
--contains 'your.email@example.com' \
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ per-file diff commands.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **LaTeX special-character guidance for CVs** (`framework_version` 1.4.1 -> 1.4.2 in
|
||||||
|
`05-cv-templates.md`, 1.0.1 -> 1.0.2 in `06-cover-letter-templates.md`) - `05` gains a
|
||||||
|
"LaTeX Special Characters" section and `06`'s existing one is completed beyond `\_`/`\&`.
|
||||||
|
The load-bearing case is an unescaped `%` in a quantified achievement bullet: it starts a
|
||||||
|
LaTeX comment, so "cut latency by 40% and saved DKK 2M" compiles with zero errors and
|
||||||
|
renders as "cut latency by 40" - silent content loss in the deliverable, on exactly the
|
||||||
|
content the guidance steers users to write. `&` in employer names (Bang & Olufsen, H&M)
|
||||||
|
fails loudly at compile time and is now documented alongside. Pinned by
|
||||||
|
`tests/test_latex_guidance.py`.
|
||||||
|
|
||||||
- **`seen_jobs.json` entries record which mechanism produced them** - a new additive `source`
|
- **`seen_jobs.json` entries record which mechanism produced them** - a new additive `source`
|
||||||
field (`cli` for Step 1b portal-CLI output, `websearch` for the Step 1c fallback), a Step 1c
|
field (`cli` for Step 1b portal-CLI output, `websearch` for the Step 1c fallback), a Step 1c
|
||||||
rule tagging fallback results at collection time, and a `fallback (websearch):` line in the
|
rule tagging fallback results at collection time, and a `fallback (websearch):` line in the
|
||||||
@@ -46,6 +56,23 @@ per-file diff commands.
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **Example-CV bullets no longer swallowed as LaTeX optional labels** - every placeholder
|
||||||
|
bullet written as `\item [text]` (11 in `cv/main_example.tex`, 3 in
|
||||||
|
`06-cover-letter-templates.md`'s taught template) let LaTeX parse the bracketed text as
|
||||||
|
`\item`'s optional argument: the shipped example CV rendered all Professional Experience
|
||||||
|
bullets clipped off the left page edge, with the word "Achievement" appearing 9 times in
|
||||||
|
the source and 0 times in the PDF text layer - a clean compile, green CI. Bullets are now
|
||||||
|
braced (`\item {[text]}`), the cover-letter guide teaches the braced form, and CI's stock
|
||||||
|
PDF assertions additionally require `Achievement` to survive `pdftotext`. Pinned by
|
||||||
|
`tests/test_latex_guidance.py`.
|
||||||
|
- **Documented ATS extraction commands pin `-enc UTF-8`** - `pdftotext -layout` without an
|
||||||
|
encoding flag emits Latin-1 on Xpdf builds, so every non-ASCII character in a correct CV
|
||||||
|
(Rambøll, Ingeniør, København) read back as a replacement character and failed the
|
||||||
|
parseability checklist, steering the agent to "fix" a healthy document. The commands in
|
||||||
|
`apply.md`, `05-cv-templates.md`, and `CLAUDE.md`'s verification checklist now carry
|
||||||
|
`-enc UTF-8`, which is deterministic on both poppler and Xpdf. Pinned by
|
||||||
|
`tests/test_latex_guidance.py`.
|
||||||
|
|
||||||
- **`jobbank-search` search output now carries the `/scrape` contract's `date` field** (#342) -
|
- **`jobbank-search` search output now carries the `/scrape` contract's `date` field** (#342) -
|
||||||
the CLI emitted `posted` (full ISO 8601) but not the cross-portal `date` key, the one Step 2
|
the CLI emitted `posted` (full ISO 8601) but not the cross-portal `date` key, the one Step 2
|
||||||
contract field it was missing. Search results now additively emit `date` as `YYYY-MM-DD`
|
contract field it was missing. Search results now additively emit `date` as `YYYY-MM-DD`
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ Both documents MUST be compiled and visually inspected via the Read tool on the
|
|||||||
- [ ] **Cover letter bullet font matches body font** - `\lettercontent{}` must not wrap `\begin{itemize}...\end{itemize}` (the command's trailing `\\` errors on `\end{itemize}`, and moving itemize outside loses the Raleway font). Standard pattern: close `\lettercontent{}`, then wrap the list in `{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont \begin{itemize}...\end{itemize}\par}`
|
- [ ] **Cover letter bullet font matches body font** - `\lettercontent{}` must not wrap `\begin{itemize}...\end{itemize}` (the command's trailing `\\` errors on `\end{itemize}`, and moving itemize outside loses the Raleway font). Standard pattern: close `\lettercontent{}`, then wrap the list in `{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont \begin{itemize}...\end{itemize}\par}`
|
||||||
|
|
||||||
### ATS & keyword verification (CV)
|
### ATS & keyword verification (CV)
|
||||||
ATS parsers read the PDF's embedded text layer, not the rendered page. Extract it with `pdftotext -layout` and verify what a parser sees. `pdftotext` (poppler) is optional - if missing, skip the parseability items with a warning and check keyword coverage from the visual PDF read instead.
|
ATS parsers read the PDF's embedded text layer, not the rendered page. Extract it with `pdftotext -layout -enc UTF-8` and verify what a parser sees. `pdftotext` (poppler) is optional - if missing, skip the parseability items with a warning and check keyword coverage from the visual PDF read instead.
|
||||||
- [ ] CV text layer extracts cleanly - no `(cid:*)` markers, `�` replacement characters, or text visible in the PDF but absent from the extraction
|
- [ ] CV text layer extracts cleanly - no `(cid:*)` markers, `�` replacement characters, or text visible in the PDF but absent from the extraction
|
||||||
- [ ] Email and phone appear as **literal text** in the extraction (icon-glyph noise like `MOBILE-ALT`/`Envelope` is harmless, but a contact detail carried only by an icon or hyperlink is invisible to ATS)
|
- [ ] Email and phone appear as **literal text** in the extraction (icon-glyph noise like `MOBILE-ALT`/`Envelope` is harmless, but a contact detail carried only by an icon or hyperlink is invisible to ATS)
|
||||||
- [ ] Reading order of the extracted text matches the visual order (single-column stock template is safe; multi-column custom templates are where this breaks)
|
- [ ] Reading order of the extracted text matches the visual order (single-column stock template is safe; multi-column custom templates are where this breaks)
|
||||||
|
|||||||
+11
-11
@@ -93,10 +93,10 @@
|
|||||||
% --- Most Recent Role ---
|
% --- Most Recent Role ---
|
||||||
\item{\cventry{[YYYY-Present]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt}
|
\item{\cventry{[YYYY-Present]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt}
|
||||||
\begin{itemize}
|
\begin{itemize}
|
||||||
\item [Achievement or responsibility 1 - be specific, use numbers where possible]
|
\item {[Achievement or responsibility 1 - be specific, use numbers where possible]}
|
||||||
\item [Achievement or responsibility 2]
|
\item {[Achievement or responsibility 2]}
|
||||||
\item [Achievement or responsibility 3]
|
\item {[Achievement or responsibility 3]}
|
||||||
\item [Achievement or responsibility 4]
|
\item {[Achievement or responsibility 4]}
|
||||||
\end{itemize}}}
|
\end{itemize}}}
|
||||||
|
|
||||||
\vspace{3pt}
|
\vspace{3pt}
|
||||||
@@ -104,9 +104,9 @@
|
|||||||
% --- Previous Role ---
|
% --- Previous Role ---
|
||||||
\item{\cventry{[YYYY-YYYY]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt}
|
\item{\cventry{[YYYY-YYYY]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt}
|
||||||
\begin{itemize}
|
\begin{itemize}
|
||||||
\item [Achievement or responsibility 1]
|
\item {[Achievement or responsibility 1]}
|
||||||
\item [Achievement or responsibility 2]
|
\item {[Achievement or responsibility 2]}
|
||||||
\item [Achievement or responsibility 3]
|
\item {[Achievement or responsibility 3]}
|
||||||
\end{itemize}}}
|
\end{itemize}}}
|
||||||
|
|
||||||
\vspace{3pt}
|
\vspace{3pt}
|
||||||
@@ -114,8 +114,8 @@
|
|||||||
% --- Earlier Role ---
|
% --- Earlier Role ---
|
||||||
\item{\cventry{[YYYY-YYYY]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt}
|
\item{\cventry{[YYYY-YYYY]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt}
|
||||||
\begin{itemize}
|
\begin{itemize}
|
||||||
\item [Achievement or responsibility 1]
|
\item {[Achievement or responsibility 1]}
|
||||||
\item [Achievement or responsibility 2]
|
\item {[Achievement or responsibility 2]}
|
||||||
\end{itemize}}}
|
\end{itemize}}}
|
||||||
|
|
||||||
\end{itemize}
|
\end{itemize}
|
||||||
@@ -147,7 +147,7 @@ Thesis: ``[Thesis Title].'' [Brief description of research focus.]
|
|||||||
\section{Languages}
|
\section{Languages}
|
||||||
\vspace{1pt}
|
\vspace{1pt}
|
||||||
\begin{itemize}
|
\begin{itemize}
|
||||||
\item [Language 1] (native), [Language 2] (fluent), [Language 3] (intermediate).
|
\item {[Language 1] (native), [Language 2] (fluent), [Language 3] (intermediate).}
|
||||||
\end{itemize}
|
\end{itemize}
|
||||||
|
|
||||||
% ============================================================
|
% ============================================================
|
||||||
@@ -157,7 +157,7 @@ Thesis: ``[Thesis Title].'' [Brief description of research focus.]
|
|||||||
\section{Publications}
|
\section{Publications}
|
||||||
\vspace{1pt}
|
\vspace{1pt}
|
||||||
\begin{itemize}
|
\begin{itemize}
|
||||||
\item [Author(s)] ([Year]). [Title]. [Journal/Conference]. \href{[DOI_URL]}{DOI link}
|
\item {[Author(s)] ([Year]). [Title]. [Journal/Conference]. \href{[DOI_URL]}{DOI link}}
|
||||||
\end{itemize}
|
\end{itemize}
|
||||||
|
|
||||||
% ============================================================
|
% ============================================================
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Guards for the LaTeX authoring guidance and the example documents.
|
||||||
|
|
||||||
|
Three silent-failure modes live here, all found by the 2026-08-19 review
|
||||||
|
(F9, F31, F34). Each one produces a clean compile and a green CI run
|
||||||
|
while the rendered document or its ATS extraction is wrong, so the spec
|
||||||
|
files and the example sources are the only place a test can catch them:
|
||||||
|
|
||||||
|
- F9: a bullet written as `\\item [text]` is parsed as moderncv's
|
||||||
|
optional label, rendered off the left page edge, and dropped from the
|
||||||
|
PDF text layer. The example CV shipped that way for months.
|
||||||
|
- F31: an unescaped `%` in body text silently truncates the rest of the
|
||||||
|
line (`&` at least fails loudly). The guidance must name the escapes.
|
||||||
|
- F34: `pdftotext` without `-enc UTF-8` emits Latin-1 on Xpdf builds,
|
||||||
|
so a correct Danish CV fails the documented "no replacement
|
||||||
|
characters" check and the agent is sent to "fix" a healthy document.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
SKILL_DIR = REPO / ".claude" / "skills" / "job-application-assistant"
|
||||||
|
CV_TEMPLATES = SKILL_DIR / "05-cv-templates.md"
|
||||||
|
COVER_TEMPLATES = SKILL_DIR / "06-cover-letter-templates.md"
|
||||||
|
APPLY = REPO / ".claude" / "commands" / "apply.md"
|
||||||
|
EXAMPLE_CV = REPO / "cv" / "main_example.tex"
|
||||||
|
EXAMPLE_COVER = REPO / "cover_letters" / "cover_example.tex"
|
||||||
|
|
||||||
|
# \item whose body starts with [ - with or without whitespace between.
|
||||||
|
# LaTeX skips spaces while scanning for the optional argument, so
|
||||||
|
# `\item [text]` and `\item[text]` both swallow the text as a label.
|
||||||
|
# The safe spelling `\item {[text]}` does not match.
|
||||||
|
UNBRACED_BRACKET_ITEM = re.compile(r"\\item\s*\[")
|
||||||
|
|
||||||
|
# The escapes both guidance files must document. `%` is the load-bearing
|
||||||
|
# one: it truncates silently. The others fail loudly or corrupt spacing.
|
||||||
|
REQUIRED_ESCAPES = ["\\&", "\\%", "\\$", "\\#", "\\_"]
|
||||||
|
|
||||||
|
|
||||||
|
def section(text, heading):
|
||||||
|
"""Return the body of a markdown section up to the next heading."""
|
||||||
|
pattern = re.compile(
|
||||||
|
rf"^#+ {re.escape(heading)}[^\n]*\n(.*?)(?=^#+ |\Z)",
|
||||||
|
re.MULTILINE | re.DOTALL,
|
||||||
|
)
|
||||||
|
match = pattern.search(text)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
class TestBulletBracketTrap(unittest.TestCase):
|
||||||
|
"""F9: no document or template doc may teach `\\item [text]`."""
|
||||||
|
|
||||||
|
def assert_no_unbraced_bracket_items(self, path):
|
||||||
|
offending = [
|
||||||
|
f"{path.name}:{lineno}: {line.strip()}"
|
||||||
|
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
|
||||||
|
if UNBRACED_BRACKET_ITEM.search(line)
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
offending,
|
||||||
|
[],
|
||||||
|
"\\item followed by [ is parsed as an optional label and the "
|
||||||
|
"text is clipped off the page; write \\item {[...]} instead:\n"
|
||||||
|
+ "\n".join(offending),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_example_cv_has_no_bracket_labelled_bullets(self):
|
||||||
|
self.assert_no_unbraced_bracket_items(EXAMPLE_CV)
|
||||||
|
|
||||||
|
def test_example_cover_letter_has_no_bracket_labelled_bullets(self):
|
||||||
|
self.assert_no_unbraced_bracket_items(EXAMPLE_COVER)
|
||||||
|
|
||||||
|
def test_cover_letter_guide_does_not_teach_the_broken_pattern(self):
|
||||||
|
self.assert_no_unbraced_bracket_items(COVER_TEMPLATES)
|
||||||
|
|
||||||
|
def test_cv_guide_does_not_teach_the_broken_pattern(self):
|
||||||
|
self.assert_no_unbraced_bracket_items(CV_TEMPLATES)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpecialCharacterGuidance(unittest.TestCase):
|
||||||
|
"""F31: both template guides must document the LaTeX escapes."""
|
||||||
|
|
||||||
|
def assert_escapes_documented(self, path):
|
||||||
|
body = section(path.read_text(encoding="utf-8"), "LaTeX Special Characters")
|
||||||
|
self.assertIsNotNone(
|
||||||
|
body, f"{path.name} has no 'LaTeX Special Characters' section"
|
||||||
|
)
|
||||||
|
missing = [esc for esc in REQUIRED_ESCAPES if esc not in body]
|
||||||
|
self.assertEqual(
|
||||||
|
missing,
|
||||||
|
[],
|
||||||
|
f"{path.name}'s special-characters section is missing: {missing}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cv_guide_documents_the_escapes(self):
|
||||||
|
self.assert_escapes_documented(CV_TEMPLATES)
|
||||||
|
|
||||||
|
def test_cover_letter_guide_documents_the_escapes(self):
|
||||||
|
self.assert_escapes_documented(COVER_TEMPLATES)
|
||||||
|
|
||||||
|
def test_cv_guide_warns_that_percent_truncates_silently(self):
|
||||||
|
body = section(
|
||||||
|
CV_TEMPLATES.read_text(encoding="utf-8"), "LaTeX Special Characters"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(body)
|
||||||
|
self.assertRegex(
|
||||||
|
body,
|
||||||
|
re.compile(r"silent", re.IGNORECASE),
|
||||||
|
"the % failure mode must be called out as silent - it is the "
|
||||||
|
"reason this section exists (a clean compile with the rest of "
|
||||||
|
"the bullet gone)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAtsExtractionEncoding(unittest.TestCase):
|
||||||
|
"""F34: every documented extraction command must pin the encoding."""
|
||||||
|
|
||||||
|
def assert_pdftotext_commands_pin_utf8(self, path):
|
||||||
|
offending = [
|
||||||
|
f"{path.name}:{lineno}: {line.strip()}"
|
||||||
|
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
|
||||||
|
if "pdftotext" in line and "-layout" in line and "-enc UTF-8" not in line
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
offending,
|
||||||
|
[],
|
||||||
|
"pdftotext without -enc UTF-8 emits Latin-1 on Xpdf builds, so "
|
||||||
|
"the ATS check reports phantom replacement characters on any "
|
||||||
|
"non-ASCII CV; add -enc UTF-8:\n" + "\n".join(offending),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_apply_extraction_command_pins_utf8(self):
|
||||||
|
self.assert_pdftotext_commands_pin_utf8(APPLY)
|
||||||
|
|
||||||
|
def test_cv_guide_extraction_command_pins_utf8(self):
|
||||||
|
self.assert_pdftotext_commands_pin_utf8(CV_TEMPLATES)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user