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
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
|
+
)
|