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
receipts/ats/scorer.py ADDED
@@ -0,0 +1,550 @@
1
+ """Text-based ATS scorer — five dimensions, zero external dependencies.
2
+
3
+ Scores a resume against a job description across:
4
+ 1. Keyword match (TF-IDF-style overlap)
5
+ 2. Action verb strength
6
+ 3. Quantification density
7
+ 4. Section completeness
8
+ 5. Brevity & formatting
9
+
10
+ Works on .tex input directly (strips LaTeX → plain text → scores).
11
+ No multimodal, no paid APIs, no spaCy/sklearn.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ import re
18
+ from collections import Counter
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from receipts.resume.tex_parser import ParsedResume, parse_resume
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Data model
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class DimensionScore:
31
+ name: str
32
+ score: int # 0–100
33
+ detail: str
34
+ weight: float = 1.0
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class BulletScore:
39
+ text: str
40
+ section: str
41
+ has_action_verb: bool
42
+ has_number: bool
43
+ word_count: int
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ATSScore:
48
+ dimensions: list[DimensionScore]
49
+ bullet_scores: list[BulletScore]
50
+ composite: float
51
+ resume_name: str
52
+
53
+ def dimension(self, name: str) -> DimensionScore | None:
54
+ for d in self.dimensions:
55
+ if d.name == name:
56
+ return d
57
+ return None
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # 1. Keyword match — TF-IDF-style overlap
62
+ # ---------------------------------------------------------------------------
63
+
64
+ _STOP_WORDS = frozenset(
65
+ "a an the and or but in on at to for of is it by with as from be was were "
66
+ "been are this that these those has have had do does did will would shall "
67
+ "should can could may might must not no nor so yet also very too each every "
68
+ "all any both few more most other some such than up out about into over "
69
+ "after before between through during above below under again further then "
70
+ "once here there when where why how what which who whom its our your their "
71
+ "he she we they i you me him her us them my his your our".split()
72
+ )
73
+
74
+ # Words that appear in virtually every job description and carry no signal —
75
+ # counting them as "keywords" deflates every resume's score equally.
76
+ _GENERIC_JD_WORDS = frozenset(
77
+ "software engineer engineers engineering developer developers development "
78
+ "experience experienced requirements required requirement skills skill "
79
+ "strong ability able working works building builds knowledge familiarity "
80
+ "familiar understanding proficiency proficient plus years year degree "
81
+ "bachelor bachelors master masters computer science looking join role "
82
+ "roles responsibilities responsibility qualifications qualification "
83
+ "preferred bonus candidate candidates position team teams communication "
84
+ "collaborative collaboration excellent solid hands-on hands passionate "
85
+ "environment fast-paced opportunity company related field equivalent "
86
+ "including etc write writing design designing develop developing "
87
+ "implement implementing maintain maintaining relevant technologies "
88
+ "technology tools tool best practices practice modern".split()
89
+ )
90
+
91
+ _TOKEN_RE = re.compile(r"[a-zA-Z][a-zA-Z0-9+#._-]{1,}")
92
+
93
+ _KNOWN_TECH_TOKENS: frozenset[str] | None = None
94
+
95
+
96
+ def _known_tech_tokens() -> frozenset[str]:
97
+ """Lowercased single tokens from the known-tech list, for boosting."""
98
+ global _KNOWN_TECH_TOKENS
99
+ if _KNOWN_TECH_TOKENS is None:
100
+ from receipts.resume.claim_extractor import KNOWN_TECH
101
+
102
+ tokens: set[str] = set()
103
+ for tech in KNOWN_TECH:
104
+ tokens.add(tech.lower())
105
+ for part in tech.lower().split():
106
+ tokens.add(part)
107
+ _KNOWN_TECH_TOKENS = frozenset(tokens)
108
+ return _KNOWN_TECH_TOKENS
109
+
110
+
111
+ def _tokenize(text: str) -> list[str]:
112
+ # Trailing ./-/_ come from sentence punctuation ("skills.", "Docker,"),
113
+ # while internal ones are real ("node.js") — strip only the tail.
114
+ tokens = (t.lower().rstrip("._-") for t in _TOKEN_RE.findall(text))
115
+ return [t for t in tokens if t and t not in _STOP_WORDS]
116
+
117
+
118
+ def _tf(tokens: list[str]) -> dict[str, float]:
119
+ counts = Counter(tokens)
120
+ total = len(tokens) or 1
121
+ return {t: c / total for t, c in counts.items()}
122
+
123
+
124
+ def _idf(doc_tokens: list[list[str]]) -> dict[str, float]:
125
+ n = len(doc_tokens) or 1
126
+ df: Counter[str] = Counter()
127
+ for tokens in doc_tokens:
128
+ df.update(set(tokens))
129
+ return {t: math.log(n / (1 + count)) + 1 for t, count in df.items()}
130
+
131
+
132
+ def _known_tech_phrases() -> list[str]:
133
+ """Multi-word tech terms ("Docker Compose", "Tailwind CSS"), lowercased."""
134
+ from receipts.resume.claim_extractor import KNOWN_TECH
135
+
136
+ return [t.lower() for t in KNOWN_TECH if " " in t]
137
+
138
+
139
+ # Common multi-word JD requirements that aren't in the tech list but that
140
+ # ATS systems match as phrases, not tokens.
141
+ _JD_PHRASES = [
142
+ "rest apis",
143
+ "rest api",
144
+ "machine learning",
145
+ "deep learning",
146
+ "unit testing",
147
+ "system design",
148
+ "data structures",
149
+ "version control",
150
+ "code review",
151
+ "code reviews",
152
+ "vector databases",
153
+ "vector database",
154
+ "github actions",
155
+ "open source",
156
+ ]
157
+
158
+
159
+ def _extract_jd_phrases(jd_lower: str) -> list[str]:
160
+ """Multi-word keywords found verbatim in the JD.
161
+
162
+ Longest-first so "rest apis" suppresses its substring "rest api".
163
+ """
164
+ candidates = sorted(set(_known_tech_phrases() + _JD_PHRASES), key=len, reverse=True)
165
+ found: list[str] = []
166
+ for phrase in candidates:
167
+ if phrase in jd_lower and not any(phrase in f for f in found):
168
+ found.append(phrase)
169
+ return found
170
+
171
+
172
+ def _keyword_match_score(jd_text: str, resume_text: str) -> DimensionScore:
173
+ jd_tokens = _tokenize(jd_text)
174
+ resume_tokens = _tokenize(resume_text)
175
+
176
+ if not jd_tokens:
177
+ return DimensionScore("keyword_match", 50, "No JD keywords extracted.")
178
+
179
+ jd_lower = jd_text.lower()
180
+ resume_lower = resume_text.lower()
181
+
182
+ jd_tf = _tf(jd_tokens)
183
+ idf = _idf([jd_tokens, resume_tokens])
184
+ tech_tokens = _known_tech_tokens()
185
+
186
+ # Phrases are matched verbatim so "REST APIs" and "GitHub Actions"
187
+ # aren't shredded into meaningless single tokens.
188
+ phrases = _extract_jd_phrases(jd_lower)
189
+ phrase_parts = {part for p in phrases for part in p.split()}
190
+
191
+ jd_tfidf: dict[str, float] = {}
192
+ for t, tf in jd_tf.items():
193
+ if t in _GENERIC_JD_WORDS:
194
+ continue # every JD says "software engineer, strong skills"
195
+ if t in phrase_parts:
196
+ continue # covered by a phrase ("rest" inside "rest apis")
197
+ weight = tf * idf.get(t, 1)
198
+ if t in tech_tokens:
199
+ weight *= 2.5 # concrete tech terms are what ATS systems scan for
200
+ jd_tfidf[t] = weight
201
+
202
+ if not jd_tfidf and not phrases:
203
+ return DimensionScore("keyword_match", 50, "No specific JD keywords extracted.")
204
+
205
+ top_tokens = sorted(jd_tfidf, key=lambda t: jd_tfidf[t], reverse=True)
206
+ top_tokens = top_tokens[: max(0, 30 - len(phrases))]
207
+
208
+ # Weighted coverage: phrases count double — they're the highest-signal
209
+ # requirements a JD states.
210
+ matched_weight = 0.0
211
+ total_weight = 0.0
212
+ matched: list[str] = []
213
+ missing: list[str] = []
214
+
215
+ for phrase in phrases:
216
+ total_weight += 2.0
217
+ if phrase in resume_lower:
218
+ matched_weight += 2.0
219
+ matched.append(phrase)
220
+ else:
221
+ missing.append(phrase)
222
+
223
+ resume_set = set(resume_tokens)
224
+ for kw in top_tokens:
225
+ total_weight += 1.0
226
+ if kw in resume_set:
227
+ matched_weight += 1.0
228
+ matched.append(kw)
229
+ else:
230
+ missing.append(kw)
231
+
232
+ ratio = matched_weight / total_weight if total_weight else 0
233
+ # Real ATS screens treat ~70% keyword coverage as a strong match —
234
+ # a linear 0-100 ramp punished every realistic resume. Cap stays 100.
235
+ score = min(100, round(ratio * 115))
236
+
237
+ total_terms = len(phrases) + len(top_tokens)
238
+ missing_str = ", ".join(missing[:8])
239
+ detail = f"{len(matched)}/{total_terms} top JD keywords found."
240
+ if missing_str:
241
+ detail += f" Missing: {missing_str}"
242
+
243
+ return DimensionScore("keyword_match", score, detail, weight=2.0)
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # 2. Action verb strength
248
+ # ---------------------------------------------------------------------------
249
+
250
+ _STRONG_VERBS = frozenset(
251
+ "architected automated built configured consolidated containerized created "
252
+ "debugged delivered deployed designed developed eliminated engineered "
253
+ "established executed expanded extracted founded generated implemented "
254
+ "improved increased integrated launched led maintained managed migrated "
255
+ "modernized monitored onboarded operated optimized orchestrated overhauled "
256
+ "pioneered productionized prototyped provisioned published rebuilt reduced "
257
+ "refactored released replaced resolved restructured revamped scaled secured "
258
+ "shipped simplified spearheaded standardized streamlined strengthened "
259
+ "supervised transformed troubleshot unified upgraded validated".split()
260
+ )
261
+
262
+ _WEAK_STARTERS = frozenset(
263
+ "assisted contributed experienced helped involved participated responsible "
264
+ "supported tasked utilized used worked".split()
265
+ )
266
+
267
+
268
+ _NEUTRAL_VERB_CREDIT = 0.4 # noun-led lines (skills rows) aren't weak, just neutral
269
+
270
+
271
+ def _first_word(text: str) -> str:
272
+ words = text.strip().split()
273
+ return words[0].lower().rstrip(",.:;") if words else ""
274
+
275
+
276
+ def _action_verb_score(bullets: list[BulletScore]) -> DimensionScore:
277
+ """Graded verb scoring: strong (1.0) / neutral (0.4) / weak starter (0.0).
278
+
279
+ Also penalizes leaning on the same strong verb over and over — three
280
+ bullets all starting with "Developed" read as template filler to both
281
+ ATS parsers and humans.
282
+ """
283
+ if not bullets:
284
+ return DimensionScore("action_verbs", 0, "No bullets found.")
285
+
286
+ strong = 0
287
+ weak = 0
288
+ credit = 0.0
289
+ verb_counts: Counter[str] = Counter()
290
+
291
+ for b in bullets:
292
+ first = _first_word(b.text)
293
+ if b.has_action_verb:
294
+ strong += 1
295
+ credit += 1.0
296
+ verb_counts[first] += 1
297
+ elif first in _WEAK_STARTERS:
298
+ weak += 1
299
+ else:
300
+ credit += _NEUTRAL_VERB_CREDIT
301
+
302
+ overused = [v for v, n in verb_counts.items() if n >= 3]
303
+ penalty = min(15, 5 * len(overused))
304
+
305
+ score = max(0, min(100, round(credit / len(bullets) * 100) - penalty))
306
+
307
+ detail = f"{strong}/{len(bullets)} bullets start with a strong action verb."
308
+ if weak:
309
+ detail += f" {weak} weak starter(s) (worked/helped/used...)."
310
+ if overused:
311
+ detail += f" Overused: {', '.join(overused)} — vary your verbs."
312
+
313
+ return DimensionScore("action_verbs", score, detail, weight=1.0)
314
+
315
+
316
+ # ---------------------------------------------------------------------------
317
+ # 3. Quantification density
318
+ # ---------------------------------------------------------------------------
319
+
320
+ _NUMBER_RE = re.compile(
321
+ r"(?:\d[\d,]*(?:\.\d+)?\s*%|"
322
+ r"\d[\d,]*\+?\s+[\w/.-]+|"
323
+ r"sub-\d[\d.]*\s*(?:s|ms)|"
324
+ r"\$\d[\d,.]*[KMBkmb]?)"
325
+ )
326
+
327
+
328
+ _VAGUE_QUANTIFIERS = re.compile(
329
+ r"\b(?:several|many|various|numerous|multiple|significant(?:ly)?|"
330
+ r"a\s+(?:number|lot|variety)\s+of|some)\b",
331
+ re.IGNORECASE,
332
+ )
333
+
334
+
335
+ def _quantification_score(bullets: list[BulletScore]) -> DimensionScore:
336
+ if not bullets:
337
+ return DimensionScore("quantification", 0, "No bullets found.")
338
+
339
+ quantified = sum(1 for b in bullets if b.has_number)
340
+ ratio = quantified / len(bullets)
341
+ score = min(100, int(ratio * 110))
342
+
343
+ # Vague quantifiers in unquantified bullets are the cheapest wins —
344
+ # each "several" is a spot where a real number should live.
345
+ vague = sum(
346
+ 1 for b in bullets if not b.has_number and _VAGUE_QUANTIFIERS.search(b.text)
347
+ )
348
+
349
+ detail = f"{quantified}/{len(bullets)} bullets contain measurable numbers."
350
+ if vague:
351
+ detail += (
352
+ f" {vague} bullet(s) use vague quantifiers (several/many/...)"
353
+ " — replace with real numbers."
354
+ )
355
+ if ratio < 0.4:
356
+ detail += " Aim for 50%+ quantified bullets."
357
+
358
+ return DimensionScore("quantification", score, detail, weight=1.5)
359
+
360
+
361
+ # ---------------------------------------------------------------------------
362
+ # 4. Section completeness
363
+ # ---------------------------------------------------------------------------
364
+
365
+ _EXPECTED_SECTIONS = {
366
+ "education": ["education"],
367
+ "experience": ["experience", "work", "internship", "employment"],
368
+ "projects": ["project"],
369
+ "skills": ["skill", "technical"],
370
+ }
371
+
372
+ _BONUS_SECTIONS = {
373
+ "certifications": ["certification", "certificate"],
374
+ "publications": ["publication", "paper", "research"],
375
+ }
376
+
377
+
378
+ def _section_completeness_score(resume: ParsedResume) -> DimensionScore:
379
+ section_names_lower = [s.name.lower() for s in resume.sections]
380
+
381
+ found_core = 0
382
+ missing_core: list[str] = []
383
+ for label, keywords in _EXPECTED_SECTIONS.items():
384
+ if any(kw in sn for sn in section_names_lower for kw in keywords):
385
+ found_core += 1
386
+ else:
387
+ missing_core.append(label)
388
+
389
+ total_core = len(_EXPECTED_SECTIONS)
390
+ bonus = 0
391
+ for keywords in _BONUS_SECTIONS.values():
392
+ if any(kw in sn for sn in section_names_lower for kw in keywords):
393
+ bonus += 1
394
+
395
+ base = (found_core / total_core) * 85
396
+ bonus_pts = min(15, bonus * 8)
397
+ score = min(100, int(base + bonus_pts))
398
+
399
+ detail = f"{found_core}/{total_core} core sections present."
400
+ if missing_core:
401
+ detail += f" Missing: {', '.join(missing_core)}."
402
+ if bonus:
403
+ detail += f" +{bonus} bonus section(s)."
404
+
405
+ return DimensionScore("section_completeness", score, detail, weight=0.75)
406
+
407
+
408
+ # ---------------------------------------------------------------------------
409
+ # 5. Brevity & formatting
410
+ # ---------------------------------------------------------------------------
411
+
412
+
413
+ def _brevity_score(bullets: list[BulletScore], resume: ParsedResume) -> DimensionScore:
414
+ if not bullets:
415
+ return DimensionScore("brevity", 50, "No bullets to evaluate.")
416
+
417
+ word_counts = [b.word_count for b in bullets]
418
+ avg_words = sum(word_counts) / len(word_counts)
419
+
420
+ too_long = sum(1 for wc in word_counts if wc > 35)
421
+ too_short = sum(1 for wc in word_counts if wc < 6)
422
+
423
+ total_bullets = len(bullets)
424
+ total_sections = len(resume.sections)
425
+
426
+ penalties = 0
427
+ if avg_words > 30:
428
+ penalties += 15
429
+ elif avg_words > 25:
430
+ penalties += 8
431
+ if too_long > total_bullets * 0.3:
432
+ penalties += 10
433
+ if too_short > total_bullets * 0.2:
434
+ penalties += 5
435
+ if total_sections < 3:
436
+ penalties += 10
437
+ if total_bullets > 25:
438
+ penalties += 5
439
+
440
+ score = max(0, min(100, 100 - penalties))
441
+
442
+ detail = f"Avg {avg_words:.0f} words/bullet across {total_bullets} bullets."
443
+ if too_long:
444
+ detail += f" {too_long} bullet(s) over 35 words."
445
+ if too_short:
446
+ detail += f" {too_short} bullet(s) under 6 words."
447
+
448
+ return DimensionScore("brevity", score, detail, weight=0.75)
449
+
450
+
451
+ # ---------------------------------------------------------------------------
452
+ # Bullet analysis
453
+ # ---------------------------------------------------------------------------
454
+
455
+
456
+ def _analyze_bullets(resume: ParsedResume) -> list[BulletScore]:
457
+ scores: list[BulletScore] = []
458
+
459
+ for sec in resume.sections:
460
+ for entry in sec.entries:
461
+ for bullet in entry.bullets:
462
+ text = bullet.text.strip()
463
+ words = text.split()
464
+ first_word = words[0].lower().rstrip(",.:;") if words else ""
465
+
466
+ has_verb = first_word in _STRONG_VERBS
467
+ has_num = bool(_NUMBER_RE.search(text))
468
+
469
+ scores.append(
470
+ BulletScore(
471
+ text=text,
472
+ section=sec.name,
473
+ has_action_verb=has_verb,
474
+ has_number=has_num,
475
+ word_count=len(words),
476
+ )
477
+ )
478
+
479
+ return scores
480
+
481
+
482
+ # ---------------------------------------------------------------------------
483
+ # Resume text extraction
484
+ # ---------------------------------------------------------------------------
485
+
486
+
487
+ def _resume_plain_text(resume: ParsedResume) -> str:
488
+ parts: list[str] = []
489
+ for sec in resume.sections:
490
+ parts.append(sec.name)
491
+ for entry in sec.entries:
492
+ if entry.heading:
493
+ parts.append(entry.heading)
494
+ for b in entry.bullets:
495
+ parts.append(b.text)
496
+ return " ".join(parts)
497
+
498
+
499
+ # ---------------------------------------------------------------------------
500
+ # Public API
501
+ # ---------------------------------------------------------------------------
502
+
503
+
504
+ def score_resume(
505
+ resume: ParsedResume,
506
+ jd_text: str,
507
+ ) -> ATSScore:
508
+ """Score a parsed resume against a job description.
509
+
510
+ Returns an ATSScore with five dimensions (0–100 each) and a
511
+ weighted composite score.
512
+ """
513
+ bullet_scores = _analyze_bullets(resume)
514
+ resume_text = _resume_plain_text(resume)
515
+
516
+ dimensions = [
517
+ _keyword_match_score(jd_text, resume_text),
518
+ _action_verb_score(bullet_scores),
519
+ _quantification_score(bullet_scores),
520
+ _section_completeness_score(resume),
521
+ _brevity_score(bullet_scores, resume),
522
+ ]
523
+
524
+ total_weight = sum(d.weight for d in dimensions)
525
+ composite = (
526
+ sum(d.score * d.weight for d in dimensions) / total_weight
527
+ if total_weight
528
+ else 0
529
+ )
530
+
531
+ return ATSScore(
532
+ dimensions=dimensions,
533
+ bullet_scores=bullet_scores,
534
+ composite=round(composite, 1),
535
+ resume_name=resume.name,
536
+ )
537
+
538
+
539
+ def score_tex_file(tex_path: Path, jd_text: str) -> ATSScore:
540
+ """Score a resume file (.tex, .pdf, or .txt) against a job description."""
541
+ from receipts.resume.loader import load_resume
542
+
543
+ resume = load_resume(tex_path)
544
+ return score_resume(resume, jd_text)
545
+
546
+
547
+ def score_tex_string(tex_source: str, jd_text: str) -> ATSScore:
548
+ """Score a TeX string against a job description."""
549
+ resume = parse_resume(tex_source)
550
+ return score_resume(resume, jd_text)
receipts/ats/stats.py ADDED
@@ -0,0 +1,164 @@
1
+ """Statistical validation — confidence intervals on ATS score improvement.
2
+
3
+ Uses bootstrap resampling (no scipy/numpy required). Provides confidence
4
+ intervals and a simple significance test on per-bullet score deltas.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import random
10
+ from dataclasses import dataclass
11
+
12
+ from receipts.ats.scorer import ATSScore, BulletScore
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ConfidenceInterval:
17
+ mean: float
18
+ lower: float
19
+ upper: float
20
+ confidence: float
21
+ n_samples: int
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ImprovementReport:
26
+ keyword_match_ci: ConfidenceInterval | None
27
+ action_verbs_ci: ConfidenceInterval | None
28
+ quantification_ci: ConfidenceInterval | None
29
+ composite_delta: float
30
+ n_bullets_before: int
31
+ n_bullets_after: int
32
+ significant: bool
33
+
34
+ @property
35
+ def summary(self) -> str:
36
+ lines = [
37
+ f"Composite improvement: {self.composite_delta:+.1f} points",
38
+ f"Bullets: {self.n_bullets_before} → {self.n_bullets_after}",
39
+ f"Statistically significant: {'Yes' if self.significant else 'No'}",
40
+ "",
41
+ ]
42
+ for ci, name in [
43
+ (self.keyword_match_ci, "Keyword match"),
44
+ (self.action_verbs_ci, "Action verbs"),
45
+ (self.quantification_ci, "Quantification"),
46
+ ]:
47
+ if ci:
48
+ lines.append(
49
+ f" {name:<18} {ci.mean:+.1f} "
50
+ f"[{ci.lower:+.1f}, {ci.upper:+.1f}] "
51
+ f"({int(ci.confidence * 100)}% CI, n={ci.n_samples})"
52
+ )
53
+ return "\n".join(lines)
54
+
55
+
56
+ def _bootstrap_ci(
57
+ values: list[float],
58
+ confidence: float = 0.95,
59
+ n_bootstrap: int = 1000,
60
+ seed: int = 42,
61
+ ) -> ConfidenceInterval:
62
+ """Compute bootstrap confidence interval for the mean of values."""
63
+ rng = random.Random(seed)
64
+ n = len(values)
65
+
66
+ if n == 0:
67
+ return ConfidenceInterval(
68
+ mean=0.0, lower=0.0, upper=0.0, confidence=confidence, n_samples=0
69
+ )
70
+
71
+ means: list[float] = []
72
+ for _ in range(n_bootstrap):
73
+ sample = [rng.choice(values) for _ in range(n)]
74
+ means.append(sum(sample) / n)
75
+
76
+ means.sort()
77
+ alpha = 1 - confidence
78
+ lower_idx = int((alpha / 2) * n_bootstrap)
79
+ upper_idx = int((1 - alpha / 2) * n_bootstrap) - 1
80
+
81
+ actual_mean = sum(values) / n
82
+
83
+ return ConfidenceInterval(
84
+ mean=round(actual_mean, 2),
85
+ lower=round(means[lower_idx], 2),
86
+ upper=round(means[upper_idx], 2),
87
+ confidence=confidence,
88
+ n_samples=n,
89
+ )
90
+
91
+
92
+ def _bullet_metric_deltas(
93
+ before_bullets: list[BulletScore],
94
+ after_bullets: list[BulletScore],
95
+ ) -> dict[str, list[float]]:
96
+ """Compute per-bullet metric differences (paired where possible, then extras)."""
97
+ n = min(len(before_bullets), len(after_bullets))
98
+
99
+ verb_deltas: list[float] = []
100
+ quant_deltas: list[float] = []
101
+
102
+ for i in range(n):
103
+ verb_deltas.append(
104
+ float(after_bullets[i].has_action_verb)
105
+ - float(before_bullets[i].has_action_verb)
106
+ )
107
+ quant_deltas.append(
108
+ float(after_bullets[i].has_number) - float(before_bullets[i].has_number)
109
+ )
110
+
111
+ for i in range(n, len(after_bullets)):
112
+ verb_deltas.append(float(after_bullets[i].has_action_verb))
113
+ quant_deltas.append(float(after_bullets[i].has_number))
114
+
115
+ return {"action_verbs": verb_deltas, "quantification": quant_deltas}
116
+
117
+
118
+ def compute_improvement(
119
+ before: ATSScore,
120
+ after: ATSScore,
121
+ confidence: float = 0.95,
122
+ n_bootstrap: int = 1000,
123
+ ) -> ImprovementReport:
124
+ """Compute statistical improvement report between two scores."""
125
+ composite_delta = after.composite - before.composite
126
+
127
+ kw_before = before.dimension("keyword_match")
128
+ kw_after = after.dimension("keyword_match")
129
+ keyword_ci = None
130
+ if kw_before and kw_after:
131
+ kw_delta = kw_after.score - kw_before.score
132
+ keyword_ci = ConfidenceInterval(
133
+ mean=float(kw_delta),
134
+ lower=float(kw_delta),
135
+ upper=float(kw_delta),
136
+ confidence=confidence,
137
+ n_samples=1,
138
+ )
139
+
140
+ deltas = _bullet_metric_deltas(before.bullet_scores, after.bullet_scores)
141
+
142
+ verb_ci = None
143
+ if deltas["action_verbs"]:
144
+ verb_ci = _bootstrap_ci(deltas["action_verbs"], confidence, n_bootstrap)
145
+
146
+ quant_ci = None
147
+ if deltas["quantification"]:
148
+ quant_ci = _bootstrap_ci(deltas["quantification"], confidence, n_bootstrap)
149
+
150
+ significant = composite_delta > 0 and (
151
+ (verb_ci is not None and verb_ci.lower > 0)
152
+ or (quant_ci is not None and quant_ci.lower > 0)
153
+ or composite_delta >= 5.0
154
+ )
155
+
156
+ return ImprovementReport(
157
+ keyword_match_ci=keyword_ci,
158
+ action_verbs_ci=verb_ci,
159
+ quantification_ci=quant_ci,
160
+ composite_delta=round(composite_delta, 1),
161
+ n_bullets_before=len(before.bullet_scores),
162
+ n_bullets_after=len(after.bullet_scores),
163
+ significant=significant,
164
+ )