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.
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/PKG-INFO +2 -2
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/__init__.py +0 -4
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/cli.py +8 -4
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/corrections.py +70 -40
- cc_transcript-4.0.0/cc_transcript/corrections_cli.py +122 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/evidence.py +2 -13
- cc_transcript-4.0.0/cc_transcript/extract/__init__.py +23 -0
- cc_transcript-4.0.0/cc_transcript/extract/correct.py +181 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/judge/__init__.py +1 -1
- cc_transcript-4.0.0/cc_transcript/judge/llm.py +106 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/pyproject.toml +2 -2
- cc_transcript-3.3.0/cc_transcript/judge/llm.py +0 -91
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/Cargo.lock +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/Cargo.toml +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/LICENSE +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/README.md +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/__main__.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/_parser_rs.pyi +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/activity.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/backend.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/builders.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/context.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/decisions.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/discovery.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/disktruth.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/filterspec.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/ids.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/judge/verdicts.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/messages.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/mining/__init__.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/mining/candidates.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/mining/confidence.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/mining/filterspec.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/mining/formats.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/mining/signals.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/mining/sourcekind.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/mining/store.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/models.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/parser.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/py.typed +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/query.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/render.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/rust.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/sentiment/__init__.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/sentiment/buckets.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/sentiment/engine.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/sentiment/lexicon.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/sentiment/scorespec.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/store.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/cc_transcript/tools.py +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/Cargo.toml +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/data/afinn-en-165.tsv +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/data/domain_overrides.tsv +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/src/event.rs +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/src/filter.rs +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/src/lexicon.rs +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/src/lib.rs +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/src/model.rs +0 -0
- {cc_transcript-3.3.0 → cc_transcript-4.0.0}/rust/src/score.rs +0 -0
- {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
|
+
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.
|
|
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(
|
|
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 (``
|
|
4
|
-
pushed back on paired with the correction that
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
the
|
|
8
|
-
|
|
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
|
|
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
|
|
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
|
|
50
|
+
UNIQUE (session_id, anchor_uuid, incorrect_digest)
|
|
51
51
|
);
|
|
52
52
|
|
|
53
|
-
CREATE INDEX IF NOT EXISTS
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_corrections_session_ts ON corrections (session_id, ts_ms);
|
|
54
54
|
|
|
55
|
-
CREATE INDEX IF NOT EXISTS
|
|
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,
|
|
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
|
|
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 ``
|
|
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
|
-
|
|
82
|
-
|
|
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 ``
|
|
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
|
-
|
|
117
|
-
|
|
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
|
|
154
|
-
across
|
|
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
|
|
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
|
|
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
|
|
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
|
-
``
|
|
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
|
|
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
|
+
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|