cc-transcript 2.0.0__tar.gz → 3.0.1__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-2.0.0 → cc_transcript-3.0.1}/PKG-INFO +4 -7
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/README.md +2 -3
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/__init__.py +25 -9
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/activity.py +2 -2
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/context.py +13 -23
- cc_transcript-3.0.1/cc_transcript/corrections.py +231 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/evidence.py +93 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/filterspec.py +1 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/mining/__init__.py +0 -1
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/mining/candidates.py +4 -5
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/mining/confidence.py +1 -8
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/mining/filterspec.py +5 -17
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/mining/signals.py +3 -4
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/mining/store.py +3 -4
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/models.py +1 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/tools.py +5 -4
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/pyproject.toml +2 -4
- cc_transcript-2.0.0/cc_transcript/filters.py +0 -109
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/Cargo.lock +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/Cargo.toml +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/LICENSE +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/__main__.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/_parser_rs.pyi +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/backend.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/builders.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/cli.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/decisions.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/discovery.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/disktruth.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/ids.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/judge/__init__.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/judge/llm.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/judge/verdicts.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/messages.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/mining/formats.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/mining/sourcekind.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/parser.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/py.typed +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/query.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/render.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/rust.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/sentiment/__init__.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/sentiment/buckets.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/sentiment/engine.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/sentiment/lexicon.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/sentiment/scorespec.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/cc_transcript/store.py +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/rust/Cargo.toml +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/rust/data/afinn-en-165.tsv +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/rust/data/domain_overrides.tsv +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/rust/src/event.rs +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/rust/src/filter.rs +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/rust/src/lexicon.rs +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/rust/src/lib.rs +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/rust/src/model.rs +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/rust/src/score.rs +0 -0
- {cc_transcript-2.0.0 → cc_transcript-3.0.1}/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: 3.0.1
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Environment :: Console
|
|
6
6
|
Classifier: Intended Audience :: Developers
|
|
@@ -16,16 +16,14 @@ 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: cc-transcript[sentiment] ; extra == 'lexicon'
|
|
20
19
|
Requires-Dist: spawnllm>=0.1.3 ; extra == 'llm'
|
|
21
20
|
Requires-Dist: spacy>=3.8 ; extra == 'sentiment'
|
|
22
21
|
Requires-Dist: afinn>=0.1 ; extra == 'sentiment'
|
|
23
22
|
Provides-Extra: dev
|
|
24
|
-
Provides-Extra: lexicon
|
|
25
23
|
Provides-Extra: llm
|
|
26
24
|
Provides-Extra: sentiment
|
|
27
25
|
License-File: LICENSE
|
|
28
|
-
Summary: 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,
|
|
26
|
+
Summary: 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.
|
|
29
27
|
Keywords: claude-code,claude,anthropic,transcripts,jsonl,parser,cli,agents,sentiment,feedback-mining
|
|
30
28
|
Author-email: Yasyf Mohamedali <yasyfm@gmail.com>
|
|
31
29
|
License-Expression: PolyForm-Noncommercial-1.0.0
|
|
@@ -88,8 +86,7 @@ clean = list(apply_spec(events, spec))
|
|
|
88
86
|
```
|
|
89
87
|
|
|
90
88
|
`NOISE_SPEC` is a ready-made spec for the universal structural noise (system reminders,
|
|
91
|
-
local-command output, skill banners).
|
|
92
|
-
available — every rule is off by default, so a bare `FilterConfig()` passes everything through.
|
|
89
|
+
local-command output, skill banners).
|
|
93
90
|
|
|
94
91
|
## The CLI
|
|
95
92
|
|
|
@@ -161,7 +158,7 @@ The plugin's skill teaches Claude to answer questions about its own history —
|
|
|
161
158
|
## What problems does this solve?
|
|
162
159
|
|
|
163
160
|
- **One faithful parse.** Anything reading Claude Code transcripts re-implements the same JSONL quirks (str-or-list content, tool results nested two ways, envelope-less mode markers). This is that parser, written once and typed strictly.
|
|
164
|
-
- **Non-lossy by design.** The event model is a superset: sidechains, `<synthetic>` turns, thinking blocks, and unrecognized entry types all survive parsing. You decide what to drop, via composable filter specs (`build_spec`)
|
|
161
|
+
- **Non-lossy by design.** The event model is a superset: sidechains, `<synthetic>` turns, thinking blocks, and unrecognized entry types all survive parsing. You decide what to drop, via composable filter specs (`build_spec`).
|
|
165
162
|
- **Incremental ingestion.** `FileStateStore` tracks per-file mtimes in SQLite (WAL, safe across concurrent tasks) so re-runs only reparse changed files, and you compose your own writes in the same transaction.
|
|
166
163
|
- **Two engines, one contract.** A single `Backend` protocol with two implementations: `RustBackend` (PyO3 + rayon) is the default fast path, and `PythonBackend` is the readable reference — parity-asserted against each other. Filter specs are portable, so a spec built in Python runs Rust-side without giving up the fast path.
|
|
167
164
|
- **One activity spine.** `SessionActivity` lifts parsed events into turns, typed tool calls, and first-class edits; `ContextWindow` persists `EventRef`s plus labeled previews and hydrates back to full fidelity while the transcript lives; rendering is `Budget`-bounded in exactly one place.
|
|
@@ -49,8 +49,7 @@ clean = list(apply_spec(events, spec))
|
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
`NOISE_SPEC` is a ready-made spec for the universal structural noise (system reminders,
|
|
52
|
-
local-command output, skill banners).
|
|
53
|
-
available — every rule is off by default, so a bare `FilterConfig()` passes everything through.
|
|
52
|
+
local-command output, skill banners).
|
|
54
53
|
|
|
55
54
|
## The CLI
|
|
56
55
|
|
|
@@ -122,7 +121,7 @@ The plugin's skill teaches Claude to answer questions about its own history —
|
|
|
122
121
|
## What problems does this solve?
|
|
123
122
|
|
|
124
123
|
- **One faithful parse.** Anything reading Claude Code transcripts re-implements the same JSONL quirks (str-or-list content, tool results nested two ways, envelope-less mode markers). This is that parser, written once and typed strictly.
|
|
125
|
-
- **Non-lossy by design.** The event model is a superset: sidechains, `<synthetic>` turns, thinking blocks, and unrecognized entry types all survive parsing. You decide what to drop, via composable filter specs (`build_spec`)
|
|
124
|
+
- **Non-lossy by design.** The event model is a superset: sidechains, `<synthetic>` turns, thinking blocks, and unrecognized entry types all survive parsing. You decide what to drop, via composable filter specs (`build_spec`).
|
|
126
125
|
- **Incremental ingestion.** `FileStateStore` tracks per-file mtimes in SQLite (WAL, safe across concurrent tasks) so re-runs only reparse changed files, and you compose your own writes in the same transaction.
|
|
127
126
|
- **Two engines, one contract.** A single `Backend` protocol with two implementations: `RustBackend` (PyO3 + rayon) is the default fast path, and `PythonBackend` is the readable reference — parity-asserted against each other. Filter specs are portable, so a spec built in Python runs Rust-side without giving up the fast path.
|
|
128
127
|
- **One activity spine.** `SessionActivity` lifts parsed events into turns, typed tool calls, and first-class edits; `ContextWindow` persists `EventRef`s plus labeled previews and hydrates back to full fidelity while the transcript lives; rendering is `Budget`-bounded in exactly one place.
|
|
@@ -101,6 +101,13 @@ EXPORTS: dict[str, str] = {
|
|
|
101
101
|
"git_corrections",
|
|
102
102
|
"harvest_pairs",
|
|
103
103
|
"match_corrections",
|
|
104
|
+
"record_harvest",
|
|
105
|
+
),
|
|
106
|
+
"cc_transcript.corrections": (
|
|
107
|
+
"CORRECTIONS_DDL",
|
|
108
|
+
"Correction",
|
|
109
|
+
"CorrectionLog",
|
|
110
|
+
"Origin",
|
|
104
111
|
),
|
|
105
112
|
"cc_transcript.context": (
|
|
106
113
|
"ContextWindow",
|
|
@@ -121,10 +128,10 @@ EXPORTS: dict[str, str] = {
|
|
|
121
128
|
"export_activity",
|
|
122
129
|
"load_export",
|
|
123
130
|
),
|
|
124
|
-
"cc_transcript.filters": ("JUNK_USER_MESSAGE_RE", "FilterConfig", "apply_filters"),
|
|
125
131
|
"cc_transcript.filterspec": (
|
|
126
132
|
"ASSISTANTS",
|
|
127
133
|
"INTERRUPT_MARKER_RE",
|
|
134
|
+
"JUNK_USER_MESSAGE_RE",
|
|
128
135
|
"RESUME_PHRASE_SET",
|
|
129
136
|
"STOP_HOOK_RE",
|
|
130
137
|
"STRUCTURAL_NOISE_RE",
|
|
@@ -251,6 +258,18 @@ if TYPE_CHECKING:
|
|
|
251
258
|
from cc_transcript.context import (
|
|
252
259
|
capture_window as capture_window,
|
|
253
260
|
)
|
|
261
|
+
from cc_transcript.corrections import (
|
|
262
|
+
CORRECTIONS_DDL as CORRECTIONS_DDL,
|
|
263
|
+
)
|
|
264
|
+
from cc_transcript.corrections import (
|
|
265
|
+
Correction as Correction,
|
|
266
|
+
)
|
|
267
|
+
from cc_transcript.corrections import (
|
|
268
|
+
CorrectionLog as CorrectionLog,
|
|
269
|
+
)
|
|
270
|
+
from cc_transcript.corrections import (
|
|
271
|
+
Origin as Origin,
|
|
272
|
+
)
|
|
254
273
|
from cc_transcript.decisions import (
|
|
255
274
|
DECISIONS_DDL as DECISIONS_DDL,
|
|
256
275
|
)
|
|
@@ -317,14 +336,8 @@ if TYPE_CHECKING:
|
|
|
317
336
|
from cc_transcript.evidence import (
|
|
318
337
|
match_corrections as match_corrections,
|
|
319
338
|
)
|
|
320
|
-
from cc_transcript.
|
|
321
|
-
|
|
322
|
-
)
|
|
323
|
-
from cc_transcript.filters import (
|
|
324
|
-
FilterConfig as FilterConfig,
|
|
325
|
-
)
|
|
326
|
-
from cc_transcript.filters import (
|
|
327
|
-
apply_filters as apply_filters,
|
|
339
|
+
from cc_transcript.evidence import (
|
|
340
|
+
record_harvest as record_harvest,
|
|
328
341
|
)
|
|
329
342
|
from cc_transcript.filterspec import (
|
|
330
343
|
ASSISTANTS as ASSISTANTS,
|
|
@@ -332,6 +345,9 @@ if TYPE_CHECKING:
|
|
|
332
345
|
from cc_transcript.filterspec import (
|
|
333
346
|
INTERRUPT_MARKER_RE as INTERRUPT_MARKER_RE,
|
|
334
347
|
)
|
|
348
|
+
from cc_transcript.filterspec import (
|
|
349
|
+
JUNK_USER_MESSAGE_RE as JUNK_USER_MESSAGE_RE,
|
|
350
|
+
)
|
|
335
351
|
from cc_transcript.filterspec import (
|
|
336
352
|
RESUME_PHRASE_SET as RESUME_PHRASE_SET,
|
|
337
353
|
)
|
|
@@ -20,7 +20,7 @@ from cc_transcript.discovery import TranscriptDiscovery, TranscriptExpiredError,
|
|
|
20
20
|
from cc_transcript.ids import EventRef
|
|
21
21
|
from cc_transcript.models import AssistantEvent, SystemEvent, ToolResultBlock, ToolUseBlock, UserEvent
|
|
22
22
|
from cc_transcript.parser import TranscriptParser
|
|
23
|
-
from cc_transcript.tools import file_path_of, hunks_of
|
|
23
|
+
from cc_transcript.tools import file_path_of, hunks_of, parse_tool_call
|
|
24
24
|
|
|
25
25
|
if TYPE_CHECKING:
|
|
26
26
|
from collections.abc import Mapping, Sequence
|
|
@@ -299,7 +299,7 @@ def lift_turn(
|
|
|
299
299
|
tool_uses=tuple(
|
|
300
300
|
ToolUse(
|
|
301
301
|
ref=EventRef(session_id, event.meta.uuid, block.id),
|
|
302
|
-
call=block.
|
|
302
|
+
call=parse_tool_call(block.name, block.input, on_error="other"),
|
|
303
303
|
result=results.get(block.id),
|
|
304
304
|
turn_index=index,
|
|
305
305
|
ts=event.meta.timestamp,
|
|
@@ -24,11 +24,10 @@ if TYPE_CHECKING:
|
|
|
24
24
|
|
|
25
25
|
from cc_transcript.activity import Turn
|
|
26
26
|
|
|
27
|
-
SCHEMA = "cc-transcript.context/
|
|
27
|
+
SCHEMA = "cc-transcript.context/2"
|
|
28
28
|
SUMMARY_LABEL = "[summary fidelity — transcript unavailable]"
|
|
29
29
|
|
|
30
30
|
type Fidelity = Literal["full", "summary"]
|
|
31
|
-
type Origin = Literal["live", "migrated"]
|
|
32
31
|
type Role = Literal["user", "assistant"]
|
|
33
32
|
|
|
34
33
|
|
|
@@ -43,8 +42,7 @@ class TurnRef:
|
|
|
43
42
|
Attributes:
|
|
44
43
|
role: Whether a user prompt opened the turn or it is assistant-only
|
|
45
44
|
preamble.
|
|
46
|
-
refs: References to the turn's events
|
|
47
|
-
legacy rows.
|
|
45
|
+
refs: References to the turn's events.
|
|
48
46
|
preview: The turn rendered at capture time, at the preview budget.
|
|
49
47
|
tool_digests: Content digests of the turn's tool calls, in order.
|
|
50
48
|
"""
|
|
@@ -60,16 +58,14 @@ class ContextWindow:
|
|
|
60
58
|
"""The turns around an anchor event, persisted as refs plus previews.
|
|
61
59
|
|
|
62
60
|
Attributes:
|
|
63
|
-
anchor: The event the window centers on
|
|
64
|
-
rows, which can never hydrate.
|
|
61
|
+
anchor: The event the window centers on.
|
|
65
62
|
before: Turns preceding the anchor's turn, oldest first.
|
|
66
|
-
trigger: The anchor's own turn
|
|
63
|
+
trigger: The anchor's own turn, or None when the window carries no
|
|
64
|
+
trigger preview.
|
|
67
65
|
after: Turns following the anchor's turn, oldest first.
|
|
68
66
|
fidelity: ``'full'`` while the transcript backs the window;
|
|
69
67
|
``'summary'`` once only the previews remain.
|
|
70
68
|
preview_chars: The preview budget the window was captured at.
|
|
71
|
-
origin: ``'live'`` for captured windows, ``'migrated'`` for rows
|
|
72
|
-
converted from legacy stores.
|
|
73
69
|
|
|
74
70
|
Example:
|
|
75
71
|
>>> window = capture_window(activity, anchor)
|
|
@@ -77,13 +73,12 @@ class ContextWindow:
|
|
|
77
73
|
>>> text = hydrated.render(budget=Budget()) if hydrated else window.render_preview(budget=Budget())
|
|
78
74
|
"""
|
|
79
75
|
|
|
80
|
-
anchor: EventRef
|
|
76
|
+
anchor: EventRef
|
|
81
77
|
before: tuple[TurnRef, ...]
|
|
82
78
|
trigger: TurnRef | None
|
|
83
79
|
after: tuple[TurnRef, ...]
|
|
84
80
|
fidelity: Fidelity
|
|
85
81
|
preview_chars: int
|
|
86
|
-
origin: Origin
|
|
87
82
|
|
|
88
83
|
def render_preview(self, *, budget: Budget) -> str:
|
|
89
84
|
"""Render the persisted previews, never touching the transcript.
|
|
@@ -102,12 +97,10 @@ class ContextWindow:
|
|
|
102
97
|
"""Resolve every ref back to real turns for full-fidelity rendering.
|
|
103
98
|
|
|
104
99
|
Returns:
|
|
105
|
-
None when the
|
|
106
|
-
|
|
107
|
-
|
|
100
|
+
None when the transcript has expired or any ref was compacted
|
|
101
|
+
away — callers fall back to :meth:`render_preview`, never
|
|
102
|
+
hydrate-or-fail.
|
|
108
103
|
"""
|
|
109
|
-
if self.anchor is None:
|
|
110
|
-
return None
|
|
111
104
|
try:
|
|
112
105
|
activity = await SessionActivity.from_session(self.anchor.session_id)
|
|
113
106
|
except TranscriptExpiredError:
|
|
@@ -121,17 +114,16 @@ class ContextWindow:
|
|
|
121
114
|
return HydratedWindow(window=self, turns=tuple(turns))
|
|
122
115
|
|
|
123
116
|
def to_json(self) -> str:
|
|
124
|
-
"""Serialize to the ``cc-transcript.context/
|
|
117
|
+
"""Serialize to the ``cc-transcript.context/2`` wire schema, byte-stably."""
|
|
125
118
|
return json.dumps(
|
|
126
119
|
{
|
|
127
120
|
"schema": SCHEMA,
|
|
128
|
-
"anchor":
|
|
121
|
+
"anchor": ref_payload(self.anchor),
|
|
129
122
|
"before": [turn_ref_payload(ref) for ref in self.before],
|
|
130
123
|
"trigger": None if self.trigger is None else turn_ref_payload(self.trigger),
|
|
131
124
|
"after": [turn_ref_payload(ref) for ref in self.after],
|
|
132
125
|
"fidelity": self.fidelity,
|
|
133
126
|
"preview_chars": self.preview_chars,
|
|
134
|
-
"origin": self.origin,
|
|
135
127
|
},
|
|
136
128
|
ensure_ascii=False,
|
|
137
129
|
separators=(",", ":"),
|
|
@@ -144,19 +136,18 @@ class ContextWindow:
|
|
|
144
136
|
|
|
145
137
|
Raises:
|
|
146
138
|
SchemaError: When ``data`` does not carry the literal
|
|
147
|
-
``cc-transcript.context/
|
|
139
|
+
``cc-transcript.context/2`` schema.
|
|
148
140
|
"""
|
|
149
141
|
payload = json.loads(data)
|
|
150
142
|
if not isinstance(payload, dict) or payload.get("schema") != SCHEMA:
|
|
151
143
|
raise SchemaError(f"expected schema {SCHEMA!r}, got: {data[:120]}")
|
|
152
144
|
return cls(
|
|
153
|
-
anchor=
|
|
145
|
+
anchor=ref_from(payload["anchor"]),
|
|
154
146
|
before=tuple(turn_ref_from(item) for item in payload["before"]),
|
|
155
147
|
trigger=None if payload["trigger"] is None else turn_ref_from(payload["trigger"]),
|
|
156
148
|
after=tuple(turn_ref_from(item) for item in payload["after"]),
|
|
157
149
|
fidelity=payload["fidelity"],
|
|
158
150
|
preview_chars=payload["preview_chars"],
|
|
159
|
-
origin=payload["origin"],
|
|
160
151
|
)
|
|
161
152
|
|
|
162
153
|
|
|
@@ -213,7 +204,6 @@ def capture_window(
|
|
|
213
204
|
after=tuple(turn_ref(turn, budget) for turn in activity.turns[trigger.index + 1 : trigger.index + 1 + after]),
|
|
214
205
|
fidelity="full",
|
|
215
206
|
preview_chars=preview_chars,
|
|
216
|
-
origin="live",
|
|
217
207
|
)
|
|
218
208
|
|
|
219
209
|
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""The shared code-correction ledger every consumer reads and writes.
|
|
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.
|
|
9
|
+
|
|
10
|
+
Import-light by contract, like :mod:`cc_transcript.ids` and
|
|
11
|
+
:mod:`cc_transcript.decisions`: the standard library plus identity primitives
|
|
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`,
|
|
14
|
+
which is allowed the heavier activity imports.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sqlite3
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING, Literal, Self
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from cc_transcript.ids import EventUuid, SessionId, ToolDigest
|
|
30
|
+
|
|
31
|
+
CORRECTIONS_DDL = """\
|
|
32
|
+
CREATE TABLE IF NOT EXISTS corrections_v1 (
|
|
33
|
+
id INTEGER PRIMARY KEY,
|
|
34
|
+
ts_ms INTEGER NOT NULL,
|
|
35
|
+
session_id TEXT NOT NULL,
|
|
36
|
+
source TEXT NOT NULL,
|
|
37
|
+
anchor_uuid TEXT NOT NULL,
|
|
38
|
+
incorrect_digest TEXT NOT NULL,
|
|
39
|
+
incorrect_file TEXT NOT NULL,
|
|
40
|
+
incorrect_old TEXT NOT NULL,
|
|
41
|
+
incorrect_new TEXT NOT NULL,
|
|
42
|
+
correction_origin TEXT CHECK (correction_origin IN ('session', 'git')),
|
|
43
|
+
correction_file TEXT,
|
|
44
|
+
correction_old TEXT,
|
|
45
|
+
correction_new TEXT,
|
|
46
|
+
correction_commit TEXT,
|
|
47
|
+
overlap REAL NOT NULL DEFAULT 0,
|
|
48
|
+
extractor_version INTEGER NOT NULL,
|
|
49
|
+
detail_json TEXT NOT NULL DEFAULT '{}',
|
|
50
|
+
UNIQUE (session_id, anchor_uuid, incorrect_digest, extractor_version)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_corrections_v1_session_ts ON corrections_v1 (session_id, ts_ms);
|
|
54
|
+
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_corrections_v1_incorrect_digest ON corrections_v1 (incorrect_digest);
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
Origin = Literal["session", "git"]
|
|
59
|
+
|
|
60
|
+
CORRECTION_COLUMNS = (
|
|
61
|
+
"ts_ms, session_id, source, anchor_uuid, incorrect_digest, incorrect_file, incorrect_old, "
|
|
62
|
+
"incorrect_new, correction_origin, correction_file, correction_old, correction_new, "
|
|
63
|
+
"correction_commit, overlap, extractor_version, detail_json"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class Correction:
|
|
69
|
+
"""One incorrect edit and the correction that later overwrote it.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
ts_ms: Integer-millisecond timestamp of the incorrect edit.
|
|
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.
|
|
76
|
+
incorrect_digest: The cross-language content digest of the incorrect
|
|
77
|
+
edit's tool call — the join key shared with ``decisions_v1``.
|
|
78
|
+
incorrect_file: The file the incorrect edit targeted.
|
|
79
|
+
incorrect_old: The content the incorrect edit replaced (hunks joined).
|
|
80
|
+
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.
|
|
85
|
+
correction_file: The file the correction touched, when one exists.
|
|
86
|
+
correction_old: The content the correction replaced, when one exists.
|
|
87
|
+
correction_new: The content the correction wrote, when one exists.
|
|
88
|
+
correction_commit: The full commit hash, when the correction is a git fix.
|
|
89
|
+
overlap: The hunk-overlap score linking incorrect and correction; 0.0
|
|
90
|
+
when there is no correction.
|
|
91
|
+
detail: Structured extras, serialized to ``detail_json``.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
ts_ms: int
|
|
95
|
+
session_id: SessionId
|
|
96
|
+
source: str
|
|
97
|
+
anchor_uuid: EventUuid
|
|
98
|
+
incorrect_digest: ToolDigest
|
|
99
|
+
incorrect_file: str
|
|
100
|
+
incorrect_old: str
|
|
101
|
+
incorrect_new: str
|
|
102
|
+
extractor_version: int
|
|
103
|
+
correction_origin: Origin | None = None
|
|
104
|
+
correction_file: str | None = None
|
|
105
|
+
correction_old: str | None = None
|
|
106
|
+
correction_new: str | None = None
|
|
107
|
+
correction_commit: str | None = None
|
|
108
|
+
overlap: float = 0.0
|
|
109
|
+
detail: Mapping[str, Any] = field(default_factory=dict)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class CorrectionLog:
|
|
113
|
+
"""The ``corrections_v1`` ledger at ``~/.cc-transcript/corrections.db``.
|
|
114
|
+
|
|
115
|
+
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.
|
|
119
|
+
|
|
120
|
+
Example:
|
|
121
|
+
>>> log = CorrectionLog.open()
|
|
122
|
+
>>> log.append(correction)
|
|
123
|
+
>>> log.by_digest(session_id, incorrect_digest=digest)
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
127
|
+
self.conn = conn
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def open(cls, path: Path | None = None) -> Self:
|
|
131
|
+
"""Opens (creating if needed) the ledger at ``path``.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
path: The database file path; its parents are created if absent.
|
|
135
|
+
Defaults to ``~/.cc-transcript/corrections.db``.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The opened log.
|
|
139
|
+
"""
|
|
140
|
+
path = path or Path.home() / ".cc-transcript" / "corrections.db"
|
|
141
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
conn = sqlite3.connect(path, autocommit=True)
|
|
143
|
+
conn.row_factory = sqlite3.Row
|
|
144
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
|
145
|
+
conn.execute("PRAGMA busy_timeout = 2000")
|
|
146
|
+
conn.executescript(CORRECTIONS_DDL)
|
|
147
|
+
return cls(conn)
|
|
148
|
+
|
|
149
|
+
def append(self, correction: Correction) -> None:
|
|
150
|
+
"""Appends ``correction`` as a single ``INSERT OR IGNORE``.
|
|
151
|
+
|
|
152
|
+
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.
|
|
155
|
+
"""
|
|
156
|
+
self.conn.execute(
|
|
157
|
+
f"INSERT OR IGNORE INTO corrections_v1 ({CORRECTION_COLUMNS}) "
|
|
158
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
159
|
+
(
|
|
160
|
+
correction.ts_ms,
|
|
161
|
+
correction.session_id,
|
|
162
|
+
correction.source,
|
|
163
|
+
correction.anchor_uuid,
|
|
164
|
+
correction.incorrect_digest,
|
|
165
|
+
correction.incorrect_file,
|
|
166
|
+
correction.incorrect_old,
|
|
167
|
+
correction.incorrect_new,
|
|
168
|
+
correction.correction_origin,
|
|
169
|
+
correction.correction_file,
|
|
170
|
+
correction.correction_old,
|
|
171
|
+
correction.correction_new,
|
|
172
|
+
correction.correction_commit,
|
|
173
|
+
correction.overlap,
|
|
174
|
+
correction.extractor_version,
|
|
175
|
+
json.dumps(dict(correction.detail)),
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def for_session(self, session_id: SessionId) -> tuple[Correction, ...]:
|
|
180
|
+
"""All corrections for ``session_id``, ordered by timestamp."""
|
|
181
|
+
return tuple(
|
|
182
|
+
correction_of(row)
|
|
183
|
+
for row in self.conn.execute(
|
|
184
|
+
"SELECT * FROM corrections_v1 WHERE session_id = ? ORDER BY ts_ms, id", (session_id,)
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def for_anchor(self, session_id: SessionId, anchor_uuid: EventUuid) -> tuple[Correction, ...]:
|
|
189
|
+
"""The corrections harvested around one feedback ``anchor_uuid``."""
|
|
190
|
+
return tuple(
|
|
191
|
+
correction_of(row)
|
|
192
|
+
for row in self.conn.execute(
|
|
193
|
+
"SELECT * FROM corrections_v1 WHERE session_id = ? AND anchor_uuid = ? ORDER BY ts_ms, id",
|
|
194
|
+
(session_id, anchor_uuid),
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def by_digest(self, session_id: SessionId, *, incorrect_digest: ToolDigest) -> tuple[Correction, ...]:
|
|
199
|
+
"""Corrections of the tool call with ``incorrect_digest`` in ``session_id``.
|
|
200
|
+
|
|
201
|
+
The cross-consumer join: pass the ``tool_digest`` a hook recorded in
|
|
202
|
+
``decisions_v1`` to learn whether that exact edit was later corrected.
|
|
203
|
+
"""
|
|
204
|
+
return tuple(
|
|
205
|
+
correction_of(row)
|
|
206
|
+
for row in self.conn.execute(
|
|
207
|
+
"SELECT * FROM corrections_v1 WHERE session_id = ? AND incorrect_digest = ? ORDER BY ts_ms, id",
|
|
208
|
+
(session_id, incorrect_digest),
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def correction_of(row: sqlite3.Row) -> Correction:
|
|
214
|
+
return Correction(
|
|
215
|
+
ts_ms=row["ts_ms"],
|
|
216
|
+
session_id=row["session_id"],
|
|
217
|
+
source=row["source"],
|
|
218
|
+
anchor_uuid=row["anchor_uuid"],
|
|
219
|
+
incorrect_digest=row["incorrect_digest"],
|
|
220
|
+
incorrect_file=row["incorrect_file"],
|
|
221
|
+
incorrect_old=row["incorrect_old"],
|
|
222
|
+
incorrect_new=row["incorrect_new"],
|
|
223
|
+
extractor_version=row["extractor_version"],
|
|
224
|
+
correction_origin=row["correction_origin"],
|
|
225
|
+
correction_file=row["correction_file"],
|
|
226
|
+
correction_old=row["correction_old"],
|
|
227
|
+
correction_new=row["correction_new"],
|
|
228
|
+
correction_commit=row["correction_commit"],
|
|
229
|
+
overlap=row["overlap"],
|
|
230
|
+
detail=json.loads(row["detail_json"]),
|
|
231
|
+
)
|
|
@@ -15,12 +15,17 @@ from datetime import UTC, datetime
|
|
|
15
15
|
from typing import TYPE_CHECKING
|
|
16
16
|
|
|
17
17
|
from cc_transcript.activity import hunk_overlap
|
|
18
|
+
from cc_transcript.corrections import Correction
|
|
19
|
+
from cc_transcript.ids import tool_digest
|
|
20
|
+
from cc_transcript.models import AssistantEvent, ToolUseBlock
|
|
18
21
|
from cc_transcript.tools import Hunk
|
|
19
22
|
|
|
20
23
|
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Sequence
|
|
21
25
|
from pathlib import Path
|
|
22
26
|
|
|
23
27
|
from cc_transcript.activity import Edit, SessionActivity
|
|
28
|
+
from cc_transcript.corrections import CorrectionLog, Origin
|
|
24
29
|
from cc_transcript.ids import EventRef
|
|
25
30
|
|
|
26
31
|
EXTRACTOR_VERSION = 1
|
|
@@ -196,6 +201,94 @@ def run_git(repo: Path, *args: str) -> str | None:
|
|
|
196
201
|
return proc.stdout if proc.returncode == 0 else None
|
|
197
202
|
|
|
198
203
|
|
|
204
|
+
def record_harvest(
|
|
205
|
+
log: CorrectionLog,
|
|
206
|
+
activity: SessionActivity,
|
|
207
|
+
anchor: EventRef,
|
|
208
|
+
pairs: Sequence[CandidatePair],
|
|
209
|
+
*,
|
|
210
|
+
source: str,
|
|
211
|
+
extractor_version: int = EXTRACTOR_VERSION,
|
|
212
|
+
) -> int:
|
|
213
|
+
"""Records ``pairs`` harvested around ``anchor`` into the shared ledger.
|
|
214
|
+
|
|
215
|
+
Lowers each :class:`CandidatePair` to a :class:`~cc_transcript.corrections.Correction`
|
|
216
|
+
— resolving the incorrect edit's raw tool call to the cross-language
|
|
217
|
+
``incorrect_digest`` — and appends it. Idempotent: re-recording the same
|
|
218
|
+
harvest writes nothing new.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
The number of corrections appended this call.
|
|
222
|
+
"""
|
|
223
|
+
rows = [
|
|
224
|
+
correction
|
|
225
|
+
for pair in pairs
|
|
226
|
+
if (correction := lower_pair(activity, anchor, pair, source=source, extractor_version=extractor_version))
|
|
227
|
+
]
|
|
228
|
+
for row in rows:
|
|
229
|
+
log.append(row)
|
|
230
|
+
return len(rows)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def lower_pair(
|
|
234
|
+
activity: SessionActivity, anchor: EventRef, pair: CandidatePair, *, source: str, extractor_version: int
|
|
235
|
+
) -> Correction | None:
|
|
236
|
+
if (block := incorrect_block(activity, pair.incorrect)) is None:
|
|
237
|
+
return None
|
|
238
|
+
incorrect_old, incorrect_new = joined_hunks(pair.incorrect.hunks)
|
|
239
|
+
origin, correction_file, correction_old, correction_new, commit = correction_columns(pair.correction)
|
|
240
|
+
return Correction(
|
|
241
|
+
ts_ms=int(pair.incorrect.ts.timestamp() * 1000),
|
|
242
|
+
session_id=anchor.session_id,
|
|
243
|
+
source=source,
|
|
244
|
+
anchor_uuid=anchor.event_uuid,
|
|
245
|
+
incorrect_digest=tool_digest(block.name, block.input),
|
|
246
|
+
incorrect_file=pair.incorrect.file_path,
|
|
247
|
+
incorrect_old=incorrect_old,
|
|
248
|
+
incorrect_new=incorrect_new,
|
|
249
|
+
extractor_version=extractor_version,
|
|
250
|
+
correction_origin=origin,
|
|
251
|
+
correction_file=correction_file,
|
|
252
|
+
correction_old=correction_old,
|
|
253
|
+
correction_new=correction_new,
|
|
254
|
+
correction_commit=commit,
|
|
255
|
+
overlap=pair.overlap,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def incorrect_block(activity: SessionActivity, edit: Edit) -> ToolUseBlock | None:
|
|
260
|
+
if (turn := activity.turn_of(edit.ref)) is None:
|
|
261
|
+
return None
|
|
262
|
+
return next(
|
|
263
|
+
(
|
|
264
|
+
block
|
|
265
|
+
for event in turn.events
|
|
266
|
+
if isinstance(event, AssistantEvent) and event.meta.uuid == edit.ref.event_uuid
|
|
267
|
+
for block in event.blocks
|
|
268
|
+
if isinstance(block, ToolUseBlock) and block.id == edit.ref.tool_use_id
|
|
269
|
+
),
|
|
270
|
+
None,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def joined_hunks(hunks: Sequence[Hunk]) -> tuple[str, str]:
|
|
275
|
+
return "\n".join(hunk.old for hunk in hunks), "\n".join(hunk.new for hunk in hunks)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def correction_columns(
|
|
279
|
+
correction: Edit | GitFix | None,
|
|
280
|
+
) -> tuple[Origin | None, str | None, str | None, str | None, str | None]:
|
|
281
|
+
match correction:
|
|
282
|
+
case None:
|
|
283
|
+
return None, None, None, None, None
|
|
284
|
+
case GitFix(file_path=file_path, hunks=hunks, commit=commit):
|
|
285
|
+
old, new = joined_hunks(hunks)
|
|
286
|
+
return "git", file_path, old, new, commit
|
|
287
|
+
case edit:
|
|
288
|
+
old, new = joined_hunks(edit.hunks)
|
|
289
|
+
return "session", edit.file_path, old, new, None
|
|
290
|
+
|
|
291
|
+
|
|
199
292
|
def parse_show_hunks(diff: str) -> tuple[Hunk, ...]:
|
|
200
293
|
sections: list[tuple[list[str], list[str]]] = []
|
|
201
294
|
for line in diff.splitlines():
|
|
@@ -512,3 +512,4 @@ def predicate_to_dict(predicate: Predicate) -> dict[str, Any]:
|
|
|
512
512
|
STRUCTURAL_NOISE_RE = compile_groups(STRUCTURAL_NOISE_GROUPS, True)
|
|
513
513
|
INTERRUPT_MARKER_RE = compile_groups(INTERRUPT_MARKER_GROUPS, True)
|
|
514
514
|
STOP_HOOK_RE = compile_groups(STOP_HOOK_GROUPS, True)
|
|
515
|
+
JUNK_USER_MESSAGE_RE = compile_groups(SENTIMENT_JUNK_GROUPS, True)
|
|
@@ -31,11 +31,10 @@ class FeedbackCandidate:
|
|
|
31
31
|
text: The verbatim pushback text.
|
|
32
32
|
window: The durable context window around the feedback, captured via
|
|
33
33
|
:func:`~cc_transcript.context.capture_window`.
|
|
34
|
-
ref: The resolvable reference to the originating event
|
|
35
|
-
|
|
34
|
+
ref: The resolvable reference to the originating event.
|
|
35
|
+
signal: The de-noising confidence signal.
|
|
36
36
|
session_id: The transcript session the feedback came from.
|
|
37
37
|
cc_version: The Claude Code version recorded for the origin.
|
|
38
|
-
signal: The de-noising confidence signal, when computed.
|
|
39
38
|
payload: Detector-specific metadata preserved verbatim.
|
|
40
39
|
"""
|
|
41
40
|
|
|
@@ -44,10 +43,10 @@ class FeedbackCandidate:
|
|
|
44
43
|
occurred_at: datetime
|
|
45
44
|
text: str
|
|
46
45
|
window: ContextWindow
|
|
47
|
-
ref: EventRef
|
|
46
|
+
ref: EventRef
|
|
47
|
+
signal: CandidateSignal
|
|
48
48
|
session_id: SessionId | None = None
|
|
49
49
|
cc_version: str | None = None
|
|
50
|
-
signal: CandidateSignal | None = None
|
|
51
50
|
payload: Mapping[str, Any] | None = None
|
|
52
51
|
|
|
53
52
|
|
|
@@ -55,18 +55,11 @@ def noise(*reasons: str, durable: bool = True) -> CandidateSignal:
|
|
|
55
55
|
return CandidateSignal(NONE, reasons, durable)
|
|
56
56
|
|
|
57
57
|
|
|
58
|
-
def effective_confidence(signal: CandidateSignal | None) -> Confidence:
|
|
59
|
-
"""Returns ``signal``'s confidence, or :data:`MEDIUM` when no signal is set."""
|
|
60
|
-
return signal.confidence if signal else MEDIUM
|
|
61
|
-
|
|
62
|
-
|
|
63
58
|
def to_payload(signal: CandidateSignal) -> dict[str, Any]:
|
|
64
59
|
return {"confidence": signal.confidence, "reasons": list(signal.reasons), "durable": signal.durable}
|
|
65
60
|
|
|
66
61
|
|
|
67
|
-
def from_payload(data: Mapping[str, Any]
|
|
68
|
-
if data is None:
|
|
69
|
-
return None
|
|
62
|
+
def from_payload(data: Mapping[str, Any]) -> CandidateSignal:
|
|
70
63
|
return CandidateSignal(
|
|
71
64
|
confidence=Confidence(data["confidence"]),
|
|
72
65
|
reasons=tuple(data["reasons"]),
|
|
@@ -18,8 +18,6 @@ from __future__ import annotations
|
|
|
18
18
|
from dataclasses import dataclass
|
|
19
19
|
from typing import TYPE_CHECKING
|
|
20
20
|
|
|
21
|
-
from cc_transcript.mining.confidence import effective_confidence
|
|
22
|
-
|
|
23
21
|
if TYPE_CHECKING:
|
|
24
22
|
from collections.abc import Iterable, Iterator
|
|
25
23
|
|
|
@@ -30,13 +28,7 @@ if TYPE_CHECKING:
|
|
|
30
28
|
|
|
31
29
|
@dataclass(frozen=True, slots=True)
|
|
32
30
|
class ConfidenceAtLeast:
|
|
33
|
-
"""Matches candidates whose
|
|
34
|
-
|
|
35
|
-
A candidate without a stored signal scores
|
|
36
|
-
:data:`~cc_transcript.mining.confidence.MEDIUM` via
|
|
37
|
-
:func:`~cc_transcript.mining.confidence.effective_confidence`, so
|
|
38
|
-
legacy rows pass any floor at or below it.
|
|
39
|
-
"""
|
|
31
|
+
"""Matches candidates whose signal confidence is at least ``floor``."""
|
|
40
32
|
|
|
41
33
|
floor: Confidence
|
|
42
34
|
|
|
@@ -57,11 +49,7 @@ class HasReason:
|
|
|
57
49
|
|
|
58
50
|
@dataclass(frozen=True, slots=True)
|
|
59
51
|
class IsDurable:
|
|
60
|
-
"""Matches candidates whose signal durability equals ``want``.
|
|
61
|
-
|
|
62
|
-
A candidate without a stored signal counts as durable, mirroring
|
|
63
|
-
:class:`~cc_transcript.mining.confidence.CandidateSignal`'s default.
|
|
64
|
-
"""
|
|
52
|
+
"""Matches candidates whose signal durability equals ``want``."""
|
|
65
53
|
|
|
66
54
|
want: bool
|
|
67
55
|
|
|
@@ -92,13 +80,13 @@ class CandidateFilterSpec:
|
|
|
92
80
|
def predicate_matches(predicate: CandidatePredicate, candidate: FeedbackCandidate) -> bool:
|
|
93
81
|
match predicate:
|
|
94
82
|
case ConfidenceAtLeast(floor):
|
|
95
|
-
return
|
|
83
|
+
return candidate.signal.confidence >= floor
|
|
96
84
|
case SourceKindIn(kinds):
|
|
97
85
|
return candidate.source_kind in kinds
|
|
98
86
|
case HasReason(reason):
|
|
99
|
-
return
|
|
87
|
+
return reason in candidate.signal.reasons
|
|
100
88
|
case IsDurable(want):
|
|
101
|
-
return
|
|
89
|
+
return candidate.signal.durable is want
|
|
102
90
|
|
|
103
91
|
|
|
104
92
|
def keep_candidate(candidate: FeedbackCandidate, spec: CandidateFilterSpec) -> bool:
|
|
@@ -12,8 +12,7 @@ Every signal carries a calibrated :class:`CandidateSignal` spanning the full
|
|
|
12
12
|
confidence band: arithmetic bumps and demotions over the anchors, with named
|
|
13
13
|
reason codes (``trigger_proximate``, ``short_followup``, ``substantive``,
|
|
14
14
|
``hedged``, ``embedded_text``, ``bare_marker``, ``structural_only``) so apps can
|
|
15
|
-
filter on
|
|
16
|
-
and reasons instead of re-deriving them.
|
|
15
|
+
filter on the signal's confidence and reasons instead of re-deriving them.
|
|
17
16
|
"""
|
|
18
17
|
|
|
19
18
|
from __future__ import annotations
|
|
@@ -71,9 +70,9 @@ class MiningSignal:
|
|
|
71
70
|
cc_version: The Claude Code version recorded for the origin.
|
|
72
71
|
trigger_index: The nearest preceding assistant index, or None — a hint the
|
|
73
72
|
app may use; absence never disqualifies the fact here.
|
|
73
|
+
signal: The de-noising confidence signal; apps re-derive as needed.
|
|
74
74
|
lower_bound: A context anchor, such as the plan-reentry edit index.
|
|
75
75
|
evidence: Detector-specific metadata preserved verbatim.
|
|
76
|
-
signal: The de-noising confidence signal; apps re-derive as needed.
|
|
77
76
|
"""
|
|
78
77
|
|
|
79
78
|
kind: SourceKind
|
|
@@ -85,9 +84,9 @@ class MiningSignal:
|
|
|
85
84
|
text: str
|
|
86
85
|
cc_version: CcVersion | None
|
|
87
86
|
trigger_index: int | None
|
|
87
|
+
signal: CandidateSignal
|
|
88
88
|
lower_bound: int | None = None
|
|
89
89
|
evidence: Mapping[str, Any] = field(default_factory=dict)
|
|
90
|
-
signal: CandidateSignal | None = None
|
|
91
90
|
|
|
92
91
|
|
|
93
92
|
class ScoredText(NamedTuple):
|
|
@@ -50,16 +50,15 @@ def now() -> str:
|
|
|
50
50
|
|
|
51
51
|
def event_row(candidate: FeedbackCandidate, ingested_at: str) -> tuple[object, ...]:
|
|
52
52
|
payload = dict(candidate.payload or {})
|
|
53
|
-
|
|
54
|
-
payload["signal"] = to_payload(candidate.signal)
|
|
53
|
+
payload["signal"] = to_payload(candidate.signal)
|
|
55
54
|
return (
|
|
56
55
|
candidate.dedup_key,
|
|
57
56
|
candidate.source_kind,
|
|
58
57
|
candidate.session_id,
|
|
59
|
-
candidate.ref.event_uuid
|
|
58
|
+
candidate.ref.event_uuid,
|
|
60
59
|
candidate.occurred_at.isoformat(),
|
|
61
60
|
candidate.text,
|
|
62
|
-
json.dumps(payload)
|
|
61
|
+
json.dumps(payload),
|
|
63
62
|
candidate.window.to_json(),
|
|
64
63
|
candidate.cc_version,
|
|
65
64
|
ingested_at,
|
|
@@ -226,10 +226,11 @@ def parse_tool_call(
|
|
|
226
226
|
"""Parse a tool's name and raw input into the typed hierarchy.
|
|
227
227
|
|
|
228
228
|
Strict by default: a known tool whose input is malformed raises
|
|
229
|
-
:class:`ToolInputError` (leniency lives in tests). The
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
229
|
+
:class:`ToolInputError` (leniency lives in tests). The wild-data boundaries
|
|
230
|
+
— the hook runtime and the activity lift — pass ``on_error='other'`` so a
|
|
231
|
+
Claude Code shape change or a model-emitted invalid call degrades to
|
|
232
|
+
:class:`OtherCall` — with a still-correct digest, since the raw mapping is
|
|
233
|
+
the substrate — instead of crashing every hook fire or session lift.
|
|
233
234
|
|
|
234
235
|
Example:
|
|
235
236
|
>>> call = parse_tool_call("Edit", {"file_path": "a.py", "old_string": "x", "new_string": "y"})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "cc-transcript"
|
|
3
|
-
version = "
|
|
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,
|
|
3
|
+
version = "3.0.1"
|
|
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"
|
|
7
7
|
license-files = ["LICENSE"]
|
|
@@ -46,8 +46,6 @@ sentiment = [
|
|
|
46
46
|
"spacy>=3.8",
|
|
47
47
|
"afinn>=0.1",
|
|
48
48
|
]
|
|
49
|
-
# Back-compat alias for the pre-0.6 extra name; prefer [sentiment].
|
|
50
|
-
lexicon = ["cc-transcript[sentiment]"]
|
|
51
49
|
llm = ["spawnllm>=0.1.3"]
|
|
52
50
|
|
|
53
51
|
[project.scripts]
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
from dataclasses import dataclass, field
|
|
5
|
-
from typing import TYPE_CHECKING
|
|
6
|
-
|
|
7
|
-
from cc_transcript.filterspec import (
|
|
8
|
-
ASSISTANTS,
|
|
9
|
-
SENTIMENT_JUNK_GROUPS,
|
|
10
|
-
USERS,
|
|
11
|
-
Clause,
|
|
12
|
-
EntrypointIn,
|
|
13
|
-
FilterSpec,
|
|
14
|
-
KindIs,
|
|
15
|
-
MetaFlag,
|
|
16
|
-
ModelIs,
|
|
17
|
-
TextEmpty,
|
|
18
|
-
TextMatchesAny,
|
|
19
|
-
apply_spec,
|
|
20
|
-
compile_groups,
|
|
21
|
-
)
|
|
22
|
-
from cc_transcript.models import AssistantEvent, ModeEvent, OtherEvent, SystemEvent, UserEvent
|
|
23
|
-
|
|
24
|
-
if TYPE_CHECKING:
|
|
25
|
-
from collections.abc import Iterable, Iterator
|
|
26
|
-
|
|
27
|
-
from cc_transcript.filterspec import EventKind
|
|
28
|
-
from cc_transcript.models import TranscriptEvent
|
|
29
|
-
|
|
30
|
-
JUNK_USER_MESSAGE_RE = compile_groups(SENTIMENT_JUNK_GROUPS, True)
|
|
31
|
-
|
|
32
|
-
KIND_BY_TYPE: dict[type[TranscriptEvent], EventKind] = {
|
|
33
|
-
UserEvent: "user",
|
|
34
|
-
AssistantEvent: "assistant",
|
|
35
|
-
SystemEvent: "system",
|
|
36
|
-
ModeEvent: "mode",
|
|
37
|
-
OtherEvent: "other",
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@dataclass(frozen=True, slots=True)
|
|
42
|
-
class FilterConfig:
|
|
43
|
-
"""Opt-in, consumer-side filtering of a transcript event stream.
|
|
44
|
-
|
|
45
|
-
A back-compatible flag-bag that lowers to a :class:`~cc_transcript.FilterSpec`
|
|
46
|
-
via :meth:`to_spec`. Every flag defaults off, so a bare ``FilterConfig()``
|
|
47
|
-
passes events through untouched.
|
|
48
|
-
|
|
49
|
-
Attributes:
|
|
50
|
-
keep_types: When set, drop every event not an instance of one of these
|
|
51
|
-
types; a type-level allowlist applied before the per-event rules.
|
|
52
|
-
drop_sidechain: Drop events whose envelope marks a sidechain.
|
|
53
|
-
drop_synthetic: Drop assistant events with model ``<synthetic>``.
|
|
54
|
-
drop_compacted: Drop compaction-summary and transcript-only entries
|
|
55
|
-
(envelope flags on :class:`~cc_transcript.models.EntryMeta`).
|
|
56
|
-
drop_empty: Drop user events with no text and assistant events with
|
|
57
|
-
neither text nor a tool use.
|
|
58
|
-
drop_ephemeral_entrypoints: Drop events from these entrypoints.
|
|
59
|
-
junk_pattern: Drop user events whose text matches this pattern.
|
|
60
|
-
"""
|
|
61
|
-
|
|
62
|
-
keep_types: tuple[type[TranscriptEvent], ...] | None = None
|
|
63
|
-
drop_sidechain: bool = False
|
|
64
|
-
drop_synthetic: bool = False
|
|
65
|
-
drop_compacted: bool = False
|
|
66
|
-
drop_empty: bool = False
|
|
67
|
-
drop_ephemeral_entrypoints: frozenset[str] = frozenset()
|
|
68
|
-
junk_pattern: re.Pattern[str] | None = field(default=None)
|
|
69
|
-
|
|
70
|
-
def to_spec(self) -> FilterSpec:
|
|
71
|
-
"""Lowers this flag-bag into an equivalent ordered :class:`FilterSpec`."""
|
|
72
|
-
return FilterSpec(clauses=tuple(self.clauses()))
|
|
73
|
-
|
|
74
|
-
def clauses(self) -> Iterator[Clause]:
|
|
75
|
-
if self.keep_types is not None:
|
|
76
|
-
yield Clause(KindIs(frozenset(KIND_BY_TYPE[kind] for kind in self.keep_types)), negate=True)
|
|
77
|
-
if self.drop_synthetic:
|
|
78
|
-
yield Clause(ModelIs(frozenset({"<synthetic>"})), applies_to=ASSISTANTS)
|
|
79
|
-
if self.drop_empty:
|
|
80
|
-
yield Clause(TextEmpty(consider_tool_use=True), applies_to=ASSISTANTS)
|
|
81
|
-
yield Clause(TextEmpty(consider_tool_use=False), applies_to=USERS)
|
|
82
|
-
if self.junk_pattern is not None:
|
|
83
|
-
yield Clause(
|
|
84
|
-
TextMatchesAny(
|
|
85
|
-
(("junk", self.junk_pattern.pattern),),
|
|
86
|
-
ignore_case=bool(self.junk_pattern.flags & re.IGNORECASE),
|
|
87
|
-
),
|
|
88
|
-
applies_to=USERS,
|
|
89
|
-
)
|
|
90
|
-
if self.drop_sidechain:
|
|
91
|
-
yield Clause(MetaFlag("is_sidechain"))
|
|
92
|
-
if self.drop_compacted:
|
|
93
|
-
yield Clause(MetaFlag("is_compact_summary"))
|
|
94
|
-
yield Clause(MetaFlag("is_visible_in_transcript_only"))
|
|
95
|
-
if self.drop_ephemeral_entrypoints:
|
|
96
|
-
yield Clause(EntrypointIn(self.drop_ephemeral_entrypoints))
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def apply_filters(events: Iterable[TranscriptEvent], config: FilterConfig) -> Iterator[TranscriptEvent]:
|
|
100
|
-
"""Yields the events that survive ``config``.
|
|
101
|
-
|
|
102
|
-
Args:
|
|
103
|
-
events: The events to filter.
|
|
104
|
-
config: The filtering rules to apply.
|
|
105
|
-
|
|
106
|
-
Yields:
|
|
107
|
-
The events for which every enabled rule holds.
|
|
108
|
-
"""
|
|
109
|
-
return apply_spec(events, config.to_spec())
|
|
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
|