cc-transcript 3.3.0__tar.gz → 4.0.0__tar.gz

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 (60) hide show
  1. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/PKG-INFO +2 -2
  2. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/__init__.py +0 -4
  3. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/cli.py +8 -4
  4. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/corrections.py +70 -40
  5. cc_transcript-4.0.0/cc_transcript/corrections_cli.py +122 -0
  6. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/evidence.py +2 -13
  7. cc_transcript-4.0.0/cc_transcript/extract/__init__.py +23 -0
  8. cc_transcript-4.0.0/cc_transcript/extract/correct.py +181 -0
  9. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/judge/__init__.py +1 -1
  10. cc_transcript-4.0.0/cc_transcript/judge/llm.py +106 -0
  11. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/pyproject.toml +2 -2
  12. cc_transcript-3.3.0/cc_transcript/judge/llm.py +0 -91
  13. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/Cargo.lock +0 -0
  14. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/Cargo.toml +0 -0
  15. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/LICENSE +0 -0
  16. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/README.md +0 -0
  17. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/__main__.py +0 -0
  18. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/_parser_rs.pyi +0 -0
  19. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/activity.py +0 -0
  20. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/backend.py +0 -0
  21. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/builders.py +0 -0
  22. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/context.py +0 -0
  23. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/decisions.py +0 -0
  24. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/discovery.py +0 -0
  25. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/disktruth.py +0 -0
  26. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/filterspec.py +0 -0
  27. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/ids.py +0 -0
  28. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/judge/verdicts.py +0 -0
  29. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/messages.py +0 -0
  30. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/mining/__init__.py +0 -0
  31. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/mining/candidates.py +0 -0
  32. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/mining/confidence.py +0 -0
  33. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/mining/filterspec.py +0 -0
  34. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/mining/formats.py +0 -0
  35. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/mining/signals.py +0 -0
  36. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/mining/sourcekind.py +0 -0
  37. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/mining/store.py +0 -0
  38. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/models.py +0 -0
  39. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/parser.py +0 -0
  40. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/py.typed +0 -0
  41. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/query.py +0 -0
  42. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/render.py +0 -0
  43. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/rust.py +0 -0
  44. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/sentiment/__init__.py +0 -0
  45. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/sentiment/buckets.py +0 -0
  46. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/sentiment/engine.py +0 -0
  47. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/sentiment/lexicon.py +0 -0
  48. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/sentiment/scorespec.py +0 -0
  49. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/store.py +0 -0
  50. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/tools.py +0 -0
  51. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/Cargo.toml +0 -0
  52. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/data/afinn-en-165.tsv +0 -0
  53. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/data/domain_overrides.tsv +0 -0
  54. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/src/event.rs +0 -0
  55. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/src/filter.rs +0 -0
  56. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/src/lexicon.rs +0 -0
  57. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/src/lib.rs +0 -0
  58. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/src/model.rs +0 -0
  59. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/src/score.rs +0 -0
  60. {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/src/value.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-transcript
3
- Version: 3.3.0
3
+ Version: 4.0.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -16,7 +16,7 @@ Requires-Dist: orjson>=3.10
16
16
  Requires-Dist: pytest>=8.0 ; extra == 'dev'
17
17
  Requires-Dist: ty>=0.0.44 ; extra == 'dev'
18
18
  Requires-Dist: ruff>=0.8 ; extra == 'dev'
19
- Requires-Dist: spawnllm>=0.1.3 ; extra == 'llm'
19
+ Requires-Dist: spawnllm>=0.2.0 ; extra == 'llm'
20
20
  Requires-Dist: spacy>=3.8 ; extra == 'sentiment'
21
21
  Requires-Dist: afinn>=0.1 ; extra == 'sentiment'
22
22
  Provides-Extra: dev
@@ -97,7 +97,6 @@ EXPORTS: dict[str, str] = {
97
97
  "native_user_classifier",
98
98
  ),
99
99
  "cc_transcript.evidence": (
100
- "EXTRACTOR_VERSION",
101
100
  "CandidatePair",
102
101
  "GitFix",
103
102
  "git_corrections",
@@ -320,9 +319,6 @@ if TYPE_CHECKING:
320
319
  from cc_transcript.disktruth import (
321
320
  load_export as load_export,
322
321
  )
323
- from cc_transcript.evidence import (
324
- EXTRACTOR_VERSION as EXTRACTOR_VERSION,
325
- )
326
322
  from cc_transcript.evidence import (
327
323
  CandidatePair as CandidatePair,
328
324
  )
@@ -24,6 +24,7 @@ from cc_transcript.builders import (
24
24
  drop_synthetic,
25
25
  keep_only,
26
26
  )
27
+ from cc_transcript.corrections_cli import corrections
27
28
  from cc_transcript.discovery import CLAUDE_PROJECTS_DIR, TranscriptDiscovery, find_transcript_sync
28
29
  from cc_transcript.filterspec import ASSISTANTS, USERS, EventKind, event_kind, keep
29
30
  from cc_transcript.ids import SessionId, tool_digest
@@ -255,6 +256,9 @@ def cli() -> None:
255
256
  """Investigate Claude Code transcripts: list, show, grep, and stats."""
256
257
 
257
258
 
259
+ cli.add_command(corrections)
260
+
261
+
258
262
  @cli.command("list")
259
263
  @discovery_options
260
264
  @click.option("--json", "as_json", is_flag=True, help="Emit one JSON object per transcript.")
@@ -424,7 +428,9 @@ def grep(
424
428
  for i in range(lo, hi)
425
429
  )
426
430
  if not as_json:
427
- out.append(f"{files_matched} files, {matched} matches" + (f" · {note}" if (note := scope_note(targets)) else ""))
431
+ out.append(
432
+ f"{files_matched} files, {matched} matches" + (f" · {note}" if (note := scope_note(targets)) else "")
433
+ )
428
434
  emit(out)
429
435
  if matched == 0:
430
436
  raise SystemExit(1)
@@ -449,9 +455,7 @@ def stats(
449
455
  targets = resolve_targets(paths, root=root, project=project, contains=contains, limit=None if all_ else limit)
450
456
  transcripts = parse_transcripts(targets.paths)
451
457
  if per_file and as_json:
452
- emit(
453
- orjson.dumps({"path": str(parsed.path)} | stats_dict(collect_stats([parsed]))) for parsed in transcripts
454
- )
458
+ emit(orjson.dumps({"path": str(parsed.path)} | stats_dict(collect_stats([parsed]))) for parsed in transcripts)
455
459
  elif per_file:
456
460
  emit(
457
461
  chain(
@@ -1,16 +1,16 @@
1
1
  """The shared code-correction ledger every consumer reads and writes.
2
2
 
3
- One SQLite table (``corrections_v1``) records each incorrect edit a developer
4
- pushed back on paired with the correction that later overwrote it — from the
5
- same session or from git history. cc-pushback writes it from its enrich pass;
6
- captain-hook and cc-review join it to their own records by ``incorrect_digest``,
7
- the same cross-language tool digest the decision ledger keys on. The DDL below
8
- is vendored byte-identical into the Go repo and byte-compared in CI.
3
+ One SQLite table (``corrections``) records each incorrect edit a developer
4
+ pushed back on paired with the correction that overwrote it — a later edit, a
5
+ git commit, or a reviewer's natural-language note. cc-pushback and captain-hook
6
+ write it from their harvest passes; cc-review writes human review corrections
7
+ through the ``cc-transcript corrections`` CLI; all join it by ``incorrect_digest``,
8
+ the same cross-language tool digest the decision ledger keys on.
9
9
 
10
10
  Import-light by contract, like :mod:`cc_transcript.ids` and
11
11
  :mod:`cc_transcript.decisions`: the standard library plus identity primitives
12
12
  only, so a hook reading corrections pays nothing for the parser. The harvest
13
- that produces rows lives in :func:`cc_transcript.evidence.record_harvest`,
13
+ that produces mined rows lives in :func:`cc_transcript.evidence.record_harvest`,
14
14
  which is allowed the heavier activity imports.
15
15
  """
16
16
 
@@ -29,93 +29,94 @@ if TYPE_CHECKING:
29
29
  from cc_transcript.ids import EventUuid, SessionId, ToolDigest
30
30
 
31
31
  CORRECTIONS_DDL = """\
32
- CREATE TABLE IF NOT EXISTS corrections_v1 (
32
+ CREATE TABLE IF NOT EXISTS corrections (
33
33
  id INTEGER PRIMARY KEY,
34
34
  ts_ms INTEGER NOT NULL,
35
35
  session_id TEXT NOT NULL,
36
36
  source TEXT NOT NULL,
37
37
  anchor_uuid TEXT NOT NULL,
38
- incorrect_digest TEXT NOT NULL,
38
+ incorrect_digest TEXT,
39
39
  incorrect_file TEXT NOT NULL,
40
40
  incorrect_old TEXT NOT NULL,
41
41
  incorrect_new TEXT NOT NULL,
42
- correction_origin TEXT CHECK (correction_origin IN ('session', 'git')),
42
+ correction_origin TEXT CHECK (correction_origin IN ('session', 'git', 'review')),
43
43
  correction_file TEXT,
44
44
  correction_old TEXT,
45
45
  correction_new TEXT,
46
46
  correction_commit TEXT,
47
+ correction_text TEXT,
47
48
  overlap REAL NOT NULL DEFAULT 0,
48
- extractor_version INTEGER NOT NULL,
49
49
  detail_json TEXT NOT NULL DEFAULT '{}',
50
- UNIQUE (session_id, anchor_uuid, incorrect_digest, extractor_version)
50
+ UNIQUE (session_id, anchor_uuid, incorrect_digest)
51
51
  );
52
52
 
53
- CREATE INDEX IF NOT EXISTS idx_corrections_v1_session_ts ON corrections_v1 (session_id, ts_ms);
53
+ CREATE INDEX IF NOT EXISTS idx_corrections_session_ts ON corrections (session_id, ts_ms);
54
54
 
55
- CREATE INDEX IF NOT EXISTS idx_corrections_v1_incorrect_digest ON corrections_v1 (incorrect_digest);
55
+ CREATE INDEX IF NOT EXISTS idx_corrections_incorrect_digest ON corrections (incorrect_digest);
56
56
  """
57
57
 
58
- Origin = Literal["session", "git"]
58
+ Origin = Literal["session", "git", "review"]
59
59
 
60
60
  CORRECTION_COLUMNS = (
61
61
  "ts_ms, session_id, source, anchor_uuid, incorrect_digest, incorrect_file, incorrect_old, "
62
62
  "incorrect_new, correction_origin, correction_file, correction_old, correction_new, "
63
- "correction_commit, overlap, extractor_version, detail_json"
63
+ "correction_commit, correction_text, overlap, detail_json"
64
64
  )
65
65
 
66
66
 
67
67
  @dataclass(frozen=True, slots=True)
68
68
  class Correction:
69
- """One incorrect edit and the correction that later overwrote it.
69
+ """One incorrect edit and the correction that overwrote it.
70
70
 
71
71
  Attributes:
72
72
  ts_ms: Integer-millisecond timestamp of the incorrect edit.
73
73
  session_id: The Claude session UUID the edit fired in.
74
- source: The writing system, e.g. ``cc-pushback``.
75
- anchor_uuid: The transcript uuid of the feedback the harvest anchored on.
74
+ source: The writing system, e.g. ``cc-pushback``, ``captain-hook``, ``cc-review``.
75
+ anchor_uuid: The transcript uuid of the feedback the harvest anchored on,
76
+ or ``review:<reviewID>:<commentID>`` for a human review correction.
76
77
  incorrect_digest: The cross-language content digest of the incorrect
77
- edit's tool call — the join key shared with ``decisions_v1``.
78
+ edit's tool call — the join key shared with the ``decisions`` ledger.
79
+ None for human review corrections, which join by ``anchor_uuid``.
78
80
  incorrect_file: The file the incorrect edit targeted.
79
81
  incorrect_old: The content the incorrect edit replaced (hunks joined).
80
82
  incorrect_new: The content the incorrect edit wrote (hunks joined).
81
- extractor_version: The deterministic-extraction version that derived the
82
- pair; part of the UNIQUE key.
83
- correction_origin: ``'session'`` or ``'git'``, or None when no
84
- correction was found.
83
+ correction_origin: ``'session'``/``'git'`` for a mined code fix,
84
+ ``'review'`` for a human note, or None when no correction was found.
85
85
  correction_file: The file the correction touched, when one exists.
86
86
  correction_old: The content the correction replaced, when one exists.
87
87
  correction_new: The content the correction wrote, when one exists.
88
88
  correction_commit: The full commit hash, when the correction is a git fix.
89
+ correction_text: The reviewer's verbatim natural-language correction, for
90
+ ``'review'`` rows; None for mined code corrections.
89
91
  overlap: The hunk-overlap score linking incorrect and correction; 0.0
90
92
  when there is no correction.
91
- detail: Structured extras, serialized to ``detail_json``.
93
+ detail: Structured extras (e.g. ``repo``), serialized to ``detail_json``.
92
94
  """
93
95
 
94
96
  ts_ms: int
95
97
  session_id: SessionId
96
98
  source: str
97
99
  anchor_uuid: EventUuid
98
- incorrect_digest: ToolDigest
100
+ incorrect_digest: ToolDigest | None
99
101
  incorrect_file: str
100
102
  incorrect_old: str
101
103
  incorrect_new: str
102
- extractor_version: int
103
104
  correction_origin: Origin | None = None
104
105
  correction_file: str | None = None
105
106
  correction_old: str | None = None
106
107
  correction_new: str | None = None
107
108
  correction_commit: str | None = None
109
+ correction_text: str | None = None
108
110
  overlap: float = 0.0
109
111
  detail: Mapping[str, Any] = field(default_factory=dict)
110
112
 
111
113
 
112
114
  class CorrectionLog:
113
- """The ``corrections_v1`` ledger at ``~/.cc-transcript/corrections.db``.
115
+ """The ``corrections`` ledger at ``~/.cc-transcript/corrections.db``.
114
116
 
115
117
  Opened in WAL mode with a busy timeout because writers across the family
116
- (and cc-review's Go port) touch the same file concurrently. Durable by
117
- convention: rows are never auto-dropped. Requires a local disk — WAL does
118
- not work over NFS.
118
+ touch the same file concurrently. Durable by convention: rows are never
119
+ auto-dropped. Requires a local disk — WAL does not work over NFS.
119
120
 
120
121
  Example:
121
122
  >>> log = CorrectionLog.open()
@@ -150,11 +151,11 @@ class CorrectionLog:
150
151
  """Appends ``correction`` as a single ``INSERT OR IGNORE``.
151
152
 
152
153
  Idempotent on the UNIQUE key ``(session_id, anchor_uuid,
153
- incorrect_digest, extractor_version)`` — re-harvesting the same edit,
154
- across reruns or across the pairs of one event, writes one row.
154
+ incorrect_digest)`` — re-harvesting the same edit, across reruns or
155
+ across the pairs of one event, writes one row.
155
156
  """
156
157
  self.conn.execute(
157
- f"INSERT OR IGNORE INTO corrections_v1 ({CORRECTION_COLUMNS}) "
158
+ f"INSERT OR IGNORE INTO corrections ({CORRECTION_COLUMNS}) "
158
159
  "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
159
160
  (
160
161
  correction.ts_ms,
@@ -170,8 +171,8 @@ class CorrectionLog:
170
171
  correction.correction_old,
171
172
  correction.correction_new,
172
173
  correction.correction_commit,
174
+ correction.correction_text,
173
175
  correction.overlap,
174
- correction.extractor_version,
175
176
  json.dumps(dict(correction.detail)),
176
177
  ),
177
178
  )
@@ -181,16 +182,44 @@ class CorrectionLog:
181
182
  return tuple(
182
183
  correction_of(row)
183
184
  for row in self.conn.execute(
184
- "SELECT * FROM corrections_v1 WHERE session_id = ? ORDER BY ts_ms, id", (session_id,)
185
+ "SELECT * FROM corrections WHERE session_id = ? ORDER BY ts_ms, id", (session_id,)
185
186
  )
186
187
  )
187
188
 
189
+ def for_repo(self, repo: str) -> tuple[Correction, ...]:
190
+ """All corrections whose ``detail.repo`` is ``repo``, ordered by timestamp.
191
+
192
+ The repo key producers stamp into ``detail`` so a per-repo consumer (the
193
+ captain-hook reviewer) can pull every correction for its repo at once.
194
+ """
195
+ return tuple(
196
+ correction_of(row)
197
+ for row in self.conn.execute(
198
+ "SELECT * FROM corrections WHERE json_extract(detail_json, '$.repo') = ? ORDER BY ts_ms, id",
199
+ (repo,),
200
+ )
201
+ )
202
+
203
+ def since(self, ts_ms: int, *, source: str | None = None) -> tuple[Correction, ...]:
204
+ """Corrections with ``ts_ms`` strictly greater than ``ts_ms``, oldest first.
205
+
206
+ A cursor read for incremental consumers; pass ``source`` to scope to one
207
+ producer.
208
+ """
209
+ if source is None:
210
+ rows = self.conn.execute("SELECT * FROM corrections WHERE ts_ms > ? ORDER BY ts_ms, id", (ts_ms,))
211
+ else:
212
+ rows = self.conn.execute(
213
+ "SELECT * FROM corrections WHERE ts_ms > ? AND source = ? ORDER BY ts_ms, id", (ts_ms, source)
214
+ )
215
+ return tuple(correction_of(row) for row in rows)
216
+
188
217
  def for_anchor(self, session_id: SessionId, anchor_uuid: EventUuid) -> tuple[Correction, ...]:
189
218
  """The corrections harvested around one feedback ``anchor_uuid``."""
190
219
  return tuple(
191
220
  correction_of(row)
192
221
  for row in self.conn.execute(
193
- "SELECT * FROM corrections_v1 WHERE session_id = ? AND anchor_uuid = ? ORDER BY ts_ms, id",
222
+ "SELECT * FROM corrections WHERE session_id = ? AND anchor_uuid = ? ORDER BY ts_ms, id",
194
223
  (session_id, anchor_uuid),
195
224
  )
196
225
  )
@@ -199,12 +228,13 @@ class CorrectionLog:
199
228
  """Corrections of the tool call with ``incorrect_digest`` in ``session_id``.
200
229
 
201
230
  The cross-consumer join: pass the ``tool_digest`` a hook recorded in
202
- ``decisions_v1`` to learn whether that exact edit was later corrected.
231
+ the ``decisions`` ledger to learn whether that exact edit was later
232
+ corrected.
203
233
  """
204
234
  return tuple(
205
235
  correction_of(row)
206
236
  for row in self.conn.execute(
207
- "SELECT * FROM corrections_v1 WHERE session_id = ? AND incorrect_digest = ? ORDER BY ts_ms, id",
237
+ "SELECT * FROM corrections WHERE session_id = ? AND incorrect_digest = ? ORDER BY ts_ms, id",
208
238
  (session_id, incorrect_digest),
209
239
  )
210
240
  )
@@ -220,12 +250,12 @@ def correction_of(row: sqlite3.Row) -> Correction:
220
250
  incorrect_file=row["incorrect_file"],
221
251
  incorrect_old=row["incorrect_old"],
222
252
  incorrect_new=row["incorrect_new"],
223
- extractor_version=row["extractor_version"],
224
253
  correction_origin=row["correction_origin"],
225
254
  correction_file=row["correction_file"],
226
255
  correction_old=row["correction_old"],
227
256
  correction_new=row["correction_new"],
228
257
  correction_commit=row["correction_commit"],
258
+ correction_text=row["correction_text"],
229
259
  overlap=row["overlap"],
230
260
  detail=json.loads(row["detail_json"]),
231
261
  )
@@ -0,0 +1,122 @@
1
+ """The ``cc-transcript corrections`` CLI: the ledger's write/read surface for
2
+ non-Python consumers (cc-review's Go) and humans.
3
+
4
+ The ledger's location is owned here — callers never name the database file. Most
5
+ work goes through the structured ``add``/``query`` commands; ``sql`` is the
6
+ escape hatch for the rare ad-hoc statement.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import time
13
+ from dataclasses import asdict
14
+ from typing import cast
15
+
16
+ import click
17
+ import orjson
18
+
19
+ from cc_transcript.corrections import Correction, CorrectionLog, Origin
20
+ from cc_transcript.ids import EventUuid, SessionId, ToolDigest
21
+
22
+
23
+ def emit_rows(rows: tuple[Correction, ...]) -> None:
24
+ for row in rows:
25
+ click.echo(orjson.dumps(asdict(row)))
26
+
27
+
28
+ @click.group()
29
+ def corrections() -> None:
30
+ """Read and write the shared code-correction ledger."""
31
+
32
+
33
+ @corrections.command("add")
34
+ @click.option("--session", required=True, help="The Claude session UUID the edit fired in.")
35
+ @click.option("--source", required=True, help="The writing system, e.g. cc-review.")
36
+ @click.option("--anchor", required=True, help="The feedback anchor uuid, or review:<reviewID>:<commentID>.")
37
+ @click.option("--incorrect-file", required=True, help="The file the incorrect edit targeted.")
38
+ @click.option("--ts-ms", type=int, default=None, help="Edit timestamp in ms [default: now].")
39
+ @click.option("--origin", type=click.Choice(["session", "git", "review"]), default=None)
40
+ @click.option("--incorrect-old", default="", help="Content the incorrect edit replaced.")
41
+ @click.option("--incorrect-new", default="", help="Content the incorrect edit wrote.")
42
+ @click.option("--incorrect-digest", default=None, help="Cross-language tool digest; omit for review rows.")
43
+ @click.option("--correction-file", default=None)
44
+ @click.option("--correction-old", default=None)
45
+ @click.option("--correction-new", default=None)
46
+ @click.option("--correction-commit", default=None)
47
+ @click.option("--correction-text", default=None, help="The reviewer's verbatim note, for review rows.")
48
+ @click.option("--overlap", type=float, default=0.0)
49
+ @click.option("--repo", default=None, help="Repo key, stamped into detail.repo.")
50
+ @click.option("--detail", "detail_json", default=None, help="Extra JSON object merged into detail.")
51
+ def add(
52
+ session: str,
53
+ source: str,
54
+ anchor: str,
55
+ incorrect_file: str,
56
+ ts_ms: int | None,
57
+ origin: str | None,
58
+ incorrect_old: str,
59
+ incorrect_new: str,
60
+ incorrect_digest: str | None,
61
+ correction_file: str | None,
62
+ correction_old: str | None,
63
+ correction_new: str | None,
64
+ correction_commit: str | None,
65
+ correction_text: str | None,
66
+ overlap: float,
67
+ repo: str | None,
68
+ detail_json: str | None,
69
+ ) -> None:
70
+ """Append one correction to the ledger (idempotent)."""
71
+ detail = (json.loads(detail_json) if detail_json else {}) | ({"repo": repo} if repo else {})
72
+ CorrectionLog.open().append(
73
+ Correction(
74
+ ts_ms=ts_ms if ts_ms is not None else int(time.time() * 1000),
75
+ session_id=SessionId(session),
76
+ source=source,
77
+ anchor_uuid=EventUuid(anchor),
78
+ incorrect_digest=ToolDigest(incorrect_digest) if incorrect_digest else None,
79
+ incorrect_file=incorrect_file,
80
+ incorrect_old=incorrect_old,
81
+ incorrect_new=incorrect_new,
82
+ correction_origin=cast("Origin | None", origin),
83
+ correction_file=correction_file,
84
+ correction_old=correction_old,
85
+ correction_new=correction_new,
86
+ correction_commit=correction_commit,
87
+ correction_text=correction_text,
88
+ overlap=overlap,
89
+ detail=detail,
90
+ )
91
+ )
92
+
93
+
94
+ @corrections.command("query")
95
+ @click.option("--session", default=None)
96
+ @click.option("--repo", default=None)
97
+ @click.option("--digest", default=None, help="Requires --session.")
98
+ @click.option("--since", type=int, default=None, help="Corrections with ts_ms greater than this.")
99
+ @click.option("--source", default=None, help="Scopes --since to one producer.")
100
+ def query(session: str | None, repo: str | None, digest: str | None, since: int | None, source: str | None) -> None:
101
+ """Query corrections as one JSON object per line."""
102
+ log = CorrectionLog.open()
103
+ match (session, digest, repo, since):
104
+ case (str(s), str(d), _, _):
105
+ emit_rows(log.by_digest(SessionId(s), incorrect_digest=ToolDigest(d)))
106
+ case (str(s), None, _, _):
107
+ emit_rows(log.for_session(SessionId(s)))
108
+ case (None, _, str(r), _):
109
+ emit_rows(log.for_repo(r))
110
+ case (None, _, None, int(ts)):
111
+ emit_rows(log.since(ts, source=source))
112
+ case _:
113
+ raise click.UsageError("pass one of --session, --repo, or --since (and --digest only with --session)")
114
+
115
+
116
+ @corrections.command("sql")
117
+ @click.argument("statement")
118
+ def sql(statement: str) -> None:
119
+ """Run a raw SQL statement against the ledger — the escape hatch."""
120
+ rows = CorrectionLog.open().conn.execute(statement).fetchall()
121
+ for row in rows:
122
+ click.echo(orjson.dumps(dict(row)))
@@ -28,9 +28,6 @@ if TYPE_CHECKING:
28
28
  from cc_transcript.corrections import CorrectionLog, Origin
29
29
  from cc_transcript.ids import EventRef
30
30
 
31
- EXTRACTOR_VERSION = 1
32
- """The deterministic-extraction version; part of every derived artifact's UNIQUE key."""
33
-
34
31
  GIT_TIMEOUT_S = 15
35
32
 
36
33
 
@@ -208,7 +205,6 @@ def record_harvest(
208
205
  pairs: Sequence[CandidatePair],
209
206
  *,
210
207
  source: str,
211
- extractor_version: int = EXTRACTOR_VERSION,
212
208
  ) -> int:
213
209
  """Records ``pairs`` harvested around ``anchor`` into the shared ledger.
214
210
 
@@ -220,19 +216,13 @@ def record_harvest(
220
216
  Returns:
221
217
  The number of corrections appended this call.
222
218
  """
223
- rows = [
224
- correction
225
- for pair in pairs
226
- if (correction := lower_pair(activity, anchor, pair, source=source, extractor_version=extractor_version))
227
- ]
219
+ rows = [correction for pair in pairs if (correction := lower_pair(activity, anchor, pair, source=source))]
228
220
  for row in rows:
229
221
  log.append(row)
230
222
  return len(rows)
231
223
 
232
224
 
233
- def lower_pair(
234
- activity: SessionActivity, anchor: EventRef, pair: CandidatePair, *, source: str, extractor_version: int
235
- ) -> Correction | None:
225
+ def lower_pair(activity: SessionActivity, anchor: EventRef, pair: CandidatePair, *, source: str) -> Correction | None:
236
226
  if (block := incorrect_block(activity, pair.incorrect)) is None:
237
227
  return None
238
228
  incorrect_old, incorrect_new = joined_hunks(pair.incorrect.hunks)
@@ -246,7 +236,6 @@ def lower_pair(
246
236
  incorrect_file=pair.incorrect.file_path,
247
237
  incorrect_old=incorrect_old,
248
238
  incorrect_new=incorrect_new,
249
- extractor_version=extractor_version,
250
239
  correction_origin=origin,
251
240
  correction_file=correction_file,
252
241
  correction_old=correction_old,
@@ -0,0 +1,23 @@
1
+ """The LLM-grounded correction extractor domain.
2
+
3
+ Layers on :mod:`cc_transcript.evidence` (the deterministic harvest) and
4
+ :mod:`cc_transcript.judge` (the LLM runner) to pick the one edit a piece of
5
+ feedback faults and append it to the shared ledger. Re-exports lazily so
6
+ ``import cc_transcript.extract`` stays free of the ``[llm]`` extra; the symbols
7
+ pull in pydantic only when first accessed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ if TYPE_CHECKING:
15
+ from cc_transcript.extract.correct import CorrectionPick, extract_correction, usable_backend
16
+
17
+ __all__ = ["CorrectionPick", "extract_correction", "usable_backend"]
18
+
19
+
20
+ def __getattr__(name: str) -> Any:
21
+ import importlib
22
+
23
+ return getattr(importlib.import_module(f"{__name__}.correct"), name)
@@ -0,0 +1,181 @@
1
+ """The canonical, LLM-grounded correction extractor.
2
+
3
+ :func:`extract_correction` harvests the candidate incorrect edits around a
4
+ feedback anchor (deterministically, via :mod:`cc_transcript.evidence`), then picks
5
+ the single edit the feedback faults and appends that one row to the shared
6
+ ledger. The pick is an LLM call by default — lifted from cc-pushback's enrich
7
+ prompt — and degrades to the best-overlap candidate when no LLM backend is ready
8
+ (:func:`usable_backend` returns None). The pick keys to the candidate's index, so
9
+ the row carries the real cross-language ``incorrect_digest`` from the harvested
10
+ tool call, never reconstructed content.
11
+
12
+ Behind the ``[llm]`` extra: this module imports pydantic at definition time and
13
+ ``spawnllm`` lazily, so only LLM-capable consumers import it. A hook that merely
14
+ reads :mod:`cc_transcript.corrections` pays nothing.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import replace
20
+ from functools import partial
21
+ from typing import TYPE_CHECKING
22
+
23
+ import anyio
24
+ import anyio.to_thread
25
+ from pydantic import BaseModel
26
+
27
+ from cc_transcript.evidence import GitFix, harvest_pairs, lower_pair
28
+ from cc_transcript.judge.llm import run_structured_on
29
+ from cc_transcript.render import Budget, hunk_lines
30
+
31
+ if TYPE_CHECKING:
32
+ from collections.abc import Sequence
33
+ from pathlib import Path
34
+
35
+ from spawnllm import LlmBackend, TModel
36
+
37
+ from cc_transcript.activity import SessionActivity
38
+ from cc_transcript.corrections import Correction, CorrectionLog
39
+ from cc_transcript.evidence import CandidatePair
40
+ from cc_transcript.ids import EventRef
41
+ from cc_transcript.tools import Hunk
42
+
43
+ HUNK_BUDGET = Budget(tool_chars=600)
44
+
45
+ PICK_PROMPT = """\
46
+ You are grounding one piece of developer FEEDBACK on an AI coding assistant's work
47
+ in the concrete code change it faults.
48
+
49
+ Below are candidate edits the assistant made shortly before the feedback, newest
50
+ first. Each shows the edit's before (-) and after (+) content and, when one was
51
+ found, the correction that later overwrote it — from the same session, or from git
52
+ history (marked "git <sha>"). The correction ranked most likely by content overlap
53
+ is tagged [likely fix].
54
+
55
+ Decide which single candidate edit THIS feedback faults, if any:
56
+ - candidate: the NUMBER of the candidate the feedback is about, or null when the
57
+ feedback is not about any of these edits (it may fault the approach, a command,
58
+ or work outside this window).
59
+ - note: one short clause explaining the call.
60
+
61
+ === CANDIDATE EDITS ===
62
+ {candidates}
63
+ === FEEDBACK (verbatim) ===
64
+ {feedback}"""
65
+
66
+
67
+ class CorrectionPick(BaseModel):
68
+ """The candidate a piece of feedback faults.
69
+
70
+ Attributes:
71
+ candidate: The 1-based number of the faulted candidate, or None when the
72
+ feedback is not about any harvested edit.
73
+ note: One short clause explaining the call.
74
+ """
75
+
76
+ candidate: int | None
77
+ note: str
78
+
79
+
80
+ def hunk_block(hunks: Sequence[Hunk]) -> str:
81
+ return "\n".join(line for hunk in hunks for line in hunk_lines(hunk.old, hunk.new, budget=HUNK_BUDGET))
82
+
83
+
84
+ def likely_fix(pairs: Sequence[CandidatePair]) -> CandidatePair | None:
85
+ best = max(pairs, key=lambda pair: pair.overlap)
86
+ return best if best.correction is not None and best.overlap > 0 else None
87
+
88
+
89
+ def correction_header(pair: CandidatePair, *, likely: bool) -> str:
90
+ match pair.correction:
91
+ case None:
92
+ return "no correction found"
93
+ case GitFix(commit=commit):
94
+ head = f"correction (git {commit}, overlap {pair.overlap:.2f})"
95
+ case correction:
96
+ turns = correction.turn_index - pair.incorrect.turn_index
97
+ head = f"correction (same session, {turns} turn(s) later, overlap {pair.overlap:.2f})"
98
+ return f"{head} [likely fix]:" if likely else f"{head}:"
99
+
100
+
101
+ def candidate_block(index: int, pair: CandidatePair, *, anchor_turn: int, likely: bool) -> str:
102
+ return "\n".join(
103
+ (
104
+ f"--- candidate {index}: {pair.incorrect.file_path} "
105
+ f"({pair.incorrect.tool}, {anchor_turn - pair.incorrect.turn_index} turn(s) before the feedback) ---",
106
+ hunk_block(pair.incorrect.hunks),
107
+ correction_header(pair, likely=likely),
108
+ *(() if pair.correction is None else (hunk_block(pair.correction.hunks),)),
109
+ )
110
+ )
111
+
112
+
113
+ def build_pick_prompt(feedback: str, pairs: Sequence[CandidatePair], *, anchor_turn: int) -> str:
114
+ likely = likely_fix(pairs)
115
+ return PICK_PROMPT.format(
116
+ candidates="\n\n".join(
117
+ candidate_block(index, pair, anchor_turn=anchor_turn, likely=pair is likely)
118
+ for index, pair in enumerate(pairs, 1)
119
+ ),
120
+ feedback=feedback,
121
+ )
122
+
123
+
124
+ def usable_backend() -> LlmBackend | None:
125
+ """The first ready spawnllm backend for the ``review`` specialty, or None when none is ready.
126
+
127
+ Probe once per pass and pass the result to :func:`extract_correction`; the
128
+ status check spawns a subprocess per call.
129
+ """
130
+ from spawnllm import BackendUnavailable, select_backend
131
+
132
+ try:
133
+ return select_backend(specialty="review")
134
+ except BackendUnavailable:
135
+ return None
136
+
137
+
138
+ async def choose_pair(
139
+ pairs: Sequence[CandidatePair], *, feedback: str, anchor_turn: int, tier: TModel, backend: LlmBackend | None
140
+ ) -> CandidatePair | None:
141
+ if backend is None:
142
+ return max(pairs, key=lambda pair: pair.overlap)
143
+ pick = await run_structured_on(
144
+ backend, build_pick_prompt(feedback, pairs, anchor_turn=anchor_turn), response_model=CorrectionPick, tier=tier
145
+ )
146
+ return pairs[pick.candidate - 1] if pick.candidate is not None and 1 <= pick.candidate <= len(pairs) else None
147
+
148
+
149
+ async def extract_correction(
150
+ log: CorrectionLog,
151
+ activity: SessionActivity,
152
+ anchor: EventRef,
153
+ *,
154
+ source: str,
155
+ feedback: str,
156
+ repo: Path | None = None,
157
+ tier: TModel = "medium",
158
+ backend: LlmBackend | None = None,
159
+ ) -> Correction | None:
160
+ """Harvests around ``anchor`` and appends the one correction ``feedback`` faults.
161
+
162
+ The faulted candidate is the LLM's pick when ``backend`` is given, else the
163
+ best-overlap candidate. Idempotent per anchor: a no-op when this anchor
164
+ already has a row, so re-runs and overlapping producers never duplicate.
165
+ Returns the appended correction, or None when nothing is harvested or picked.
166
+ """
167
+ if log.for_anchor(anchor.session_id, anchor.event_uuid):
168
+ return None
169
+ pairs = await anyio.to_thread.run_sync(partial(harvest_pairs, activity, anchor, repo=repo))
170
+ if not pairs:
171
+ return None
172
+ turn = activity.turn_of(anchor)
173
+ pick = await choose_pair(
174
+ pairs, feedback=feedback, anchor_turn=turn.index if turn else 0, tier=tier, backend=backend
175
+ )
176
+ if pick is None or (row := lower_pair(activity, anchor, pick, source=source)) is None:
177
+ return None
178
+ if repo is not None:
179
+ row = replace(row, detail={"repo": str(repo)})
180
+ log.append(row)
181
+ return row
@@ -12,7 +12,7 @@ prompts, the verdict model, and any SQL views over the verdict table.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
- from cc_transcript.judge.llm import resolved_model, run_structured, structured_judge
15
+ from cc_transcript.judge.llm import resolved_model, run_structured, run_structured_on, structured_judge
16
16
  from cc_transcript.judge.verdicts import (
17
17
  AuditEstimate,
18
18
  AuditSample,
@@ -0,0 +1,106 @@
1
+ """Headless structured completions via spawnllm's first ready CLI backend.
2
+
3
+ Argv construction and envelope parsing come from the shared ``spawnllm`` library;
4
+ the spawn stays local (``anyio.run_process``). The backend is whichever of
5
+ spawnllm's CLIs is installed and authenticated (:func:`spawnllm.select_backend`),
6
+ using the caller's existing CLI auth — no API key, no pinned provider.
7
+ ``spawnllm`` and ``pydantic`` load lazily inside each function, so importing the
8
+ judge package needs no extra installed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import subprocess
15
+ from functools import cache
16
+ from typing import TYPE_CHECKING, cast
17
+
18
+ import anyio
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Awaitable, Callable
22
+
23
+ from pydantic import BaseModel
24
+ from spawnllm import LlmBackend, TModel
25
+
26
+ LLM_TIMEOUT = 180
27
+
28
+
29
+ @cache
30
+ def default_backend() -> LlmBackend:
31
+ """The first installed, authenticated spawnllm backend, cached per process.
32
+
33
+ Raises:
34
+ spawnllm.BackendUnavailable: When no backend is installed and authenticated.
35
+ """
36
+ from spawnllm import select_backend
37
+
38
+ return select_backend()
39
+
40
+
41
+ def resolved_model(tier: TModel) -> str:
42
+ """The concrete model name the active backend runs for an abstract tier.
43
+
44
+ The verdict store keys on this string; it reflects whichever backend
45
+ :func:`spawnllm.select_backend` resolves, so a judged corpus stays coherent
46
+ within one backend environment.
47
+ """
48
+ return default_backend().models[tier]
49
+
50
+
51
+ async def run_structured_on[M: BaseModel](
52
+ backend: LlmBackend, prompt: str, *, response_model: type[M], tier: TModel, timeout: int = LLM_TIMEOUT
53
+ ) -> M:
54
+ """Runs one structured completion on an explicit ``backend``.
55
+
56
+ A caller that resolves a backend once per pass passes it here, so a large pass
57
+ probes auth once; :func:`run_structured` is the select-and-run convenience.
58
+
59
+ Raises:
60
+ subprocess.SubprocessError: If the backend CLI exits non-zero or times out.
61
+ pydantic.ValidationError: If the response does not match the schema.
62
+ """
63
+ from spawnllm import resolve_schema_path, schema_for
64
+
65
+ argv, stdin = backend.invocation(
66
+ prompt,
67
+ model=backend.models[tier],
68
+ schema_path=resolve_schema_path(backend, schema_for(response_model)),
69
+ agent=False,
70
+ )
71
+ try:
72
+ with anyio.fail_after(timeout):
73
+ result = await anyio.run_process(
74
+ argv, input=stdin.encode() if stdin is not None else None, check=True, env=os.environ | backend.env()
75
+ )
76
+ except TimeoutError as exc:
77
+ raise subprocess.TimeoutExpired(argv, timeout) from exc
78
+ return cast(response_model, backend.parse_response(result.stdout.decode(), response_model))
79
+
80
+
81
+ async def run_structured[M: BaseModel](
82
+ prompt: str, *, response_model: type[M], tier: TModel, timeout: int = LLM_TIMEOUT
83
+ ) -> M:
84
+ """Runs one structured completion on the first ready backend.
85
+
86
+ The prompt is delivered to the CLI and the response forced into
87
+ ``response_model``'s JSON schema. Backend selection is cached per process.
88
+
89
+ Raises:
90
+ spawnllm.BackendUnavailable: If no backend is installed and authenticated.
91
+ subprocess.SubprocessError: If the backend CLI exits non-zero or times out.
92
+ pydantic.ValidationError: If the response does not match the schema.
93
+ """
94
+ return await run_structured_on(default_backend(), prompt, response_model=response_model, tier=tier, timeout=timeout)
95
+
96
+
97
+ def structured_judge[M: BaseModel](
98
+ response_model: type[M], *, tier: TModel, timeout: int = LLM_TIMEOUT
99
+ ) -> Callable[[str], Awaitable[M]]:
100
+ """Returns a prompt-to-verdict callable that plugs into :func:`run_verdicts`.
101
+
102
+ Example:
103
+ >>> judge = structured_judge(Verdict, tier="medium")
104
+ >>> await run_verdicts(rows, prompt_for, judge, persist, concurrency=8)
105
+ """
106
+ return lambda prompt: run_structured(prompt, response_model=response_model, tier=tier, timeout=timeout)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cc-transcript"
3
- version = "3.3.0"
3
+ version = "4.0.0"
4
4
  description = "The Claude Code session-activity platform: a superset JSONL parser (Rust fast path, Python reference), typed tool calls with a cross-language content digest, turns/edits/evidence over one spine, context windows with labeled fidelity, unified decision and correction ledgers, LLM judging, and a transcript-investigation CLI."
5
5
  readme = "README.md"
6
6
  license = "PolyForm-Noncommercial-1.0.0"
@@ -46,7 +46,7 @@ sentiment = [
46
46
  "spacy>=3.8",
47
47
  "afinn>=0.1",
48
48
  ]
49
- llm = ["spawnllm>=0.1.3"]
49
+ llm = ["spawnllm>=0.2.0"]
50
50
 
51
51
  [project.scripts]
52
52
  cc-transcript = "cc_transcript.cli:cli"
@@ -1,91 +0,0 @@
1
- """Headless structured completions via the ``claude`` CLI, behind the ``[llm]`` extra.
2
-
3
- Argv construction and envelope parsing come from the shared ``spawnllm`` library;
4
- the spawn stays local (``anyio.run_process``). It uses the user's existing Claude
5
- Code auth (no API key). ``spawnllm`` and ``pydantic`` load lazily inside each
6
- function, so importing the judge package needs no extra installed.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import os
12
- import subprocess
13
- from typing import TYPE_CHECKING
14
-
15
- import anyio
16
-
17
- if TYPE_CHECKING:
18
- from collections.abc import Awaitable, Callable
19
-
20
- from pydantic import BaseModel
21
- from spawnllm import TModel
22
-
23
- CLAUDE_TIMEOUT = 180
24
-
25
-
26
- def resolved_model(tier: TModel) -> str:
27
- """Returns the concrete Claude model name for an abstract tier.
28
-
29
- A verdict store's unique key includes the model string, so the resolution
30
- must stay byte-identical across releases for a judged corpus to stay valid.
31
- """
32
- from spawnllm import ClaudeCliBackend
33
-
34
- return ClaudeCliBackend.models[tier]
35
-
36
-
37
- async def run_structured[M: BaseModel](
38
- prompt: str, *, response_model: type[M], tier: TModel, timeout: int = CLAUDE_TIMEOUT
39
- ) -> M:
40
- """Runs one headless ``claude`` turn and parses its structured output.
41
-
42
- The prompt is delivered over stdin and the response is forced into
43
- ``response_model``'s JSON schema via the CLI's ``--json-schema`` flag. The
44
- structured path runs with an empty system prompt, so all instructions must
45
- live in ``prompt``.
46
-
47
- Args:
48
- prompt: The full prompt, instructions included.
49
- response_model: The pydantic model the response must validate against.
50
- tier: The abstract model tier to run, resolved by the Claude backend.
51
- timeout: The per-call wall-clock budget in seconds.
52
-
53
- Returns:
54
- The validated ``response_model`` instance.
55
-
56
- Raises:
57
- subprocess.SubprocessError: If ``claude`` exits non-zero or times out.
58
- pydantic.ValidationError: If the response does not match the schema.
59
- """
60
- from spawnllm import ClaudeCliBackend, parse_structured_output, resolve_schema_path, schema_for
61
-
62
- backend = ClaudeCliBackend()
63
- argv = backend.build_command(
64
- backend.models[tier], resolve_schema_path(backend, schema_for(response_model)), agent=False
65
- )
66
- try:
67
- with anyio.fail_after(timeout):
68
- result = await anyio.run_process(argv, input=prompt.encode(), check=True, env=os.environ | backend.env())
69
- except TimeoutError as exc:
70
- raise subprocess.TimeoutExpired(argv, timeout) from exc
71
- return parse_structured_output(result.stdout.decode(), response_model)
72
-
73
-
74
- def structured_judge[M: BaseModel](
75
- response_model: type[M], *, tier: TModel, timeout: int = CLAUDE_TIMEOUT
76
- ) -> Callable[[str], Awaitable[M]]:
77
- """Returns a prompt-to-verdict callable that plugs into :func:`run_verdicts`.
78
-
79
- Args:
80
- response_model: The pydantic model each response must validate against.
81
- tier: The abstract model tier to run.
82
- timeout: The per-call wall-clock budget in seconds.
83
-
84
- Returns:
85
- A callable awaiting one structured completion per prompt.
86
-
87
- Example:
88
- >>> judge = structured_judge(Verdict, tier="medium")
89
- >>> await run_verdicts(rows, prompt_for, judge, persist, concurrency=8)
90
- """
91
- return lambda prompt: run_structured(prompt, response_model=response_model, tier=tier, timeout=timeout)
File without changes
File without changes
File without changes
File without changes