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
File without changes
@@ -0,0 +1,50 @@
1
+ """DataTable displaying claims with their verification verdicts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.widgets import DataTable
6
+
7
+ from receipts.verify.verifier import VerificationResult
8
+
9
+ _VERDICT_STYLE = {
10
+ "verified": "[green]verified[/green]",
11
+ "plausible": "[yellow]plausible[/yellow]",
12
+ "unsupported": "[red]unsupported[/red]",
13
+ }
14
+
15
+
16
+ class ClaimsTable(DataTable):
17
+ """Table showing each claim, its type, verdict, and explanation.
18
+
19
+ Narrow terminals truncate the explanation column — selecting a row
20
+ (Enter/click) surfaces the FULL claim, bullet, explanation, and
21
+ evidence in the Log tab via ``result_at``.
22
+ """
23
+
24
+ DEFAULT_CSS = """
25
+ ClaimsTable {
26
+ height: 1fr;
27
+ }
28
+ """
29
+
30
+ def on_mount(self) -> None:
31
+ self.add_columns("Type", "Value", "Verdict", "Section", "Explanation")
32
+ self.cursor_type = "row"
33
+ self.zebra_stripes = True
34
+ self._results: list[VerificationResult] = []
35
+
36
+ def add_result(self, result: VerificationResult) -> None:
37
+ self._results.append(result)
38
+ styled = _VERDICT_STYLE.get(result.verdict, result.verdict)
39
+ self.add_row(
40
+ result.claim.claim_type,
41
+ result.claim.value,
42
+ styled,
43
+ result.claim.section,
44
+ result.explanation[:80],
45
+ )
46
+
47
+ def result_at(self, row_index: int) -> VerificationResult | None:
48
+ if 0 <= row_index < len(self._results):
49
+ return self._results[row_index]
50
+ return None
@@ -0,0 +1,38 @@
1
+ """Diff view showing original vs rewritten resume bullets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.widgets import RichLog
6
+
7
+ from receipts.verify.rewriter import RewriteResult
8
+
9
+
10
+ class DiffView(RichLog):
11
+ """RichLog that shows original / rewritten bullet pairs."""
12
+
13
+ DEFAULT_CSS = """
14
+ DiffView {
15
+ height: 1fr;
16
+ }
17
+ """
18
+
19
+ def __init__(self, **kwargs) -> None:
20
+ super().__init__(markup=True, wrap=True, **kwargs)
21
+
22
+ def add_rewrite(self, rw: RewriteResult) -> None:
23
+ self.write("")
24
+ self.write("[bold]ORIGINAL:[/bold]")
25
+ self.write(f" [red]{_escape(rw.original)}[/red]")
26
+ self.write("[bold]REWRITTEN:[/bold]")
27
+ self.write(f" [green]{_escape(rw.rewritten)}[/green]")
28
+ self.write(f" [dim]{_escape(rw.explanation)}[/dim]")
29
+ self.write("─" * 60)
30
+
31
+ def show_no_rewrites(self) -> None:
32
+ self.write("")
33
+ msg = "All bullets are backed by code — no rewrites needed."
34
+ self.write(f"[green]{msg}[/green]")
35
+
36
+
37
+ def _escape(text: str) -> str:
38
+ return text.replace("[", "\\[")
@@ -0,0 +1,38 @@
1
+ """Persistent status bar showing live token count and session cost."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.reactive import reactive
6
+ from textual.widgets import Static
7
+
8
+
9
+ class StatusBar(Static):
10
+ """Footer-style bar displaying token usage, cost, and provider."""
11
+
12
+ input_tokens = reactive(0)
13
+ output_tokens = reactive(0)
14
+ provider_name = reactive("—")
15
+ status_text = reactive("Ready")
16
+
17
+ DEFAULT_CSS = """
18
+ StatusBar {
19
+ dock: bottom;
20
+ height: 1;
21
+ background: $primary-background;
22
+ color: $text;
23
+ padding: 0 1;
24
+ }
25
+ """
26
+
27
+ def render(self) -> str:
28
+ total = self.input_tokens + self.output_tokens
29
+ return (
30
+ f" {self.status_text}"
31
+ f" | tokens: {total:,}"
32
+ f" (in: {self.input_tokens:,} out: {self.output_tokens:,})"
33
+ f" | provider: {self.provider_name}"
34
+ )
35
+
36
+ def add_tokens(self, input_t: int, output_t: int) -> None:
37
+ self.input_tokens += input_t
38
+ self.output_tokens += output_t
File without changes
@@ -0,0 +1,112 @@
1
+ """Okapi BM25 lexical index — built from scratch, zero dependencies.
2
+
3
+ Dense embeddings blur exact identifiers: "pgvector", "Alembic",
4
+ "docker-compose" are lexical signals that resume claims are full of and
5
+ that cosine similarity treats as fuzz. BM25 nails them. This index is the
6
+ sparse half of hybrid retrieval (Phase 22A): both retrievers run, their
7
+ rankings merge via reciprocal rank fusion, and every surfaced candidate
8
+ still has to pass the verifier's semantic distance threshold — BM25 adds
9
+ recall, never credulity.
10
+
11
+ The implementation is textbook Okapi BM25 (k1=1.5, b=0.75) over a
12
+ code-aware tokenizer that splits snake_case and camelCase identifiers into
13
+ sub-tokens while keeping the original, so "KnowledgeBase" matches
14
+ "knowledge base" and "kb_dir_for_url" matches "url".
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ import re
21
+ from collections import Counter
22
+
23
+ _TOKEN_RE = re.compile(r"[A-Za-z0-9_]+")
24
+ _CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
25
+
26
+ _K1 = 1.5
27
+ _B = 0.75
28
+
29
+ #: Reciprocal-rank-fusion constant — the standard 60 from the RRF paper.
30
+ RRF_K = 60
31
+
32
+
33
+ def tokenize(text: str) -> list[str]:
34
+ """Code-aware tokens: whole identifiers plus their snake/camel parts."""
35
+ out: list[str] = []
36
+ for raw in _TOKEN_RE.findall(text):
37
+ low = raw.lower()
38
+ out.append(low)
39
+ if "_" in raw:
40
+ out.extend(p for p in low.split("_") if len(p) > 1)
41
+ parts = _CAMEL_RE.findall(raw)
42
+ if len(parts) > 1:
43
+ out.extend(p.lower() for p in parts if len(p) > 1)
44
+ return out
45
+
46
+
47
+ class BM25Index:
48
+ """In-memory BM25 over ``{doc_id: text}``.
49
+
50
+ Built lazily from the Chroma collection's own documents, so it can
51
+ never drift out of sync with the vector store — there is no second
52
+ index on disk to corrupt or forget to update.
53
+ """
54
+
55
+ def __init__(self, docs: dict[str, str]) -> None:
56
+ self._tf: dict[str, Counter[str]] = {}
57
+ self._doc_len: dict[str, int] = {}
58
+ df: Counter[str] = Counter()
59
+
60
+ for doc_id, text in docs.items():
61
+ tokens = tokenize(text)
62
+ counts = Counter(tokens)
63
+ self._tf[doc_id] = counts
64
+ self._doc_len[doc_id] = len(tokens)
65
+ df.update(counts.keys())
66
+
67
+ n_docs = max(len(docs), 1)
68
+ self._avgdl = sum(self._doc_len.values()) / n_docs if self._doc_len else 1.0
69
+ self._idf = {
70
+ term: math.log((n_docs - freq + 0.5) / (freq + 0.5) + 1.0)
71
+ for term, freq in df.items()
72
+ }
73
+
74
+ def __len__(self) -> int:
75
+ return len(self._tf)
76
+
77
+ def search(self, query: str, n: int = 10) -> list[tuple[str, float]]:
78
+ """Top ``n`` (doc_id, score) for the query, best first."""
79
+ q_terms = [t for t in tokenize(query) if t in self._idf]
80
+ if not q_terms:
81
+ return []
82
+
83
+ scores: dict[str, float] = {}
84
+ for doc_id, tf in self._tf.items():
85
+ dl = self._doc_len[doc_id] or 1
86
+ score = 0.0
87
+ for term in q_terms:
88
+ f = tf.get(term, 0)
89
+ if not f:
90
+ continue
91
+ idf = self._idf[term]
92
+ score += (
93
+ idf * (f * (_K1 + 1)) / (f + _K1 * (1 - _B + _B * dl / self._avgdl))
94
+ )
95
+ if score > 0:
96
+ scores[doc_id] = score
97
+
98
+ ranked = sorted(scores.items(), key=lambda kv: -kv[1])
99
+ return ranked[:n]
100
+
101
+
102
+ def rrf_fuse(rankings: list[list[str]], *, k: int = RRF_K) -> list[str]:
103
+ """Reciprocal rank fusion: merge rankings into one, best first.
104
+
105
+ score(d) = Σ over rankings 1/(k + rank_of_d). Documents high in ANY
106
+ ranking surface; documents high in SEVERAL rankings surface first.
107
+ """
108
+ scores: dict[str, float] = {}
109
+ for ranking in rankings:
110
+ for rank, doc_id in enumerate(ranking, start=1):
111
+ scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
112
+ return [d for d, _ in sorted(scores.items(), key=lambda kv: -kv[1])]
@@ -0,0 +1,128 @@
1
+ """Contextual chunk enrichment (Phase 22B) — Anthropic's contextual retrieval.
2
+
3
+ A bare code chunk is ambiguous at query time: `def connect()` could belong
4
+ to anything. Before embedding, each artifact gets ONE generated line of
5
+ context ("connection factory for the postgres pool in backend/db") prepended
6
+ to its text, so both the embedding and the BM25 index see where the chunk
7
+ lives and what it's for.
8
+
9
+ Costs one LLM call per artifact at ingest time (free on Ollama, metered like
10
+ every other call), which is why it's OFF by default (``RECEIPTS_ENRICH=1``
11
+ to enable). Context lines are cached by the RAW chunk's content hash in the
12
+ KB directory, so re-ingesting regenerates nothing for unchanged code — the
13
+ incremental-sync guarantee survives enrichment.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import os
21
+ from collections.abc import Callable
22
+ from dataclasses import replace
23
+ from pathlib import Path
24
+
25
+ from receipts.ingest.artifact_extractor import Artifact
26
+ from receipts.llm.json_utils import parse_json_loose
27
+ from receipts.llm.provider import LLMProvider
28
+
29
+ _CONTEXT_PREFIX = "[context]"
30
+
31
+ _CONTEXT_SYSTEM = """\
32
+ You label code chunks for a search index. Given a file path and a chunk of
33
+ code from that file, write ONE line (maximum 25 words) saying what the
34
+ chunk does and where it sits in the project. Plain, concrete, no fluff.
35
+
36
+ Respond in JSON: {"context": "one line here"}
37
+ """
38
+
39
+
40
+ def enrich_enabled() -> bool:
41
+ """Contextual enrichment toggle — off unless RECEIPTS_ENRICH=1."""
42
+ return os.environ.get("RECEIPTS_ENRICH", "0").strip() == "1"
43
+
44
+
45
+ def _raw_hash(artifact: Artifact) -> str:
46
+ return hashlib.sha256(artifact.code.encode("utf-8", errors="replace")).hexdigest()[
47
+ :16
48
+ ]
49
+
50
+
51
+ def _load_cache(path: Path) -> dict[str, str]:
52
+ if not path.is_file():
53
+ return {}
54
+ try:
55
+ data = json.loads(path.read_text(encoding="utf-8"))
56
+ return data if isinstance(data, dict) else {}
57
+ except (OSError, json.JSONDecodeError):
58
+ return {}
59
+
60
+
61
+ def _save_cache(path: Path, cache: dict[str, str]) -> None:
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ tmp = path.with_suffix(".json.tmp")
64
+ tmp.write_text(json.dumps(cache, indent=0), encoding="utf-8")
65
+ tmp.replace(path)
66
+
67
+
68
+ def contextualize_artifacts(
69
+ artifacts: list[Artifact],
70
+ provider: LLMProvider,
71
+ *,
72
+ cache_path: Path,
73
+ on_progress: Callable[[int, int], None] | None = None,
74
+ ) -> tuple[list[Artifact], int]:
75
+ """Prepend a generated context line to every artifact's code.
76
+
77
+ Returns (enriched_artifacts, llm_calls_made). Cached context lines are
78
+ reused by raw-content hash; already-enriched chunks (idempotence guard)
79
+ pass through untouched. A failed generation falls back to a mechanical
80
+ context line built from the path — never blocks the ingest.
81
+ """
82
+ cache = _load_cache(cache_path)
83
+ calls = 0
84
+ enriched: list[Artifact] = []
85
+
86
+ for i, artifact in enumerate(artifacts, 1):
87
+ if artifact.code.startswith(_CONTEXT_PREFIX) or artifact.kind in (
88
+ "facts",
89
+ "summary",
90
+ ):
91
+ # Already enriched, or a synthetic corpus-level chunk that is
92
+ # self-describing text — never double-wrap either.
93
+ enriched.append(artifact)
94
+ continue
95
+
96
+ key = _raw_hash(artifact)
97
+ context = cache.get(key)
98
+ if context is None:
99
+ user = (
100
+ f"FILE: {artifact.rel_path}\n"
101
+ f"KIND: {artifact.kind} {artifact.name}\n"
102
+ f"CODE:\n{artifact.code[:1500]}"
103
+ )
104
+ try:
105
+ completion = provider.complete(_CONTEXT_SYSTEM, user, json_mode=True)
106
+ calls += 1
107
+ parsed = parse_json_loose(completion.text)
108
+ context = str(parsed.get("context", "")).strip()
109
+ except Exception:
110
+ context = ""
111
+ if not context:
112
+ # Mechanical fallback — still better than nothing, and it
113
+ # keeps enrichment deterministic for the sync hash.
114
+ context = f"{artifact.kind} {artifact.name} in {artifact.rel_path}"
115
+ context = context.splitlines()[0][:200]
116
+ cache[key] = context
117
+ _save_cache(cache_path, cache)
118
+
119
+ enriched.append(
120
+ replace(
121
+ artifact,
122
+ code=f"{_CONTEXT_PREFIX} {context}\n{artifact.code}",
123
+ )
124
+ )
125
+ if on_progress is not None:
126
+ on_progress(i, len(artifacts))
127
+
128
+ return enriched, calls
@@ -0,0 +1,101 @@
1
+ """Fetch a job description from a URL and strip it to plain text.
2
+
3
+ Stdlib only (urllib + html.parser) — no new dependency for one HTTP GET.
4
+ Job boards that block bots (LinkedIn, Indeed, anything behind a JS wall)
5
+ fail with a clear instruction to paste the JD into a file and use ``--jd``
6
+ instead — never a crash, never a silent empty gap analysis.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import urllib.error
12
+ import urllib.request
13
+ from html.parser import HTMLParser
14
+
15
+ from receipts.errors import ReceiptsError
16
+
17
+ #: Below this many characters of extracted text, assume the page didn't
18
+ #: actually serve the JD (bot wall, JS-rendered shell, login redirect).
19
+ _MIN_TEXT_CHARS = 200
20
+
21
+ _MAX_BYTES = 2_000_000
22
+
23
+ _FALLBACK_HINT = (
24
+ " Copy the job description text into a file and pass it with --jd"
25
+ " instead — that path always works."
26
+ )
27
+
28
+ _SKIP_TAGS = {"script", "style", "noscript", "head", "svg", "template"}
29
+
30
+
31
+ class _TextExtractor(HTMLParser):
32
+ """Collect visible text, skipping script/style and collapsing whitespace."""
33
+
34
+ def __init__(self) -> None:
35
+ super().__init__(convert_charrefs=True)
36
+ self._chunks: list[str] = []
37
+ self._skip_depth = 0
38
+
39
+ def handle_starttag(self, tag: str, attrs) -> None:
40
+ if tag in _SKIP_TAGS:
41
+ self._skip_depth += 1
42
+
43
+ def handle_endtag(self, tag: str) -> None:
44
+ if tag in _SKIP_TAGS and self._skip_depth:
45
+ self._skip_depth -= 1
46
+
47
+ def handle_data(self, data: str) -> None:
48
+ if self._skip_depth == 0 and data.strip():
49
+ self._chunks.append(data.strip())
50
+
51
+ def text(self) -> str:
52
+ return "\n".join(self._chunks)
53
+
54
+
55
+ def html_to_text(html: str) -> str:
56
+ """Strip HTML to readable plain text."""
57
+ parser = _TextExtractor()
58
+ parser.feed(html)
59
+ return parser.text()
60
+
61
+
62
+ def fetch_jd_text(url: str, *, timeout: float = 20.0) -> str:
63
+ """Fetch ``url`` and return the page's visible text.
64
+
65
+ Raises ReceiptsError with an actionable message on every failure mode:
66
+ bad URL, HTTP error, bot-blocked page, or a page with almost no text.
67
+ """
68
+ if not url.startswith(("http://", "https://")):
69
+ raise ReceiptsError(f"'{url}' is not an http(s) URL.\n{_FALLBACK_HINT}")
70
+
71
+ request = urllib.request.Request(
72
+ url,
73
+ # A plain-browser UA: some boards 403 the default Python UA outright.
74
+ headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) receipts-cli"},
75
+ )
76
+ try:
77
+ with urllib.request.urlopen(request, timeout=timeout) as resp:
78
+ raw = resp.read(_MAX_BYTES)
79
+ charset = resp.headers.get_content_charset() or "utf-8"
80
+ except urllib.error.HTTPError as exc:
81
+ blocked = exc.code in (401, 403, 429, 451, 999)
82
+ why = (
83
+ "the site is blocking automated access"
84
+ if blocked
85
+ else f"the server returned HTTP {exc.code}"
86
+ )
87
+ raise ReceiptsError(
88
+ f"Could not fetch the JD from {url} — {why}.\n{_FALLBACK_HINT}"
89
+ ) from None
90
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
91
+ raise ReceiptsError(
92
+ f"Could not fetch the JD from {url} — {exc}.\n{_FALLBACK_HINT}"
93
+ ) from None
94
+
95
+ text = html_to_text(raw.decode(charset, errors="replace"))
96
+ if len(text) < _MIN_TEXT_CHARS:
97
+ raise ReceiptsError(
98
+ f"The page at {url} returned almost no text — it is probably"
99
+ " JS-rendered or behind bot protection.\n" + _FALLBACK_HINT
100
+ )
101
+ return text