cc-transcript 2.0.0__tar.gz → 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/PKG-INFO +4 -7
  2. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/README.md +2 -3
  3. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/__init__.py +25 -9
  4. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/activity.py +2 -2
  5. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/context.py +13 -23
  6. cc_transcript-3.0.0/cc_transcript/corrections.py +231 -0
  7. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/evidence.py +93 -0
  8. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/filterspec.py +1 -0
  9. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/mining/__init__.py +0 -1
  10. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/mining/candidates.py +4 -5
  11. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/mining/confidence.py +1 -8
  12. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/mining/filterspec.py +5 -17
  13. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/mining/signals.py +3 -4
  14. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/mining/store.py +3 -4
  15. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/models.py +1 -0
  16. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/tools.py +5 -4
  17. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/pyproject.toml +2 -4
  18. cc_transcript-2.0.0/cc_transcript/filters.py +0 -109
  19. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/Cargo.lock +0 -0
  20. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/Cargo.toml +0 -0
  21. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/LICENSE +0 -0
  22. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/__main__.py +0 -0
  23. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/_parser_rs.pyi +0 -0
  24. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/backend.py +0 -0
  25. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/builders.py +0 -0
  26. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/cli.py +0 -0
  27. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/decisions.py +0 -0
  28. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/discovery.py +0 -0
  29. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/disktruth.py +0 -0
  30. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/ids.py +0 -0
  31. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/judge/__init__.py +0 -0
  32. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/judge/llm.py +0 -0
  33. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/judge/verdicts.py +0 -0
  34. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/messages.py +0 -0
  35. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/mining/formats.py +0 -0
  36. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/mining/sourcekind.py +0 -0
  37. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/parser.py +0 -0
  38. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/py.typed +0 -0
  39. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/query.py +0 -0
  40. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/render.py +0 -0
  41. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/rust.py +0 -0
  42. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/sentiment/__init__.py +0 -0
  43. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/sentiment/buckets.py +0 -0
  44. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/sentiment/engine.py +0 -0
  45. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/sentiment/lexicon.py +0 -0
  46. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/sentiment/scorespec.py +0 -0
  47. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/cc_transcript/store.py +0 -0
  48. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/rust/Cargo.toml +0 -0
  49. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/rust/data/afinn-en-165.tsv +0 -0
  50. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/rust/data/domain_overrides.tsv +0 -0
  51. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/rust/src/event.rs +0 -0
  52. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/rust/src/filter.rs +0 -0
  53. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/rust/src/lexicon.rs +0 -0
  54. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/rust/src/lib.rs +0 -0
  55. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/rust/src/model.rs +0 -0
  56. {cc_transcript-2.0.0 → cc_transcript-3.0.0}/rust/src/score.rs +0 -0
  57. {cc_transcript-2.0.0 → cc_transcript-3.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: 2.0.0
3
+ Version: 3.0.0
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, a unified decision ledger, LLM judging, and a transcript-investigation CLI.
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). For flag-style filtering, `FilterConfig` is also
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`) or `FilterConfig`.
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). For flag-style filtering, `FilterConfig` is also
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`) or `FilterConfig`.
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.filters import (
321
- JUNK_USER_MESSAGE_RE as JUNK_USER_MESSAGE_RE,
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.call,
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/1"
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; may be ``()`` for migrated
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; None for migrated legacy
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; None for rows converted from pre-2.0
64
+ stores that recorded no trigger turn.
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 | None
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 anchor is absent, the transcript has expired, or
106
- any ref was compacted away — callers fall back to
107
- :meth:`render_preview`, never hydrate-or-fail.
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/1`` wire schema, byte-stably."""
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": None if self.anchor is None else ref_payload(self.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/1`` schema.
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=None if payload["anchor"] is None else ref_from(payload["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)
@@ -25,7 +25,6 @@ from cc_transcript.mining.confidence import (
25
25
  VERY_HIGH,
26
26
  CandidateSignal,
27
27
  Confidence,
28
- effective_confidence,
29
28
  firm,
30
29
  noise,
31
30
  strong,
@@ -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; None for
35
- migrated legacy rows.
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 | None = None
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] | None) -> CandidateSignal | None:
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 effective confidence is at least ``floor``.
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 effective_confidence(candidate.signal) >= floor
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 candidate.signal is not None and reason in candidate.signal.reasons
87
+ return reason in candidate.signal.reasons
100
88
  case IsDurable(want):
101
- return (candidate.signal is None or candidate.signal.durable) is want
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 :func:`~cc_transcript.mining.confidence.effective_confidence`
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
- if candidate.signal is not None:
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 if candidate.ref is not None else None,
58
+ candidate.ref.event_uuid,
60
59
  candidate.occurred_at.isoformat(),
61
60
  candidate.text,
62
- json.dumps(payload) if payload else None,
61
+ json.dumps(payload),
63
62
  candidate.window.to_json(),
64
63
  candidate.cc_version,
65
64
  ingested_at,
@@ -207,3 +207,4 @@ class OtherEvent:
207
207
 
208
208
 
209
209
  TranscriptEvent = UserEvent | AssistantEvent | SystemEvent | ModeEvent | OtherEvent
210
+ """The union of every typed event a parsed transcript can yield."""
@@ -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 hook runtime alone
230
- passes ``on_error='other'`` so a Claude Code shape change degrades the
231
- call to :class:`OtherCall` with a still-correct digest, since the raw
232
- mapping is the substrate instead of crashing every hook fire.
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 = "2.0.0"
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, a unified decision ledger, LLM judging, and a transcript-investigation CLI."
3
+ version = "3.0.0"
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