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.
Files changed (78) hide show
  1. codebase_receipts_cli-1.0.0.dist-info/METADATA +268 -0
  2. codebase_receipts_cli-1.0.0.dist-info/RECORD +78 -0
  3. codebase_receipts_cli-1.0.0.dist-info/WHEEL +4 -0
  4. codebase_receipts_cli-1.0.0.dist-info/entry_points.txt +2 -0
  5. codebase_receipts_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
  6. receipts/__init__.py +0 -0
  7. receipts/ama/__init__.py +0 -0
  8. receipts/ama/interviewer.py +415 -0
  9. receipts/ats/__init__.py +1 -0
  10. receipts/ats/comparator.py +98 -0
  11. receipts/ats/scorer.py +550 -0
  12. receipts/ats/stats.py +164 -0
  13. receipts/cli.py +2062 -0
  14. receipts/config.py +106 -0
  15. receipts/errors.py +18 -0
  16. receipts/export.py +120 -0
  17. receipts/ingest/__init__.py +0 -0
  18. receipts/ingest/artifact_extractor.py +679 -0
  19. receipts/ingest/git_source.py +159 -0
  20. receipts/ingest/manifest.py +114 -0
  21. receipts/ingest/scanner.py +141 -0
  22. receipts/ingest/secrets_scanner.py +225 -0
  23. receipts/interactive/__init__.py +1 -0
  24. receipts/interactive/repl.py +755 -0
  25. receipts/ledger/__init__.py +0 -0
  26. receipts/ledger/pricing_table.py +54 -0
  27. receipts/ledger/token_ledger.py +226 -0
  28. receipts/llm/__init__.py +0 -0
  29. receipts/llm/anthropic_provider.py +95 -0
  30. receipts/llm/factory.py +124 -0
  31. receipts/llm/fake_provider.py +79 -0
  32. receipts/llm/gemini_provider.py +205 -0
  33. receipts/llm/json_utils.py +46 -0
  34. receipts/llm/metered.py +62 -0
  35. receipts/llm/ollama_provider.py +117 -0
  36. receipts/llm/openai_provider.py +118 -0
  37. receipts/llm/provider.py +42 -0
  38. receipts/llm/split.py +78 -0
  39. receipts/llm/token_estimate.py +35 -0
  40. receipts/mine/__init__.py +1 -0
  41. receipts/mine/code_metrics.py +246 -0
  42. receipts/mine/git_evidence.py +109 -0
  43. receipts/prep/__init__.py +1 -0
  44. receipts/prep/dossier.py +313 -0
  45. receipts/prep/readiness.py +227 -0
  46. receipts/resume/__init__.py +0 -0
  47. receipts/resume/claim_extractor.py +338 -0
  48. receipts/resume/loader.py +35 -0
  49. receipts/resume/pdf_parser.py +259 -0
  50. receipts/resume/tex_parser.py +394 -0
  51. receipts/rewrite/__init__.py +0 -0
  52. receipts/rewrite/compiler.py +180 -0
  53. receipts/rewrite/optimizer.py +140 -0
  54. receipts/rewrite/tex_rewriter.py +227 -0
  55. receipts/tui/__init__.py +0 -0
  56. receipts/tui/ama_app.py +454 -0
  57. receipts/tui/app.py +316 -0
  58. receipts/tui/dossier_app.py +170 -0
  59. receipts/tui/hub.py +367 -0
  60. receipts/tui/ingest_app.py +183 -0
  61. receipts/tui/rewrite_app.py +463 -0
  62. receipts/tui/score_app.py +237 -0
  63. receipts/tui/widgets/__init__.py +0 -0
  64. receipts/tui/widgets/claims_table.py +50 -0
  65. receipts/tui/widgets/diff_view.py +38 -0
  66. receipts/tui/widgets/status_bar.py +38 -0
  67. receipts/verify/__init__.py +0 -0
  68. receipts/verify/bm25.py +112 -0
  69. receipts/verify/enrich.py +128 -0
  70. receipts/verify/jd_fetch.py +101 -0
  71. receipts/verify/kb.py +379 -0
  72. receipts/verify/keyword_gap.py +127 -0
  73. receipts/verify/query_expand.py +88 -0
  74. receipts/verify/rerank.py +74 -0
  75. receipts/verify/rewriter.py +172 -0
  76. receipts/verify/router.py +211 -0
  77. receipts/verify/summaries.py +258 -0
  78. 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)