codebase-receipts-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codebase_receipts_cli-1.0.0.dist-info/METADATA +268 -0
- codebase_receipts_cli-1.0.0.dist-info/RECORD +78 -0
- codebase_receipts_cli-1.0.0.dist-info/WHEEL +4 -0
- codebase_receipts_cli-1.0.0.dist-info/entry_points.txt +2 -0
- codebase_receipts_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- receipts/__init__.py +0 -0
- receipts/ama/__init__.py +0 -0
- receipts/ama/interviewer.py +415 -0
- receipts/ats/__init__.py +1 -0
- receipts/ats/comparator.py +98 -0
- receipts/ats/scorer.py +550 -0
- receipts/ats/stats.py +164 -0
- receipts/cli.py +2062 -0
- receipts/config.py +106 -0
- receipts/errors.py +18 -0
- receipts/export.py +120 -0
- receipts/ingest/__init__.py +0 -0
- receipts/ingest/artifact_extractor.py +679 -0
- receipts/ingest/git_source.py +159 -0
- receipts/ingest/manifest.py +114 -0
- receipts/ingest/scanner.py +141 -0
- receipts/ingest/secrets_scanner.py +225 -0
- receipts/interactive/__init__.py +1 -0
- receipts/interactive/repl.py +755 -0
- receipts/ledger/__init__.py +0 -0
- receipts/ledger/pricing_table.py +54 -0
- receipts/ledger/token_ledger.py +226 -0
- receipts/llm/__init__.py +0 -0
- receipts/llm/anthropic_provider.py +95 -0
- receipts/llm/factory.py +124 -0
- receipts/llm/fake_provider.py +79 -0
- receipts/llm/gemini_provider.py +205 -0
- receipts/llm/json_utils.py +46 -0
- receipts/llm/metered.py +62 -0
- receipts/llm/ollama_provider.py +117 -0
- receipts/llm/openai_provider.py +118 -0
- receipts/llm/provider.py +42 -0
- receipts/llm/split.py +78 -0
- receipts/llm/token_estimate.py +35 -0
- receipts/mine/__init__.py +1 -0
- receipts/mine/code_metrics.py +246 -0
- receipts/mine/git_evidence.py +109 -0
- receipts/prep/__init__.py +1 -0
- receipts/prep/dossier.py +313 -0
- receipts/prep/readiness.py +227 -0
- receipts/resume/__init__.py +0 -0
- receipts/resume/claim_extractor.py +338 -0
- receipts/resume/loader.py +35 -0
- receipts/resume/pdf_parser.py +259 -0
- receipts/resume/tex_parser.py +394 -0
- receipts/rewrite/__init__.py +0 -0
- receipts/rewrite/compiler.py +180 -0
- receipts/rewrite/optimizer.py +140 -0
- receipts/rewrite/tex_rewriter.py +227 -0
- receipts/tui/__init__.py +0 -0
- receipts/tui/ama_app.py +454 -0
- receipts/tui/app.py +316 -0
- receipts/tui/dossier_app.py +170 -0
- receipts/tui/hub.py +367 -0
- receipts/tui/ingest_app.py +183 -0
- receipts/tui/rewrite_app.py +463 -0
- receipts/tui/score_app.py +237 -0
- receipts/tui/widgets/__init__.py +0 -0
- receipts/tui/widgets/claims_table.py +50 -0
- receipts/tui/widgets/diff_view.py +38 -0
- receipts/tui/widgets/status_bar.py +38 -0
- receipts/verify/__init__.py +0 -0
- receipts/verify/bm25.py +112 -0
- receipts/verify/enrich.py +128 -0
- receipts/verify/jd_fetch.py +101 -0
- receipts/verify/kb.py +379 -0
- receipts/verify/keyword_gap.py +127 -0
- receipts/verify/query_expand.py +88 -0
- receipts/verify/rerank.py +74 -0
- receipts/verify/rewriter.py +172 -0
- receipts/verify/router.py +211 -0
- receipts/verify/summaries.py +258 -0
- receipts/verify/verifier.py +552 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Extract verifiable claims from parsed resume bullets.
|
|
2
|
+
|
|
3
|
+
Three kinds of claims are pulled:
|
|
4
|
+
|
|
5
|
+
* **numeric** — percentages, counts, latency, durations, ratios
|
|
6
|
+
(``"~40%"``, ``"12 microservices"``, ``"sub-2s"``).
|
|
7
|
+
* **technology** — specific tools, frameworks, languages, platforms
|
|
8
|
+
(``"FastAPI"``, ``"ChromaDB"``, ``"React"``).
|
|
9
|
+
* **contextual** — whole-bullet claims from sections where code evidence
|
|
10
|
+
is unlikely (education, work experience, certifications, etc.). These
|
|
11
|
+
exist so the AMA interviewer can still quiz on them.
|
|
12
|
+
|
|
13
|
+
Each claim carries surrounding context so the verifier (Phase 4) can
|
|
14
|
+
search the knowledge base for supporting evidence.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from receipts.resume.tex_parser import ParsedResume
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Section classification
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
_CODE_SECTION_KEYWORDS = ["project"]
|
|
29
|
+
_EXPERIENCE_SECTION_KEYWORDS = ["experience", "work", "internship", "employment"]
|
|
30
|
+
_EDUCATION_SECTION_KEYWORDS = ["education"]
|
|
31
|
+
_SKILLS_SECTION_KEYWORDS = ["skill", "technical"]
|
|
32
|
+
_NOPROBE_SECTION_KEYWORDS = [
|
|
33
|
+
"certification",
|
|
34
|
+
"publication",
|
|
35
|
+
"award",
|
|
36
|
+
"honor",
|
|
37
|
+
"extracurricular",
|
|
38
|
+
"leadership",
|
|
39
|
+
"volunteer",
|
|
40
|
+
"achievement",
|
|
41
|
+
"activity",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def classify_section(section_name: str) -> str:
|
|
46
|
+
"""Classify a resume section into a category for verification/AMA behavior.
|
|
47
|
+
|
|
48
|
+
Returns one of: ``"project"``, ``"experience"``, ``"education"``,
|
|
49
|
+
``"skills"``, ``"other"``.
|
|
50
|
+
"""
|
|
51
|
+
lower = section_name.lower()
|
|
52
|
+
for kw in _CODE_SECTION_KEYWORDS:
|
|
53
|
+
if kw in lower:
|
|
54
|
+
return "project"
|
|
55
|
+
for kw in _EXPERIENCE_SECTION_KEYWORDS:
|
|
56
|
+
if kw in lower:
|
|
57
|
+
return "experience"
|
|
58
|
+
for kw in _EDUCATION_SECTION_KEYWORDS:
|
|
59
|
+
if kw in lower:
|
|
60
|
+
return "education"
|
|
61
|
+
for kw in _SKILLS_SECTION_KEYWORDS:
|
|
62
|
+
if kw in lower:
|
|
63
|
+
return "skills"
|
|
64
|
+
for kw in _NOPROBE_SECTION_KEYWORDS:
|
|
65
|
+
if kw in lower:
|
|
66
|
+
return "other"
|
|
67
|
+
return "other"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Data model
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Claim:
|
|
77
|
+
section: str
|
|
78
|
+
entry_heading: str
|
|
79
|
+
bullet_text: str
|
|
80
|
+
claim_type: str # "numeric" | "technology" | "contextual"
|
|
81
|
+
value: str # the extracted token: "~40%", "FastAPI", or bullet text
|
|
82
|
+
snippet: str # surrounding context
|
|
83
|
+
section_type: str = "project" # classify_section() result
|
|
84
|
+
bullet_raw: str = "" # raw TeX of the source bullet, for exact replacement
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Numeric extraction
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
_NUMERIC_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
92
|
+
(re.compile(r"~?\s?\d[\d,]*(?:\.\d+)?\s*%"), "percentage"),
|
|
93
|
+
(re.compile(r"sub-\d[\d,]*(?:\.\d+)?\s*(?:s|ms)\b"), "latency"),
|
|
94
|
+
(re.compile(r"\d[\d,]*(?:\.\d+)?\s*(?:s|ms)\s+\w+"), "latency"),
|
|
95
|
+
(re.compile(r"\d[\d,]*/\d[\d,]*"), "ratio"),
|
|
96
|
+
(
|
|
97
|
+
re.compile(r"\d[\d,]*\s+(?:year|month|week|day|hour|minute|second)s?\b"),
|
|
98
|
+
"duration",
|
|
99
|
+
),
|
|
100
|
+
(re.compile(r"\d[\d,]*\+?\s+[\w./-]+(?:\s+[\w./-]+)?"), "count"),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
_SKIP_COUNT = re.compile(r"^\d+\s*(?:pt|cm|mm|em|ex|px|rem)\b", re.IGNORECASE)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _context(text: str, start: int, end: int) -> str:
|
|
107
|
+
lo = max(0, start - 40)
|
|
108
|
+
hi = min(len(text), end + 40)
|
|
109
|
+
snippet = text[lo:hi]
|
|
110
|
+
if lo > 0:
|
|
111
|
+
sp = snippet.find(" ")
|
|
112
|
+
if sp != -1 and sp < 15:
|
|
113
|
+
snippet = snippet[sp + 1 :]
|
|
114
|
+
if hi < len(text):
|
|
115
|
+
sp = snippet.rfind(" ")
|
|
116
|
+
if sp != -1 and len(snippet) - sp < 15:
|
|
117
|
+
snippet = snippet[:sp]
|
|
118
|
+
return snippet.strip()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _overlaps(span: tuple[int, int], seen: list[tuple[int, int]]) -> bool:
|
|
122
|
+
for s, e in seen:
|
|
123
|
+
if span[0] < e and s < span[1]:
|
|
124
|
+
return True
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _extract_numeric(bullet: str) -> list[tuple[str, str, str]]:
|
|
129
|
+
"""Return (value, snippet, category) triples."""
|
|
130
|
+
found: list[tuple[str, str, str]] = []
|
|
131
|
+
seen_spans: list[tuple[int, int]] = []
|
|
132
|
+
|
|
133
|
+
for pattern, category in _NUMERIC_PATTERNS:
|
|
134
|
+
for m in pattern.finditer(bullet):
|
|
135
|
+
span = m.span()
|
|
136
|
+
if _overlaps(span, seen_spans):
|
|
137
|
+
continue
|
|
138
|
+
value = m.group().strip()
|
|
139
|
+
if category == "count" and _SKIP_COUNT.match(value):
|
|
140
|
+
continue
|
|
141
|
+
if category == "count" and re.match(r"^\d+$", value):
|
|
142
|
+
continue
|
|
143
|
+
seen_spans.append(span)
|
|
144
|
+
found.append((value, _context(bullet, *span), category))
|
|
145
|
+
|
|
146
|
+
return found
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# Technology extraction
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
_KNOWN_TECH: list[str] = [
|
|
154
|
+
# multi-word (longest first so they match before substrings)
|
|
155
|
+
"Azure OpenAI",
|
|
156
|
+
"Azure SQL",
|
|
157
|
+
"Docker Compose",
|
|
158
|
+
"Framer Motion",
|
|
159
|
+
"Google Advanced Data Analytics",
|
|
160
|
+
"Radix UI",
|
|
161
|
+
"Server-Sent Events",
|
|
162
|
+
"Tailwind CSS",
|
|
163
|
+
"tree-sitter",
|
|
164
|
+
# versioned / compound
|
|
165
|
+
"Express.js",
|
|
166
|
+
"GPT-4o",
|
|
167
|
+
"Node.js",
|
|
168
|
+
"React 18",
|
|
169
|
+
# single-word frameworks / libs
|
|
170
|
+
"Alembic",
|
|
171
|
+
"ChromaDB",
|
|
172
|
+
"Clerk",
|
|
173
|
+
"Databricks",
|
|
174
|
+
"Detekt",
|
|
175
|
+
"Django",
|
|
176
|
+
"Express",
|
|
177
|
+
"FastAPI",
|
|
178
|
+
"Flask",
|
|
179
|
+
"Geekbench",
|
|
180
|
+
"HTTPX",
|
|
181
|
+
"Nginx",
|
|
182
|
+
"OpenCL",
|
|
183
|
+
"Pandas",
|
|
184
|
+
"PostgreSQL",
|
|
185
|
+
"Pydantic",
|
|
186
|
+
"Pytest",
|
|
187
|
+
"React",
|
|
188
|
+
"Recharts",
|
|
189
|
+
"Redis",
|
|
190
|
+
"SQLAlchemy",
|
|
191
|
+
"Sentry",
|
|
192
|
+
"Snowflake",
|
|
193
|
+
"Uvicorn",
|
|
194
|
+
"Vite",
|
|
195
|
+
"Vulkan",
|
|
196
|
+
"pgvector",
|
|
197
|
+
# languages
|
|
198
|
+
"Java",
|
|
199
|
+
"JavaScript",
|
|
200
|
+
"Kotlin",
|
|
201
|
+
"Python",
|
|
202
|
+
"SQL",
|
|
203
|
+
"TypeScript",
|
|
204
|
+
# infra / concepts
|
|
205
|
+
"CI/CD",
|
|
206
|
+
"Docker",
|
|
207
|
+
"Git",
|
|
208
|
+
"HNSW",
|
|
209
|
+
"JWT",
|
|
210
|
+
"RAG",
|
|
211
|
+
"REST",
|
|
212
|
+
"SPCS",
|
|
213
|
+
"SSE",
|
|
214
|
+
"WebSocket",
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
# Public alias — the ATS scorer boosts these terms during keyword ranking.
|
|
218
|
+
KNOWN_TECH = _KNOWN_TECH
|
|
219
|
+
|
|
220
|
+
_TECH_PATTERNS: list[re.Pattern[str]] = []
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _build_tech_patterns() -> None:
|
|
224
|
+
if _TECH_PATTERNS:
|
|
225
|
+
return
|
|
226
|
+
for tech in sorted(_KNOWN_TECH, key=len, reverse=True):
|
|
227
|
+
escaped = re.escape(tech)
|
|
228
|
+
_TECH_PATTERNS.append(re.compile(r"(?<![A-Za-z])" + escaped + r"(?![A-Za-z])"))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _extract_tech(bullet: str) -> list[tuple[str, str]]:
|
|
232
|
+
"""Return (tech_name, snippet) pairs."""
|
|
233
|
+
_build_tech_patterns()
|
|
234
|
+
found: list[tuple[str, str]] = []
|
|
235
|
+
seen_names: set[str] = set()
|
|
236
|
+
for i, pattern in enumerate(_TECH_PATTERNS):
|
|
237
|
+
tech = sorted(_KNOWN_TECH, key=len, reverse=True)[i]
|
|
238
|
+
m = pattern.search(bullet)
|
|
239
|
+
if m and tech not in seen_names:
|
|
240
|
+
shorter = tech.split()[0] if " " in tech else None
|
|
241
|
+
if shorter:
|
|
242
|
+
seen_names.add(shorter)
|
|
243
|
+
seen_names.add(tech)
|
|
244
|
+
found.append((tech, _context(bullet, m.start(), m.end())))
|
|
245
|
+
return found
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# Public API
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def extract_claims(
|
|
254
|
+
resume: ParsedResume,
|
|
255
|
+
sections: list[str] | None = None,
|
|
256
|
+
entries: list[str] | None = None,
|
|
257
|
+
include_skills_with_entries: bool = True,
|
|
258
|
+
) -> list[Claim]:
|
|
259
|
+
"""Pull numeric, technology, and contextual claims from every bullet.
|
|
260
|
+
|
|
261
|
+
If *sections* is given, only bullets from matching section names are
|
|
262
|
+
processed (case-insensitive substring match, so ``"project"`` matches
|
|
263
|
+
``"Projects"`` and ``"Personal Projects"``).
|
|
264
|
+
|
|
265
|
+
If *entries* is given, only bullets whose entry heading matches one of
|
|
266
|
+
the filters are processed (case-insensitive substring match, so
|
|
267
|
+
``"one more light"`` matches the "One More Light" project entry).
|
|
268
|
+
Skills-type sections are still included by default so JD keyword
|
|
269
|
+
matching always has the skills list to work with — pass
|
|
270
|
+
``include_skills_with_entries=False`` to disable that.
|
|
271
|
+
|
|
272
|
+
For non-project sections (education, experience, skills, etc.), a
|
|
273
|
+
``contextual`` claim is generated per bullet so the AMA interviewer
|
|
274
|
+
can still quiz on them even without code evidence.
|
|
275
|
+
"""
|
|
276
|
+
claims: list[Claim] = []
|
|
277
|
+
_filters = [s.lower() for s in sections] if sections else None
|
|
278
|
+
_entry_filters = [e.lower() for e in entries] if entries else None
|
|
279
|
+
|
|
280
|
+
for sec_name, heading, bullet in resume.all_items:
|
|
281
|
+
if _filters and not any(f in sec_name.lower() for f in _filters):
|
|
282
|
+
continue
|
|
283
|
+
if _entry_filters:
|
|
284
|
+
entry_match = any(f in heading.lower() for f in _entry_filters)
|
|
285
|
+
skills_pass = (
|
|
286
|
+
include_skills_with_entries and classify_section(sec_name) == "skills"
|
|
287
|
+
)
|
|
288
|
+
if not entry_match and not skills_pass:
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
sec_type = classify_section(sec_name)
|
|
292
|
+
has_specific_claims = False
|
|
293
|
+
|
|
294
|
+
for value, snippet, _cat in _extract_numeric(bullet.text):
|
|
295
|
+
claims.append(
|
|
296
|
+
Claim(
|
|
297
|
+
section=sec_name,
|
|
298
|
+
entry_heading=heading,
|
|
299
|
+
bullet_text=bullet.text,
|
|
300
|
+
claim_type="numeric",
|
|
301
|
+
value=value,
|
|
302
|
+
snippet=snippet,
|
|
303
|
+
section_type=sec_type,
|
|
304
|
+
bullet_raw=bullet.raw_tex,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
has_specific_claims = True
|
|
308
|
+
|
|
309
|
+
for tech, snippet in _extract_tech(bullet.text):
|
|
310
|
+
claims.append(
|
|
311
|
+
Claim(
|
|
312
|
+
section=sec_name,
|
|
313
|
+
entry_heading=heading,
|
|
314
|
+
bullet_text=bullet.text,
|
|
315
|
+
claim_type="technology",
|
|
316
|
+
value=tech,
|
|
317
|
+
snippet=snippet,
|
|
318
|
+
section_type=sec_type,
|
|
319
|
+
bullet_raw=bullet.raw_tex,
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
has_specific_claims = True
|
|
323
|
+
|
|
324
|
+
if not has_specific_claims and sec_type != "project":
|
|
325
|
+
claims.append(
|
|
326
|
+
Claim(
|
|
327
|
+
section=sec_name,
|
|
328
|
+
entry_heading=heading,
|
|
329
|
+
bullet_text=bullet.text,
|
|
330
|
+
claim_type="contextual",
|
|
331
|
+
value=bullet.text[:120],
|
|
332
|
+
snippet=bullet.text,
|
|
333
|
+
section_type=sec_type,
|
|
334
|
+
bullet_raw=bullet.raw_tex,
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
return claims
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""One entry point for loading a resume, whatever the format.
|
|
2
|
+
|
|
3
|
+
``.tex`` gets the macro-aware parser (exact structure, enables surgical
|
|
4
|
+
rewriting); ``.pdf`` and ``.txt`` get the layout-heuristic parser. Both
|
|
5
|
+
return the same ``ParsedResume``, so every downstream consumer is
|
|
6
|
+
format-agnostic. Rewriting the .tex file itself is the one flow that
|
|
7
|
+
genuinely requires the .tex source — its command guards for that.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from receipts.errors import ReceiptsError
|
|
15
|
+
from receipts.resume.tex_parser import ParsedResume
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_resume(path: Path) -> ParsedResume:
|
|
19
|
+
suffix = path.suffix.lower()
|
|
20
|
+
if suffix == ".tex":
|
|
21
|
+
from receipts.resume.tex_parser import parse_file
|
|
22
|
+
|
|
23
|
+
return parse_file(path)
|
|
24
|
+
if suffix == ".pdf":
|
|
25
|
+
from receipts.resume.pdf_parser import parse_pdf
|
|
26
|
+
|
|
27
|
+
return parse_pdf(path)
|
|
28
|
+
if suffix in (".txt", ".md"):
|
|
29
|
+
from receipts.resume.pdf_parser import parse_text
|
|
30
|
+
|
|
31
|
+
return parse_text(path.read_text(encoding="utf-8", errors="replace"))
|
|
32
|
+
raise ReceiptsError(
|
|
33
|
+
f"Unsupported resume format '{suffix or path.name}'."
|
|
34
|
+
" Use a .tex source, a text-based .pdf, or a plain .txt export."
|
|
35
|
+
)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Parse a PDF (or plain-text) resume into the tex_parser structures.
|
|
2
|
+
|
|
3
|
+
Friction killer: most people's resume is a PDF, not a LaTeX source. A PDF
|
|
4
|
+
is flat text with no macros to anchor on, so this parser rebuilds the
|
|
5
|
+
structure with layout heuristics — section keywords, bullet glyphs, wrap
|
|
6
|
+
continuation, heading/date shapes. It emits the exact same ``ParsedResume``
|
|
7
|
+
shapes as ``tex_parser``, so everything downstream (claim extraction,
|
|
8
|
+
verification, AMA, dossier) is untouched.
|
|
9
|
+
|
|
10
|
+
Costs ZERO LLM calls — text extraction is pypdf, structure is regex and
|
|
11
|
+
rules. Honest limits: text-layer PDFs parse well; unusual layouts may
|
|
12
|
+
mis-group a line or two; scanned/image PDFs have no text at all and are
|
|
13
|
+
rejected with a clear message instead of guessed at.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from receipts.errors import ReceiptsError
|
|
22
|
+
from receipts.resume.tex_parser import (
|
|
23
|
+
ParsedResume,
|
|
24
|
+
ResumeBullet,
|
|
25
|
+
ResumeEntry,
|
|
26
|
+
ResumeSection,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
#: Canonical section keywords (lowercase). A short line that normalizes to
|
|
30
|
+
#: one of these — or is ALL CAPS and contains one — starts a new section.
|
|
31
|
+
_SECTION_KEYWORDS = {
|
|
32
|
+
"education",
|
|
33
|
+
"experience",
|
|
34
|
+
"work experience",
|
|
35
|
+
"professional experience",
|
|
36
|
+
"employment",
|
|
37
|
+
"internships",
|
|
38
|
+
"projects",
|
|
39
|
+
"personal projects",
|
|
40
|
+
"academic projects",
|
|
41
|
+
"skills",
|
|
42
|
+
"technical skills",
|
|
43
|
+
"skills & certifications",
|
|
44
|
+
"certifications",
|
|
45
|
+
"certificates",
|
|
46
|
+
"publications",
|
|
47
|
+
"research",
|
|
48
|
+
"achievements",
|
|
49
|
+
"awards",
|
|
50
|
+
"honors",
|
|
51
|
+
"leadership",
|
|
52
|
+
"extracurricular",
|
|
53
|
+
"extracurriculars",
|
|
54
|
+
"activities",
|
|
55
|
+
"volunteering",
|
|
56
|
+
"volunteer experience",
|
|
57
|
+
"coursework",
|
|
58
|
+
"summary",
|
|
59
|
+
"objective",
|
|
60
|
+
"profile",
|
|
61
|
+
"interests",
|
|
62
|
+
"languages",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#: Sections where every non-bullet line is its own heading-only entry
|
|
66
|
+
#: (a "Languages: Python, SQL" line is a claim in itself).
|
|
67
|
+
_LINE_PER_ENTRY_SECTIONS = {
|
|
68
|
+
"skills",
|
|
69
|
+
"technical skills",
|
|
70
|
+
"certifications",
|
|
71
|
+
"certificates",
|
|
72
|
+
"education",
|
|
73
|
+
"publications",
|
|
74
|
+
"coursework",
|
|
75
|
+
"languages",
|
|
76
|
+
"interests",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_BULLET_GLYPHS = "•◦▪‣·∙●○–—*"
|
|
80
|
+
|
|
81
|
+
_YEAR_RE = re.compile(r"(?:19|20)\d{2}")
|
|
82
|
+
_DATE_TAIL_RE = re.compile(
|
|
83
|
+
r"((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+)?"
|
|
84
|
+
r"(?:19|20)\d{2}\s*[–—-]?\s*"
|
|
85
|
+
r"(?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+)?"
|
|
86
|
+
r"(?:(?:19|20)\d{2}|[Pp]resent|[Cc]urrent)?\s*$"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
_CONTACT_RE = re.compile(
|
|
90
|
+
r"@|linkedin\.com|github\.com|(?:\+?\d[\d ()\-]{7,})|https?://|www\."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
#: A line that is NOTHING BUT a date range ("May 2024 - Aug 2024",
|
|
94
|
+
#: "2021 – Present") — belongs to the heading above it, never a new entry.
|
|
95
|
+
_DATE_ONLY_RE = re.compile(
|
|
96
|
+
r"^(?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s*)?"
|
|
97
|
+
r"(?:19|20)\d{2}\s*[–—-]+\s*"
|
|
98
|
+
r"(?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s*)?"
|
|
99
|
+
r"(?:(?:19|20)\d{2}|[Pp]resent|[Cc]urrent|[Oo]ngoing)$"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _normalize(line: str) -> str:
|
|
104
|
+
return re.sub(r"[^a-z& ]", "", line.strip().lower()).strip()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _section_name(line: str) -> str | None:
|
|
108
|
+
"""The section this line starts, or None."""
|
|
109
|
+
stripped = line.strip().strip(":").strip()
|
|
110
|
+
if not stripped or len(stripped) > 40:
|
|
111
|
+
return None
|
|
112
|
+
norm = _normalize(stripped)
|
|
113
|
+
# Singular/plural tolerant: "Publication" matches "publications".
|
|
114
|
+
variants = {norm, norm + "s"}
|
|
115
|
+
if norm.endswith("s"):
|
|
116
|
+
variants.add(norm[:-1])
|
|
117
|
+
if variants & _SECTION_KEYWORDS:
|
|
118
|
+
return stripped
|
|
119
|
+
# ALL-CAPS-ish short lines containing a keyword ("TECHNICAL SKILLS ")
|
|
120
|
+
letters = [c for c in stripped if c.isalpha()]
|
|
121
|
+
if letters and all(c.isupper() for c in letters):
|
|
122
|
+
for kw in _SECTION_KEYWORDS:
|
|
123
|
+
if kw in norm or kw in norm + "s":
|
|
124
|
+
return stripped
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _is_bullet_line(line: str) -> bool:
|
|
129
|
+
s = line.strip()
|
|
130
|
+
if len(s) < 3:
|
|
131
|
+
return False
|
|
132
|
+
first = s[0]
|
|
133
|
+
if first in _BULLET_GLYPHS:
|
|
134
|
+
return True
|
|
135
|
+
# PDF extractors map list markers to arbitrary glyphs (seen: \x7f).
|
|
136
|
+
# Any non-alphanumeric single marker followed by a space counts.
|
|
137
|
+
return not first.isalnum() and first not in "([<{\"'#$%&/@\\" and s[1] == " "
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _strip_bullet(line: str) -> str:
|
|
141
|
+
return line.strip().lstrip(_BULLET_GLYPHS + "\x7f\x95").strip()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _looks_like_new_entry(line: str) -> bool:
|
|
145
|
+
"""In experience/projects, does a non-bullet line start a NEW entry
|
|
146
|
+
(vs. continuing the previous wrapped bullet)?"""
|
|
147
|
+
s = line.strip()
|
|
148
|
+
if not s or not s[0].isupper():
|
|
149
|
+
return False
|
|
150
|
+
return bool("|" in s or _DATE_TAIL_RE.search(s) or len(s) <= 60)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _entry_date(heading: str) -> str:
|
|
154
|
+
m = _YEAR_RE.search(heading)
|
|
155
|
+
return m.group(0) if m else ""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_text(text: str) -> ParsedResume:
|
|
159
|
+
"""Rebuild resume structure from flat text (PDF-extracted or .txt)."""
|
|
160
|
+
raw_lines = [ln.rstrip() for ln in text.splitlines()]
|
|
161
|
+
lines = [ln for ln in raw_lines if ln.strip()]
|
|
162
|
+
if not lines:
|
|
163
|
+
raise ReceiptsError("The resume is empty after text extraction.")
|
|
164
|
+
|
|
165
|
+
# Name: first line that isn't contact info.
|
|
166
|
+
name = ""
|
|
167
|
+
for ln in lines[:5]:
|
|
168
|
+
if not _CONTACT_RE.search(ln) and _section_name(ln) is None:
|
|
169
|
+
name = ln.strip()
|
|
170
|
+
break
|
|
171
|
+
|
|
172
|
+
sections: list[ResumeSection] = []
|
|
173
|
+
current_section: str | None = None
|
|
174
|
+
entries: list[ResumeEntry] = []
|
|
175
|
+
heading = ""
|
|
176
|
+
bullets: list[ResumeBullet] = []
|
|
177
|
+
|
|
178
|
+
def flush_entry() -> None:
|
|
179
|
+
nonlocal heading, bullets
|
|
180
|
+
if heading or bullets:
|
|
181
|
+
entries.append(
|
|
182
|
+
ResumeEntry(
|
|
183
|
+
heading=heading,
|
|
184
|
+
date=_entry_date(heading),
|
|
185
|
+
bullets=bullets,
|
|
186
|
+
heading_raw=heading,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
heading = ""
|
|
190
|
+
bullets = []
|
|
191
|
+
|
|
192
|
+
def flush_section() -> None:
|
|
193
|
+
nonlocal entries
|
|
194
|
+
flush_entry()
|
|
195
|
+
if current_section is not None and entries:
|
|
196
|
+
sections.append(ResumeSection(name=current_section, entries=entries))
|
|
197
|
+
entries = []
|
|
198
|
+
|
|
199
|
+
for line in lines:
|
|
200
|
+
sec = _section_name(line)
|
|
201
|
+
if sec is not None:
|
|
202
|
+
flush_section()
|
|
203
|
+
current_section = sec
|
|
204
|
+
continue
|
|
205
|
+
if current_section is None:
|
|
206
|
+
continue # contact header before the first section
|
|
207
|
+
|
|
208
|
+
line_per_entry = _normalize(current_section) in _LINE_PER_ENTRY_SECTIONS
|
|
209
|
+
|
|
210
|
+
if _is_bullet_line(line):
|
|
211
|
+
bullets.append(ResumeBullet(text=_strip_bullet(line), raw_tex=line))
|
|
212
|
+
elif line_per_entry:
|
|
213
|
+
# Skills/certs/education: every line is a claim of its own.
|
|
214
|
+
flush_entry()
|
|
215
|
+
heading = line.strip()
|
|
216
|
+
flush_entry()
|
|
217
|
+
elif heading and not bullets and _DATE_ONLY_RE.match(line.strip()):
|
|
218
|
+
heading = f"{heading} | {line.strip()}" # the heading's date line
|
|
219
|
+
elif not bullets and heading and not _looks_like_new_entry(line):
|
|
220
|
+
heading = f"{heading} | {line.strip()}" # wrapped heading line
|
|
221
|
+
elif bullets and not _looks_like_new_entry(line):
|
|
222
|
+
# Wrapped continuation of the previous bullet.
|
|
223
|
+
last = bullets[-1]
|
|
224
|
+
bullets[-1] = ResumeBullet(
|
|
225
|
+
text=f"{last.text} {line.strip()}",
|
|
226
|
+
raw_tex=f"{last.raw_tex} {line.strip()}",
|
|
227
|
+
)
|
|
228
|
+
else:
|
|
229
|
+
flush_entry()
|
|
230
|
+
heading = line.strip()
|
|
231
|
+
|
|
232
|
+
flush_section()
|
|
233
|
+
|
|
234
|
+
if not sections:
|
|
235
|
+
raise ReceiptsError(
|
|
236
|
+
"Could not find any resume sections (Education, Experience,"
|
|
237
|
+
" Projects, Skills...) in the text. If this is a scanned/image"
|
|
238
|
+
" PDF, export a text-based PDF or use the .tex source."
|
|
239
|
+
)
|
|
240
|
+
return ParsedResume(name=name, sections=sections)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def parse_pdf(path: Path) -> ParsedResume:
|
|
244
|
+
"""Extract text from a PDF resume and parse its structure."""
|
|
245
|
+
from pypdf import PdfReader # verified against installed pypdf 6.x
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
reader = PdfReader(str(path))
|
|
249
|
+
text = "\n".join(page.extract_text() or "" for page in reader.pages)
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
raise ReceiptsError(f"Could not read '{path.name}' as a PDF: {exc}") from None
|
|
252
|
+
|
|
253
|
+
if len(text.strip()) < 100:
|
|
254
|
+
raise ReceiptsError(
|
|
255
|
+
f"'{path.name}' has (almost) no extractable text — it is likely a"
|
|
256
|
+
" scanned/image PDF. Export a text-based PDF from your editor,"
|
|
257
|
+
" or use the .tex source."
|
|
258
|
+
)
|
|
259
|
+
return parse_text(text)
|