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,415 @@
1
+ """Mock-interview loop weighted toward weak resume bullets.
2
+
3
+ The interviewer picks bullets to quiz on — Unsupported and Plausible
4
+ bullets get asked about more often than Verified ones. For each bullet
5
+ it generates a technical question, listens to the user's answer, asks a
6
+ natural follow-up, then moves on. At the end it prints a summary of
7
+ what held up and what didn't.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import random
14
+ from dataclasses import dataclass, field
15
+
16
+ from receipts.llm.json_utils import parse_json_loose
17
+ from receipts.llm.provider import CompletionResult, LLMProvider
18
+ from receipts.verify.verifier import VerificationResult
19
+
20
+ _VERDICT_WEIGHT = {
21
+ "unsupported": 3,
22
+ "plausible": 2,
23
+ "verified": 1,
24
+ }
25
+
26
+ _QUESTION_SYSTEM = """\
27
+ You are a technical interviewer. Given a resume bullet and its \
28
+ verification status, ask ONE pointed technical question that tests \
29
+ whether the candidate truly did what the bullet claims.
30
+
31
+ Rules:
32
+ - For "unsupported" or "plausible" bullets, probe the specific claim \
33
+ that lacks evidence — ask for concrete details, numbers, or \
34
+ implementation choices.
35
+ - For "verified" bullets, ask a deeper follow-up that tests real \
36
+ understanding (architecture decisions, trade-offs, failure modes).
37
+ - Be direct and specific. No generic "tell me about" questions.
38
+ - The question should be answerable in 2-3 sentences.
39
+
40
+ Respond in JSON: {"question": "your question here"}
41
+ """
42
+
43
+ _EXPERIENCE_QUESTION_SYSTEM = """\
44
+ You are a behavioral interviewer. Given a resume bullet from the \
45
+ candidate's work experience, ask ONE pointed behavioral question \
46
+ using the STAR format (Situation, Task, Action, Result).
47
+
48
+ Rules:
49
+ - Ask about a specific aspect of the bullet — a challenge faced, a \
50
+ decision made, a result achieved, or a collaboration required.
51
+ - Probe for concrete details: team size, timeline, measurable impact.
52
+ - Do NOT ask generic questions. Reference the specific technologies, \
53
+ metrics, or responsibilities mentioned in the bullet.
54
+ - The question should be answerable in 3-4 sentences.
55
+
56
+ Respond in JSON: {"question": "your question here"}
57
+ """
58
+
59
+ _EDUCATION_QUESTION_SYSTEM = """\
60
+ You are an interviewer testing a candidate's academic knowledge. Given \
61
+ a bullet from the Education section of their resume, ask ONE pointed \
62
+ knowledge question.
63
+
64
+ Rules:
65
+ - If the bullet mentions a GPA, coursework, or degree, ask about a \
66
+ concept they should know from that program.
67
+ - If the bullet mentions a specific course or project, ask about the \
68
+ key concepts or techniques involved.
69
+ - Test understanding, not memorization — ask "how does X work" or \
70
+ "what's the trade-off between X and Y", not "define X".
71
+ - The question should be answerable in 2-3 sentences.
72
+
73
+ Respond in JSON: {"question": "your question here"}
74
+ """
75
+
76
+ _SKILLS_QUESTION_SYSTEM = """\
77
+ You are a technical interviewer. Given a skill claim from the \
78
+ candidate's resume, ask ONE practical question that tests whether \
79
+ they actually use this technology.
80
+
81
+ Rules:
82
+ - Ask about real usage: "how would you do X with this tool" or "when \
83
+ would you choose X over Y".
84
+ - Probe for hands-on experience, not textbook knowledge.
85
+ - If the skill is a language or framework, ask about a specific feature, \
86
+ pattern, or common pitfall.
87
+ - The question should be answerable in 2-3 sentences.
88
+
89
+ Respond in JSON: {"question": "your question here"}
90
+ """
91
+
92
+ _SECTION_QUESTION_SYSTEMS = {
93
+ "experience": _EXPERIENCE_QUESTION_SYSTEM,
94
+ "education": _EDUCATION_QUESTION_SYSTEM,
95
+ "skills": _SKILLS_QUESTION_SYSTEM,
96
+ }
97
+
98
+ # Interview round presets — match the questioning style to the kind of
99
+ # company round the candidate is preparing for.
100
+ _STYLE_MODIFIERS = {
101
+ "service": """\
102
+
103
+ ROUND STYLE — service company (TCS/Infosys/Wipro-type):
104
+ - Favor fundamentals and clarity over depth: "explain your project simply",
105
+ core language/OOP/DBMS concepts behind what they built.
106
+ - Expect the candidate to explain their project to a non-expert panel in
107
+ 2-3 sentences before drilling one level down.
108
+ - Keep the question answerable without whiteboarding.""",
109
+ "product": """\
110
+
111
+ ROUND STYLE — product company (design-depth round):
112
+ - Favor architecture decisions, trade-offs, scalability, and failure modes.
113
+ - Ask "why this over the alternative" and "what breaks at 10x load".
114
+ - Reference the specific technologies in the bullet — generic system
115
+ design questions are not allowed.""",
116
+ "startup": """\
117
+
118
+ ROUND STYLE — startup (ownership round):
119
+ - Favor shipping speed, scrappiness, and end-to-end ownership.
120
+ - Ask what the candidate cut, deferred, or hacked to ship — and how they'd
121
+ fix it with more time.
122
+ - Probe whether they understand the WHOLE stack of what they built, not
123
+ just their corner.""",
124
+ }
125
+
126
+ INTERVIEW_STYLES = tuple(_STYLE_MODIFIERS)
127
+
128
+ _FOLLOWUP_SYSTEM = """\
129
+ You are a technical interviewer conducting a follow-up. Given the \
130
+ original question and the candidate's answer, ask ONE natural \
131
+ follow-up question that digs deeper.
132
+
133
+ Rules:
134
+ - If the answer was vague, ask for specifics.
135
+ - If the answer was detailed, probe a related edge case or trade-off.
136
+ - Keep it conversational — this should feel like a real interview.
137
+ - One question only, answerable in 2-3 sentences.
138
+
139
+ Respond in JSON: {"follow_up": "your follow-up question here"}
140
+ """
141
+
142
+ _ASSESS_SYSTEM = """\
143
+ You are evaluating a candidate's interview answer. Given the resume \
144
+ bullet, the question asked, and the candidate's answer, assess how \
145
+ well they demonstrated real knowledge.
146
+
147
+ Respond in JSON:
148
+ {"held_up": true/false, "assessment": "one sentence summary"}
149
+
150
+ Set held_up to true if the answer shows genuine understanding, even \
151
+ if imperfect. Set it to false if the answer is vague, evasive, or \
152
+ contradicts the claim.
153
+ """
154
+
155
+ _MODEL_ANSWER_SYSTEM = """\
156
+ You are an interview coach. Given a resume bullet, an interview question \
157
+ just asked about it, and the actual code evidence from the candidate's \
158
+ codebase, write the answer the candidate SHOULD give.
159
+
160
+ Rules:
161
+ - 3-5 sentences, first person, spoken the way a candidate would in a real \
162
+ interview — not a written essay.
163
+ - Ground every claim ONLY in the provided evidence. Reference the specific \
164
+ files, functions, or patterns shown. Never invent numbers, files, or \
165
+ capabilities that aren't in the evidence.
166
+ - If the evidence only partially supports the bullet, answer honestly about \
167
+ what the code actually shows rather than overclaiming.
168
+
169
+ Respond in JSON: {"model_answer": "the answer here"}
170
+ """
171
+
172
+ _MODEL_ANSWER_NO_EVIDENCE_SYSTEM = """\
173
+ You are an interview coach. A resume bullet was just asked about in an \
174
+ interview, but there is NO code evidence in the candidate's ingested \
175
+ codebase to back it up — the claim may be about a private repo, a \
176
+ non-code achievement, or something that simply cannot be proven from code.
177
+
178
+ Write the answer the candidate should give: honest, specific where they \
179
+ legitimately can be, framed so they show real understanding WITHOUT \
180
+ fabricating evidence or numbers they cannot support.
181
+
182
+ Rules:
183
+ - 3-5 sentences, first person, conversational.
184
+ - Do NOT invent metrics, file names, or specifics that weren't in the bullet.
185
+ - Show how to speak to the claim credibly and honestly, acknowledging the \
186
+ limits of what can be demonstrated.
187
+
188
+ Respond in JSON: {"model_answer": "the answer here"}
189
+ """
190
+
191
+ _SUMMARY_SYSTEM = """\
192
+ You are wrapping up a mock interview. Given the rounds of Q&A and \
193
+ assessments, write a brief summary.
194
+
195
+ Respond in JSON:
196
+ {"strong": ["point 1", "point 2"], \
197
+ "weak": ["point 1", "point 2"], \
198
+ "overall": "one paragraph overall assessment"}
199
+ """
200
+
201
+
202
+ @dataclass(frozen=True)
203
+ class InterviewRound:
204
+ bullet_text: str
205
+ section: str
206
+ heading: str
207
+ verdict: str
208
+ question: str
209
+ answer: str
210
+ follow_up: str
211
+ follow_up_answer: str
212
+ held_up: bool
213
+ assessment: str
214
+ model_answer: str = ""
215
+ completions: list[CompletionResult] = field(default_factory=list)
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class InterviewSummary:
220
+ rounds: list[InterviewRound]
221
+ strong: list[str]
222
+ weak: list[str]
223
+ overall: str
224
+ completion: CompletionResult | None = None
225
+
226
+
227
+ def select_bullets(
228
+ results: list[VerificationResult],
229
+ n: int,
230
+ *,
231
+ rng: random.Random | None = None,
232
+ ) -> list[VerificationResult]:
233
+ """Pick *n* bullets to quiz on, weighted toward weak verdicts."""
234
+ by_bullet: dict[str, VerificationResult] = {}
235
+ for r in results:
236
+ key = r.claim.bullet_text
237
+ if key not in by_bullet:
238
+ by_bullet[key] = r
239
+ elif _VERDICT_WEIGHT.get(r.verdict, 1) > _VERDICT_WEIGHT.get(
240
+ by_bullet[key].verdict, 1
241
+ ):
242
+ by_bullet[key] = r
243
+
244
+ unique = list(by_bullet.values())
245
+ if not unique:
246
+ return []
247
+
248
+ weights = [_VERDICT_WEIGHT.get(r.verdict, 1) for r in unique]
249
+ rng = rng or random.Random()
250
+ n = min(n, len(unique))
251
+
252
+ selected: list[VerificationResult] = []
253
+ remaining = list(zip(unique, weights, strict=True))
254
+
255
+ for _ in range(n):
256
+ if not remaining:
257
+ break
258
+ items, ws = zip(*remaining, strict=True)
259
+ choice = rng.choices(list(items), weights=list(ws), k=1)[0]
260
+ selected.append(choice)
261
+ remaining = [(r, w) for r, w in remaining if r is not choice]
262
+
263
+ return selected
264
+
265
+
266
+ def generate_question(
267
+ result: VerificationResult,
268
+ provider: LLMProvider,
269
+ *,
270
+ style: str | None = None,
271
+ ) -> tuple[str, CompletionResult]:
272
+ """Ask the LLM to generate an interview question for this bullet.
273
+
274
+ Uses section-aware system prompts: behavioral/STAR for work experience,
275
+ knowledge-check for education, skill-application for skills, and
276
+ technical deep-dive for projects. ``style`` optionally tunes the
277
+ questioning to a company round type ("service" | "product" | "startup").
278
+ """
279
+ sec_type = getattr(result.claim, "section_type", "project")
280
+ system = _SECTION_QUESTION_SYSTEMS.get(sec_type, _QUESTION_SYSTEM)
281
+ modifier = _STYLE_MODIFIERS.get((style or "").strip().lower())
282
+ if modifier:
283
+ system += modifier
284
+
285
+ user = (
286
+ f"BULLET: {result.claim.bullet_text}\n"
287
+ f"SECTION: {result.claim.section}\n"
288
+ f"VERDICT: {result.verdict}\n"
289
+ f"EXPLANATION: {result.explanation}"
290
+ )
291
+ completion = provider.complete(system, user, json_mode=True)
292
+ try:
293
+ parsed = parse_json_loose(completion.text)
294
+ question = parsed.get("question", completion.text)
295
+ except (ValueError, json.JSONDecodeError, AttributeError):
296
+ question = completion.text
297
+ return question, completion
298
+
299
+
300
+ def generate_follow_up(
301
+ question: str,
302
+ answer: str,
303
+ provider: LLMProvider,
304
+ ) -> tuple[str, CompletionResult]:
305
+ """Generate a follow-up question based on the candidate's answer."""
306
+ user = f"QUESTION: {question}\nANSWER: {answer}"
307
+ completion = provider.complete(_FOLLOWUP_SYSTEM, user, json_mode=True)
308
+ try:
309
+ parsed = parse_json_loose(completion.text)
310
+ follow_up = parsed.get("follow_up", completion.text)
311
+ except (ValueError, json.JSONDecodeError, AttributeError):
312
+ follow_up = completion.text
313
+ return follow_up, completion
314
+
315
+
316
+ def assess_answer(
317
+ bullet_text: str,
318
+ question: str,
319
+ answer: str,
320
+ provider: LLMProvider,
321
+ ) -> tuple[bool, str, CompletionResult]:
322
+ """Assess whether the candidate's answer held up."""
323
+ user = f"BULLET: {bullet_text}\n" f"QUESTION: {question}\n" f"ANSWER: {answer}"
324
+ completion = provider.complete(_ASSESS_SYSTEM, user, json_mode=True)
325
+ try:
326
+ parsed = parse_json_loose(completion.text)
327
+ held_up = bool(parsed.get("held_up", False))
328
+ assessment = parsed.get("assessment", "")
329
+ except (ValueError, json.JSONDecodeError, AttributeError):
330
+ held_up = False
331
+ assessment = completion.text[:200]
332
+ return held_up, assessment, completion
333
+
334
+
335
+ def generate_model_answer(
336
+ result: VerificationResult,
337
+ question: str,
338
+ provider: LLMProvider,
339
+ ) -> tuple[str, CompletionResult]:
340
+ """Generate the strong, honest answer the candidate SHOULD have given.
341
+
342
+ Grounded ONLY in the round's retrieved code evidence. When the bullet has
343
+ no evidence behind it (unsupported claims, non-code sections), the prompt
344
+ switches to honest-reframe guidance instead of inventing support — the
345
+ same honesty constraint the verifier enforces. Evidence chunks are
346
+ truncated so a large ``SearchResult.code`` block never blows the context
347
+ of a small local model.
348
+ """
349
+ evidence = getattr(result, "evidence", None) or []
350
+ if evidence:
351
+ parts = [
352
+ f"BULLET: {result.claim.bullet_text}",
353
+ f"QUESTION: {question}",
354
+ "",
355
+ "CODE EVIDENCE:",
356
+ ]
357
+ for e in evidence[:3]:
358
+ parts.append(f"--- {e.rel_path} :: {e.name} ---")
359
+ parts.append(e.code[:1200])
360
+ user = "\n".join(parts)
361
+ system = _MODEL_ANSWER_SYSTEM
362
+ else:
363
+ user = (
364
+ f"BULLET: {result.claim.bullet_text}\n"
365
+ f"QUESTION: {question}\n"
366
+ f"VERDICT: {result.verdict}\n"
367
+ f"EXPLANATION: {result.explanation}"
368
+ )
369
+ system = _MODEL_ANSWER_NO_EVIDENCE_SYSTEM
370
+
371
+ completion = provider.complete(system, user, json_mode=True)
372
+ try:
373
+ parsed = parse_json_loose(completion.text)
374
+ answer = parsed.get("model_answer", completion.text)
375
+ except (ValueError, json.JSONDecodeError, AttributeError):
376
+ answer = completion.text
377
+ return answer, completion
378
+
379
+
380
+ def generate_summary(
381
+ rounds: list[InterviewRound],
382
+ provider: LLMProvider,
383
+ ) -> InterviewSummary:
384
+ """Generate a summary of the entire interview."""
385
+ parts = []
386
+ for i, r in enumerate(rounds, 1):
387
+ parts.append(
388
+ f"Round {i}:\n"
389
+ f" Bullet: {r.bullet_text[:80]}\n"
390
+ f" Verdict: {r.verdict}\n"
391
+ f" Q: {r.question}\n"
392
+ f" A: {r.answer[:100]}\n"
393
+ f" Held up: {r.held_up}\n"
394
+ f" Assessment: {r.assessment}"
395
+ )
396
+
397
+ completion = provider.complete(_SUMMARY_SYSTEM, "\n\n".join(parts), json_mode=True)
398
+
399
+ try:
400
+ parsed = parse_json_loose(completion.text)
401
+ strong = parsed.get("strong", [])
402
+ weak = parsed.get("weak", [])
403
+ overall = parsed.get("overall", "")
404
+ except (ValueError, json.JSONDecodeError, AttributeError):
405
+ strong = []
406
+ weak = []
407
+ overall = completion.text[:300]
408
+
409
+ return InterviewSummary(
410
+ rounds=rounds,
411
+ strong=strong,
412
+ weak=weak,
413
+ overall=overall,
414
+ completion=completion,
415
+ )
@@ -0,0 +1 @@
1
+ """ATS scoring & comparison engine."""
@@ -0,0 +1,98 @@
1
+ """Before/after ATS comparison — score original vs rewritten, report deltas."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from receipts.ats.scorer import (
9
+ ATSScore,
10
+ score_tex_file,
11
+ score_tex_string,
12
+ )
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class DimensionDelta:
17
+ name: str
18
+ before: int
19
+ after: int
20
+ delta: int
21
+ detail_before: str
22
+ detail_after: str
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ComparisonReport:
27
+ before: ATSScore
28
+ after: ATSScore
29
+ deltas: list[DimensionDelta]
30
+ composite_delta: float
31
+ improved: bool
32
+
33
+ @property
34
+ def summary(self) -> str:
35
+ direction = "+" if self.composite_delta >= 0 else ""
36
+ lines = [
37
+ f"Composite: {self.before.composite:.1f} → {self.after.composite:.1f} "
38
+ f"({direction}{self.composite_delta:.1f})",
39
+ "",
40
+ ]
41
+ for d in self.deltas:
42
+ arrow = "+" if d.delta >= 0 else ""
43
+ lines.append(
44
+ f" {d.name:<22} {d.before:>3} → {d.after:>3} ({arrow}{d.delta})"
45
+ )
46
+ return "\n".join(lines)
47
+
48
+
49
+ def compare_scores(before: ATSScore, after: ATSScore) -> ComparisonReport:
50
+ """Compare two ATSScore objects and produce a delta report."""
51
+ deltas: list[DimensionDelta] = []
52
+
53
+ for dim_before in before.dimensions:
54
+ dim_after = after.dimension(dim_before.name)
55
+ if dim_after is None:
56
+ continue
57
+ deltas.append(
58
+ DimensionDelta(
59
+ name=dim_before.name,
60
+ before=dim_before.score,
61
+ after=dim_after.score,
62
+ delta=dim_after.score - dim_before.score,
63
+ detail_before=dim_before.detail,
64
+ detail_after=dim_after.detail,
65
+ )
66
+ )
67
+
68
+ composite_delta = round(after.composite - before.composite, 1)
69
+
70
+ return ComparisonReport(
71
+ before=before,
72
+ after=after,
73
+ deltas=deltas,
74
+ composite_delta=composite_delta,
75
+ improved=composite_delta > 0,
76
+ )
77
+
78
+
79
+ def compare_tex_files(
80
+ before_path: Path,
81
+ after_path: Path,
82
+ jd_text: str,
83
+ ) -> ComparisonReport:
84
+ """Score two .tex files against the same JD and compare."""
85
+ before_score = score_tex_file(before_path, jd_text)
86
+ after_score = score_tex_file(after_path, jd_text)
87
+ return compare_scores(before_score, after_score)
88
+
89
+
90
+ def compare_tex_strings(
91
+ before_tex: str,
92
+ after_tex: str,
93
+ jd_text: str,
94
+ ) -> ComparisonReport:
95
+ """Score two TeX strings against the same JD and compare."""
96
+ before_score = score_tex_string(before_tex, jd_text)
97
+ after_score = score_tex_string(after_tex, jd_text)
98
+ return compare_scores(before_score, after_score)