Commit Graph

1 Commits (45d82feeea69a2cceb1a99974d4eedb366321f2d)

Author SHA1 Message Date
Talha Ahmed 8dcd262dc4 Repair CVs that pypdf extracts one character per line
CI / checks (push) Successful in 2m46s Details
Reported as "LinkedIn is not showing though the resume has it". The LinkedIn
was never the problem.

Traced on the live application (Mohammad Raza, inbox row 2f81cebc). Its stored
resume_text is 4,555 characters over 2,278 lines, and every one of those lines
is exactly one character long. The CV really does say

    LinkedIn:linkedin.com/in/mohammad-raza-digital-marketer

but it is stored as forty separate lines, so nothing that looks for a substring
can find it. Not a link annotation, not an image, not OCR: pypdf's default mode
breaks after every glyph on PDFs whose author positioned each one separately,
which design tools do routinely.

It survived review because a model reads that text fine. The candidate was
classified, matched and scored normally. What fails, silently, is every check
that asks "does this string appear in the resume":

  - slugs_from_text finds no profile, so linkedin_slug is stored empty
  - _clean_skills drops every skill, since each must appear in the text
  - the company and education clamps drop theirs for the same reason
  - verify_matched_keywords drops every matched keyword in the ATS engine

despace_line could not help: it rebuilds glyphs padded *within* a line, and
here there is nothing left on a line to rebuild.

is_glyph_fragmented measures the giveaway — the share of non-empty lines that
are a single character — and extract_pdf_text re-extracts with pypdf's layout
mode when it trips. Layout mode is the fallback, never the default: it is
slower and pads ordinary documents with alignment whitespace, so a CV that
extracts cleanly today is untouched. The fallback is checked before it is
trusted; fragmented text still scores a candidate, empty text fails them.

Both extractors had the defect, so the helpers live in app/services/pdf.py,
which owns PDF handling and is already imported by the recruiting path.

Measured against that real CV, before and after:

    slugs_from_text        []            -> ['mohammad-raza-digital-marketer']
    profile_url_from_text  None          -> https://www.linkedin.com/in/...
    lines                  2278          -> 61
    single-char lines      2278          -> 0
    'performance' found    False         -> True
    'google ads' found     False         -> True

Existing rows keep their broken text; extraction runs at ingest. Re-running
the match on affected rows is what backfills them.

.gitignore had `tests/**` twice and `/backend/tests/**` once. Both suites are
tracked and both run in CI, so the rules were inert for existing files and did
nothing but swallow new ones — this test was invisible to `git status` until
they went. That is also why they are removed rather than negated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:29:05 +05:00