cc-transcript 8.1.0__tar.gz → 9.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.
Files changed (67) hide show
  1. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/PKG-INFO +5 -1
  2. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/judge/__init__.py +13 -0
  3. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/judge/llm.py +2 -1
  4. cc_transcript-9.0.1/cc_transcript/judge/similar.py +371 -0
  5. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/judge/verdicts.py +202 -47
  6. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/signals.py +4 -4
  7. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/pyproject.toml +6 -1
  8. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/mining.rs +1 -1
  9. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/Cargo.lock +0 -0
  10. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/Cargo.toml +0 -0
  11. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/LICENSE +0 -0
  12. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/README.md +0 -0
  13. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/__init__.py +0 -0
  14. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/__main__.py +0 -0
  15. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/_parser_rs.pyi +0 -0
  16. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/activity.py +0 -0
  17. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/backend.py +0 -0
  18. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/builders.py +0 -0
  19. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/cli.py +0 -0
  20. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/command.py +0 -0
  21. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/context.py +0 -0
  22. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/corrections.py +0 -0
  23. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/corrections_cli.py +0 -0
  24. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/cost.py +0 -0
  25. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/decisions.py +0 -0
  26. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/discovery.py +0 -0
  27. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/disktruth.py +0 -0
  28. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/evidence.py +0 -0
  29. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/extract/__init__.py +0 -0
  30. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/extract/correct.py +0 -0
  31. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/facts.py +0 -0
  32. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/filterspec.py +0 -0
  33. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/ids.py +0 -0
  34. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/__init__.py +0 -0
  35. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/candidates.py +0 -0
  36. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/confidence.py +0 -0
  37. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/engine.py +0 -0
  38. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/filterspec.py +0 -0
  39. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/formats.py +0 -0
  40. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/sourcekind.py +0 -0
  41. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/spec.py +0 -0
  42. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/store.py +0 -0
  43. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/models.py +0 -0
  44. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/parser.py +0 -0
  45. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/py.typed +0 -0
  46. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/query.py +0 -0
  47. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/render.py +0 -0
  48. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/rust.py +0 -0
  49. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/sentiment/__init__.py +0 -0
  50. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/sentiment/buckets.py +0 -0
  51. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/sentiment/engine.py +0 -0
  52. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/sentiment/lexicon.py +0 -0
  53. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/sentiment/scorespec.py +0 -0
  54. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/store.py +0 -0
  55. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/tools.py +0 -0
  56. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/Cargo.toml +0 -0
  57. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/data/afinn-en-165.tsv +0 -0
  58. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/data/domain_overrides.tsv +0 -0
  59. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/command.rs +0 -0
  60. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/event.rs +0 -0
  61. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/filter.rs +0 -0
  62. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/lexicon.rs +0 -0
  63. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/lib.rs +0 -0
  64. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/model.rs +0 -0
  65. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/protocol.rs +0 -0
  66. {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/score.rs +0 -0
  67. {cc_transcript-8.1.0 → cc_transcript-9.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: 8.1.0
3
+ Version: 9.0.1
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -18,10 +18,14 @@ Requires-Dist: tree-sitter-bash>=0.25
18
18
  Requires-Dist: pytest>=8.0 ; extra == 'dev'
19
19
  Requires-Dist: ty>=0.0.44 ; extra == 'dev'
20
20
  Requires-Dist: ruff>=0.8 ; extra == 'dev'
21
+ Requires-Dist: sqlite-vec>=0.1.9 ; extra == 'judge'
22
+ Requires-Dist: model2vec>=0.8.2 ; extra == 'judge'
23
+ Requires-Dist: numpy>=1.26 ; extra == 'judge'
21
24
  Requires-Dist: spawnllm>=0.5.4,<0.6 ; extra == 'llm'
22
25
  Requires-Dist: spacy>=3.8 ; extra == 'sentiment'
23
26
  Requires-Dist: afinn>=0.1 ; extra == 'sentiment'
24
27
  Provides-Extra: dev
28
+ Provides-Extra: judge
25
29
  Provides-Extra: llm
26
30
  Provides-Extra: sentiment
27
31
  License-File: LICENSE
@@ -13,7 +13,18 @@ prompts, the verdict model, and any SQL views over the verdict table.
13
13
  from __future__ import annotations
14
14
 
15
15
  from cc_transcript.judge.llm import default_backend, resolved_model, structured_judge
16
+ from cc_transcript.judge.similar import (
17
+ Evidence,
18
+ KeyOverlap,
19
+ Suggestion,
20
+ default_embedder,
21
+ embed_evidence,
22
+ near_duplicate_keys,
23
+ record_evidence,
24
+ suggest_canonical_keys,
25
+ )
16
26
  from cc_transcript.judge.verdicts import (
27
+ SLUG_PATTERN,
17
28
  AuditEstimate,
18
29
  AuditSample,
19
30
  Disagreement,
@@ -25,7 +36,9 @@ from cc_transcript.judge.verdicts import (
25
36
  JudgeError,
26
37
  Metrics,
27
38
  VerdictLike,
39
+ VerdictSchemaError,
28
40
  VerdictStoreMixin,
41
+ canonical_slug,
29
42
  exact_upper_bound,
30
43
  flip_pairs,
31
44
  golden_result,
@@ -11,6 +11,7 @@ needs no extra installed.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import json
14
15
  from functools import cache
15
16
  from typing import TYPE_CHECKING
16
17
 
@@ -69,7 +70,7 @@ def structured_judge[M: BaseModel](
69
70
  async def judge(prompt: str) -> M:
70
71
  try:
71
72
  return await extract(prompt, response_model, backend=default_backend(), model=tier, timeout=timeout)
72
- except (BackendCallError, BackendUnavailable, TimeoutError, ValidationError) as error:
73
+ except (BackendCallError, BackendUnavailable, TimeoutError, ValidationError, json.JSONDecodeError) as error:
73
74
  raise JudgeError(str(error)) from error
74
75
 
75
76
  return judge
@@ -0,0 +1,371 @@
1
+ """Canonical-key retrieval over verdict evidence, via a sqlite-vec companion store.
2
+
3
+ When a verdict assigns a ``canonical_key``, :func:`record_evidence` embeds the
4
+ judged event's feedback with a static text model (``potion-retrieval-32M``) and
5
+ upserts the vector into a sqlite-vec table that lives in the verdict store's own
6
+ database — created on first use, never part of the base schema. Two read paths
7
+ sit on top: :func:`suggest_canonical_keys` ranks stored keys by evidence
8
+ similarity to a new correction, and :func:`near_duplicate_keys` flags distinct
9
+ keys whose evidence centroids nearly coincide (split detection; nothing merges).
10
+
11
+ The vector deps (``sqlite-vec``, ``model2vec``, ``numpy``) live behind the
12
+ ``cc-transcript[judge]`` extra and load lazily, so importing this module needs
13
+ none of them installed; an app that assigns canonical keys without the extra
14
+ fails loud with an :class:`ImportError` naming it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from functools import cache
20
+ from importlib.util import find_spec
21
+ from typing import TYPE_CHECKING, NamedTuple
22
+ from weakref import WeakSet
23
+
24
+ import anyio.to_thread
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Callable
28
+
29
+ import aiosqlite
30
+ import numpy as np
31
+
32
+ from cc_transcript.mining.candidates import DedupKey
33
+ from cc_transcript.mining.store import FeedbackStore
34
+ from cc_transcript.store import FileStateStore
35
+
36
+ EMBED_MODEL = "minishlab/potion-retrieval-32M"
37
+ EMBED_DIM = 512
38
+ JUDGE_EXTRA = "cc-transcript[judge]"
39
+ JUDGE_DEPS = ("model2vec", "numpy", "sqlite_vec")
40
+
41
+ VECTOR_SCHEMA = f"""
42
+ CREATE VIRTUAL TABLE IF NOT EXISTS verdict_vectors USING vec0(
43
+ vector_id TEXT PRIMARY KEY,
44
+ embedding float[{EMBED_DIM}] distance_metric=cosine
45
+ );
46
+ CREATE TABLE IF NOT EXISTS verdict_evidence (
47
+ vector_id TEXT PRIMARY KEY,
48
+ dedup_key TEXT NOT NULL,
49
+ role TEXT NOT NULL,
50
+ prompt_version INTEGER NOT NULL,
51
+ canonical_key TEXT NOT NULL,
52
+ evidence_text TEXT NOT NULL
53
+ );
54
+ """
55
+
56
+ type Embedder = Callable[[str], np.ndarray]
57
+
58
+ PREPARED_CONNECTIONS: WeakSet[aiosqlite.Connection] = WeakSet()
59
+
60
+
61
+ class Suggestion(NamedTuple):
62
+ """One canonical-key suggestion ranked by evidence similarity.
63
+
64
+ Attributes:
65
+ canonical_key: The suggested durable-rule key.
66
+ score: Cosine similarity of the query to the key's nearest evidence
67
+ vector, in ``[-1, 1]``; higher is closer.
68
+ sentences: Up to three evidence texts backing the key, most similar first.
69
+ """
70
+
71
+ canonical_key: str
72
+ score: float
73
+ sentences: tuple[str, ...]
74
+
75
+
76
+ class Evidence(NamedTuple):
77
+ """A judged event's embedded feedback, ready to upsert inside the verdict transaction.
78
+
79
+ Attributes:
80
+ vector: The serialized 512-dim embedding of the feedback plus the verdict summary.
81
+ text: The event's feedback text, stored verbatim as the evidence sentence.
82
+ canonical_key: The durable-rule key the verdict assigned.
83
+ """
84
+
85
+ vector: bytes
86
+ text: str
87
+ canonical_key: str
88
+
89
+
90
+ class KeyOverlap(NamedTuple):
91
+ """Two distinct canonical keys whose evidence centroids nearly coincide.
92
+
93
+ Attributes:
94
+ key_a: The lexicographically smaller key.
95
+ key_b: The lexicographically larger key.
96
+ similarity: Cosine similarity of the two evidence centroids.
97
+ """
98
+
99
+ key_a: str
100
+ key_b: str
101
+ similarity: float
102
+
103
+
104
+ def require_judge_extra() -> None:
105
+ """Raises a clear :class:`ImportError` naming the extra when a vector dep is missing."""
106
+ if any(find_spec(name) is None for name in JUDGE_DEPS):
107
+ raise ImportError(f"canonical-key retrieval needs the vector-store deps; install {JUDGE_EXTRA}")
108
+
109
+
110
+ @cache
111
+ def default_embedder() -> Embedder:
112
+ """The ``potion-retrieval-32M`` static embedder, loaded once and cached per process.
113
+
114
+ Returns a callable mapping one text to its L2-normalized 512-dim ``float32``
115
+ embedding. The model downloads from the Hugging Face hub on first use and is
116
+ cached there; inference is numpy-only. Tests inject a deterministic stand-in
117
+ by monkeypatching this loader.
118
+
119
+ Raises:
120
+ ImportError: When the ``cc-transcript[judge]`` extra is not installed.
121
+ """
122
+ require_judge_extra()
123
+ import numpy as np
124
+ from model2vec import StaticModel
125
+
126
+ model = StaticModel.from_pretrained(EMBED_MODEL)
127
+
128
+ def embed(text: str) -> np.ndarray:
129
+ vector = model.encode([text])[0].astype(np.float32)
130
+ return vector / np.linalg.norm(vector)
131
+
132
+ return embed
133
+
134
+
135
+ async def prepare_connection(store: FileStateStore) -> None:
136
+ """Loads the sqlite-vec extension and creates the companion tables, once per connection.
137
+
138
+ Holds ``store.lock`` around the check-and-prepare so the schema-creating
139
+ ``executescript`` — which implicitly commits the connection — can never fire
140
+ while another task holds an open :meth:`~cc_transcript.store.FileStateStore.transaction`,
141
+ and so the "already prepared" test-and-set stays atomic under concurrent
142
+ :meth:`~cc_transcript.judge.verdicts.VerdictStoreMixin.record_verdict` fan-out.
143
+ """
144
+ import sqlite_vec
145
+
146
+ async with store.lock:
147
+ if store.conn in PREPARED_CONNECTIONS:
148
+ return
149
+ await store.conn.enable_load_extension(True)
150
+ await store.conn.load_extension(sqlite_vec.loadable_path())
151
+ await store.conn.enable_load_extension(False)
152
+ await store.conn.executescript(VECTOR_SCHEMA)
153
+ PREPARED_CONNECTIONS.add(store.conn)
154
+
155
+
156
+ def serialize_vector(vector: np.ndarray) -> bytes:
157
+ import sqlite_vec
158
+
159
+ return sqlite_vec.serialize_float32(vector.tolist())
160
+
161
+
162
+ async def embed_evidence(store: FileStateStore, *, dedup_key: DedupKey, canonical_key: str, summary: str) -> Evidence:
163
+ """Prepares the vector store and embeds a judged event's feedback for upsert.
164
+
165
+ Fetches the event's feedback text from ``feedback_events`` and embeds it
166
+ together with ``summary``, off the event loop. Runs the schema-creating
167
+ :func:`prepare_connection` and the feedback read under ``store.lock``, both
168
+ outside the caller's transaction: ``prepare_connection``'s ``executescript``
169
+ implicitly commits an open transaction, so the caller calls this before
170
+ ``BEGIN`` and then upserts the returned :class:`Evidence` atomically via
171
+ :func:`record_evidence`. Called from
172
+ :meth:`~cc_transcript.judge.verdicts.VerdictStoreMixin.record_verdict`
173
+ whenever a verdict assigns a ``canonical_key``.
174
+
175
+ Args:
176
+ store: The verdict store; the vectors live in its database.
177
+ dedup_key: The judged event's dedup key.
178
+ canonical_key: The durable-rule key the verdict assigned.
179
+ summary: The verdict's one-sentence summary, embedded with the feedback.
180
+
181
+ Returns:
182
+ The serialized vector, evidence text, and canonical key, ready to upsert.
183
+
184
+ Raises:
185
+ ImportError: When the ``cc-transcript[judge]`` extra is not installed.
186
+ """
187
+ require_judge_extra()
188
+ await prepare_connection(store)
189
+ async with store.lock:
190
+ cursor = await store.conn.execute("SELECT text FROM feedback_events WHERE dedup_key = ?", (dedup_key,))
191
+ text = (await cursor.fetchone())["text"]
192
+ embedder = await anyio.to_thread.run_sync(default_embedder)
193
+ vector = await anyio.to_thread.run_sync(embedder, f"{text}\n{summary}")
194
+ return Evidence(serialize_vector(vector), text, canonical_key)
195
+
196
+
197
+ def evidence_vector_id(dedup_key: DedupKey, role: str, prompt_version: int) -> str:
198
+ return f"{dedup_key}::{role}::{prompt_version}"
199
+
200
+
201
+ async def clear_evidence(conn: aiosqlite.Connection, *, dedup_key: DedupKey, role: str, prompt_version: int) -> None:
202
+ """Deletes any evidence vector for one verdict identity, on the caller's transaction.
203
+
204
+ Both the delete pair :func:`record_evidence` runs before it re-inserts, and
205
+ the sole cleanup when a verdict is upgraded to name no ``canonical_key``: the
206
+ removal commits atomically with the verdict row that dropped the key, so a
207
+ dropped rule never leaves stale evidence for :func:`suggest_canonical_keys`
208
+ to surface.
209
+
210
+ Args:
211
+ conn: The verdict store's connection, inside the caller's write transaction.
212
+ dedup_key: The judged event's dedup key.
213
+ role: Who produced the verdict, e.g. ``judge`` or ``auditor``.
214
+ prompt_version: The prompt version that produced the verdict.
215
+ """
216
+ vector_id = evidence_vector_id(dedup_key, role, prompt_version)
217
+ await conn.execute("DELETE FROM verdict_vectors WHERE vector_id = ?", (vector_id,))
218
+ await conn.execute("DELETE FROM verdict_evidence WHERE vector_id = ?", (vector_id,))
219
+
220
+
221
+ async def record_evidence(
222
+ conn: aiosqlite.Connection, *, dedup_key: DedupKey, role: str, prompt_version: int, evidence: Evidence
223
+ ) -> None:
224
+ """Upserts a judged event's evidence vector inside the caller's open transaction.
225
+
226
+ Replaces any prior vector keyed ``(dedup_key, role, prompt_version)`` — one
227
+ vector per event per role per prompt version, mirroring the verdict's own
228
+ identity so a ``judge`` and an ``auditor`` verdict on the same event never
229
+ overwrite each other's evidence. The delete/insert pairs run on the caller's
230
+ already-begun transaction, so a failure rolls the verdict row back with them;
231
+ the failure-prone prepare and embed happen earlier in :func:`embed_evidence`.
232
+
233
+ Args:
234
+ conn: The verdict store's connection, inside the caller's write transaction.
235
+ dedup_key: The judged event's dedup key.
236
+ role: Who produced the verdict, e.g. ``judge`` or ``auditor``.
237
+ prompt_version: The prompt version that produced the verdict.
238
+ evidence: The embedded feedback from :func:`embed_evidence`.
239
+ """
240
+ await clear_evidence(conn, dedup_key=dedup_key, role=role, prompt_version=prompt_version)
241
+ vector_id = evidence_vector_id(dedup_key, role, prompt_version)
242
+ await conn.execute("INSERT INTO verdict_vectors(vector_id, embedding) VALUES (?, ?)", (vector_id, evidence.vector))
243
+ await conn.execute(
244
+ "INSERT INTO verdict_evidence(vector_id, dedup_key, role, prompt_version, canonical_key, evidence_text) "
245
+ "VALUES (?, ?, ?, ?, ?, ?)",
246
+ (vector_id, dedup_key, role, prompt_version, evidence.canonical_key, evidence.text),
247
+ )
248
+
249
+
250
+ async def prepare_evidence_removal(store: FileStateStore) -> bool:
251
+ """Readies the vec companion so an in-transaction :func:`clear_evidence` can reach it.
252
+
253
+ Returns False — skip the removal — whenever no evidence could exist: ``sqlite_vec``
254
+ is absent, or the companion tables were never created. The removal path embeds
255
+ nothing, so it gates on ``sqlite_vec`` alone — the ``model2vec``/``numpy``
256
+ embedder deps that :func:`require_judge_extra` guards belong to the insert side,
257
+ and a partial install (``sqlite_vec`` present, embedder absent) must still clear
258
+ stranded evidence. Otherwise loads the extension onto the connection (a no-op
259
+ once the connection is prepared) so a later :func:`clear_evidence` on the
260
+ caller's transaction can delete from the ``vec0`` virtual table, and returns
261
+ True. Called before the verdict transaction opens, mirroring
262
+ :func:`embed_evidence`, because :func:`prepare_connection`'s ``executescript``
263
+ commits.
264
+
265
+ Args:
266
+ store: The verdict store; the vectors live in its database.
267
+ """
268
+ if store.conn in PREPARED_CONNECTIONS:
269
+ return True
270
+ if find_spec("sqlite_vec") is None:
271
+ return False
272
+ async with store.lock:
273
+ cursor = await store.conn.execute(
274
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'verdict_evidence'"
275
+ )
276
+ if await cursor.fetchone() is None:
277
+ return False
278
+ await prepare_connection(store)
279
+ return True
280
+
281
+
282
+ async def suggest_canonical_keys(
283
+ store: FeedbackStore, text: str, *, prompt_version: int, k: int = 5
284
+ ) -> list[Suggestion]:
285
+ """Ranks stored canonical keys by evidence similarity to ``text``.
286
+
287
+ Embeds ``text`` and scans every evidence vector recorded at ``prompt_version``,
288
+ ranking each key by its single closest evidence vector (cosine similarity).
289
+ Returns the top ``k`` keys, each with up to three backing evidence sentences
290
+ ordered most-similar first.
291
+
292
+ Args:
293
+ store: The verdict store whose database holds the evidence vectors.
294
+ text: The free text to find canonical keys for, e.g. a new correction.
295
+ prompt_version: The prompt version whose evidence to search.
296
+ k: The maximum number of distinct canonical keys to return.
297
+
298
+ Returns:
299
+ Up to ``k`` :class:`Suggestion`s, highest score first.
300
+
301
+ Raises:
302
+ ImportError: When the ``cc-transcript[judge]`` extra is not installed.
303
+ """
304
+ require_judge_extra()
305
+ conn = store.store.conn
306
+ await prepare_connection(store.store)
307
+ embedder = await anyio.to_thread.run_sync(default_embedder)
308
+ query = serialize_vector(await anyio.to_thread.run_sync(embedder, text))
309
+ cur = await conn.execute(
310
+ "SELECT e.canonical_key AS ck, e.evidence_text AS ev, vec_distance_cosine(v.embedding, ?) AS dist "
311
+ "FROM verdict_vectors v JOIN verdict_evidence e ON e.vector_id = v.vector_id "
312
+ "WHERE e.prompt_version = ? ORDER BY dist",
313
+ (query, prompt_version),
314
+ )
315
+ ranked: dict[str, list[tuple[float, str]]] = {}
316
+ async for row in cur:
317
+ ranked.setdefault(row["ck"], []).append((1.0 - row["dist"], row["ev"]))
318
+ return sorted(
319
+ (Suggestion(ck, hits[0][0], tuple(ev for _, ev in hits[:3])) for ck, hits in ranked.items()),
320
+ key=lambda suggestion: suggestion.score,
321
+ reverse=True,
322
+ )[:k]
323
+
324
+
325
+ async def near_duplicate_keys(store: FeedbackStore, *, prompt_version: int, threshold: float) -> list[KeyOverlap]:
326
+ """Finds distinct canonical keys whose evidence centroids nearly coincide.
327
+
328
+ Groups the evidence vectors recorded at ``prompt_version`` by canonical key,
329
+ reduces each group to a normalized centroid, and returns every pair of
330
+ distinct keys whose centroid cosine similarity exceeds ``threshold`` — a
331
+ split-detection signal that two keys may name the same rule. Nothing merges;
332
+ the caller decides.
333
+
334
+ Args:
335
+ store: The verdict store whose database holds the evidence vectors.
336
+ prompt_version: The prompt version whose evidence to compare.
337
+ threshold: The exclusive cosine-similarity floor a pair must clear.
338
+
339
+ Returns:
340
+ Overlapping key pairs, highest similarity first, each pair's keys ordered
341
+ lexicographically.
342
+
343
+ Raises:
344
+ ImportError: When the ``cc-transcript[judge]`` extra is not installed.
345
+ """
346
+ require_judge_extra()
347
+ import numpy as np
348
+
349
+ conn = store.store.conn
350
+ await prepare_connection(store.store)
351
+ cur = await conn.execute(
352
+ "SELECT e.canonical_key AS ck, v.embedding AS emb "
353
+ "FROM verdict_vectors v JOIN verdict_evidence e ON e.vector_id = v.vector_id "
354
+ "WHERE e.prompt_version = ?",
355
+ (prompt_version,),
356
+ )
357
+ groups: dict[str, list[np.ndarray]] = {}
358
+ async for row in cur:
359
+ groups.setdefault(row["ck"], []).append(np.frombuffer(row["emb"], dtype=np.float32))
360
+ centroids = {ck: (mean := np.mean(vectors, axis=0)) / np.linalg.norm(mean) for ck, vectors in groups.items()}
361
+ keys = sorted(centroids)
362
+ return sorted(
363
+ (
364
+ KeyOverlap(key_a, key_b, similarity)
365
+ for i, key_a in enumerate(keys)
366
+ for key_b in keys[i + 1 :]
367
+ if (similarity := float(np.dot(centroids[key_a], centroids[key_b]))) > threshold
368
+ ),
369
+ key=lambda overlap: overlap.similarity,
370
+ reverse=True,
371
+ )
@@ -9,10 +9,12 @@ prompts, the verdict model, and any SQL views over the verdict table.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import re
12
13
  from dataclasses import dataclass
13
14
  from math import comb
14
15
  from random import Random
15
16
  from typing import TYPE_CHECKING, ClassVar, Protocol
17
+ from weakref import WeakKeyDictionary
16
18
 
17
19
  import anyio
18
20
 
@@ -21,6 +23,8 @@ from cc_transcript.mining.store import now
21
23
  if TYPE_CHECKING:
22
24
  from collections.abc import Awaitable, Callable, Mapping, Sequence
23
25
 
26
+ import aiosqlite
27
+
24
28
  from cc_transcript.context import Fidelity
25
29
  from cc_transcript.mining.candidates import DedupKey
26
30
  from cc_transcript.store import FileStateStore
@@ -42,13 +46,46 @@ CREATE TABLE IF NOT EXISTS {table} (
42
46
  {summary} TEXT NOT NULL,
43
47
  confidence REAL NOT NULL,
44
48
  rationale TEXT NOT NULL,
49
+ canonical_key TEXT,
45
50
  fidelity TEXT NOT NULL CHECK(fidelity IN ('full','summary')),
46
51
  judged_at TEXT NOT NULL,
47
- UNIQUE(dedup_key, role, prompt_version, model)
52
+ UNIQUE(dedup_key, role, prompt_version)
48
53
  );
49
54
  CREATE INDEX IF NOT EXISTS idx_{table}_dedup ON {table}(dedup_key);
50
55
  """
51
56
 
57
+ SLUG_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+){1,5}$")
58
+ """Matches a durable rule slug: two to six ``[a-z0-9]`` groups joined by single hyphens.
59
+
60
+ The two-group floor rejects a bare word, and the six-group ceiling — together
61
+ with the hyphen requirement — can never match a 64-character hex digest, so a
62
+ slug and a content digest never collide.
63
+ """
64
+
65
+ VALIDATED_VERDICT_TABLES: WeakKeyDictionary[aiosqlite.Connection, set[str]] = WeakKeyDictionary()
66
+ """Per-connection sets of verdict table names that passed :meth:`VerdictStoreMixin.ensure_verdict_schema`.
67
+
68
+ A table's schema is fixed for a connection's lifetime, so the v8/v9 check runs
69
+ once per table per connection; membership short-circuits the repeat calls from
70
+ every :meth:`~VerdictStoreMixin.judged`, :meth:`~VerdictStoreMixin.unjudged`, and
71
+ :meth:`~VerdictStoreMixin.record_verdict`. Scoped by table, not connection alone,
72
+ because the mixin is explicitly multi-table — two subclasses with different
73
+ ``VERDICT_TABLE`` may share one connection, and validating one must not vouch for
74
+ the other.
75
+ """
76
+
77
+
78
+ class VerdictSchemaError(RuntimeError):
79
+ """The verdict table predates the v9 schema and must be rebuilt by hand.
80
+
81
+ Raised by :class:`VerdictStoreMixin`'s read and write paths when the physical
82
+ table lacks the ``canonical_key`` column or its unique index still covers the
83
+ v8 identity ``(dedup_key, role, prompt_version, model)``. ``CREATE TABLE IF
84
+ NOT EXISTS`` leaves such a table untouched, and :meth:`~VerdictStoreMixin.judged`
85
+ neither selects ``canonical_key`` nor joins on ``model``, so a v8 table would
86
+ otherwise read without error while silently returning per-model duplicates.
87
+ """
88
+
52
89
 
53
90
  class JudgeError(RuntimeError):
54
91
  """A provider or transport failure from one judge call.
@@ -75,6 +112,8 @@ class VerdictLike(Protocol):
75
112
  confidence: The judge's probability that its accept-vs-reject call is right.
76
113
  rationale: One short clause explaining the call.
77
114
  accepted: Whether the verdict accepts the row.
115
+ canonical_key: The canonical, normalized key of the durable rule the
116
+ verdict names, or ``None`` when the verdict names no durable rule.
78
117
  """
79
118
 
80
119
  @property
@@ -87,6 +126,8 @@ class VerdictLike(Protocol):
87
126
  def rationale(self) -> str: ...
88
127
  @property
89
128
  def accepted(self) -> bool: ...
129
+ @property
130
+ def canonical_key(self) -> str | None: ...
90
131
 
91
132
 
92
133
  class VerdictStoreMixin:
@@ -99,6 +140,12 @@ class VerdictStoreMixin:
99
140
  shape. An app with an existing verdict table (cc-pushback's ``triage``)
100
141
  adopts the mixin by overriding the params — zero migration.
101
142
 
143
+ Verdict identity is ``(dedup_key, role, prompt_version)``: one verdict per
144
+ event per role per prompt version. ``model`` is provenance only — recorded
145
+ and reported, never filtered on — so a judge-backend flip cannot mass-re-judge
146
+ the corpus and :meth:`judged`'s consumers never double-count per-model
147
+ duplicates.
148
+
102
149
  Example:
103
150
  >>> class Store(VerdictStoreMixin, FeedbackStore):
104
151
  ... VERDICT_TABLE = "triage"
@@ -118,86 +165,173 @@ class VerdictStoreMixin:
118
165
  table=cls.VERDICT_TABLE, accepted=cls.ACCEPTED_COLUMN, summary=cls.SUMMARY_COLUMN
119
166
  )
120
167
 
168
+ async def ensure_verdict_schema(self) -> None:
169
+ """Validates the verdict table matches the v9 schema, once per table per connection.
170
+
171
+ ``CREATE TABLE IF NOT EXISTS`` leaves an existing v8 table (no
172
+ ``canonical_key`` column, unique index over ``(dedup_key, role,
173
+ prompt_version, model)``) in place, and neither :meth:`judged` nor
174
+ :meth:`unjudged` selects ``canonical_key`` or joins on ``model`` — so a v8
175
+ table reads without error while returning per-model duplicates. The read
176
+ and write paths call this first so the mismatch fails loud instead.
177
+
178
+ Raises:
179
+ VerdictSchemaError: When the table lacks a ``canonical_key`` column or
180
+ no unique index covers exactly ``(dedup_key, role, prompt_version)``.
181
+ """
182
+ if self.VERDICT_TABLE in VALIDATED_VERDICT_TABLES.setdefault(self.store.conn, set()):
183
+ return
184
+ columns = {
185
+ row["name"] async for row in await self.store.conn.execute(f"PRAGMA table_info({self.VERDICT_TABLE})")
186
+ }
187
+ indexes = [dict(row) async for row in await self.store.conn.execute(f"PRAGMA index_list({self.VERDICT_TABLE})")]
188
+ unique_columns = [
189
+ tuple([col["name"] async for col in await self.store.conn.execute(f"PRAGMA index_info({index['name']})")])
190
+ for index in indexes
191
+ if index["unique"]
192
+ ]
193
+ if "canonical_key" not in columns or ("dedup_key", "role", "prompt_version") not in unique_columns:
194
+ raise VerdictSchemaError(
195
+ f"verdict table {self.VERDICT_TABLE!r} predates the v9 schema: it needs a canonical_key column and a "
196
+ f"UNIQUE(dedup_key, role, prompt_version) index. Rebuild it with the manual v8-to-v9 migration "
197
+ f"(recreate {self.VERDICT_TABLE!r} from verdicts_ddl() and copy the rows over) before reading "
198
+ f"or writing."
199
+ )
200
+ VALIDATED_VERDICT_TABLES[self.store.conn].add(self.VERDICT_TABLE)
201
+
121
202
  async def record_verdict(
122
203
  self, key: DedupKey, verdict: VerdictLike, *, role: str, prompt_version: int, model: str, fidelity: Fidelity
123
204
  ) -> None:
124
- """Records one verdict, idempotently, keyed by ``(dedup_key, role, prompt_version, model)``.
125
-
126
- Fidelity sits outside the unique key: re-recording an existing verdict
127
- is a no-op, except that a ``'full'``-fidelity verdict replaces a
128
- ``'summary'`` one the re-judge path :meth:`unjudged` opens with
129
- ``refresh_summary=True``.
205
+ """Records one verdict, idempotently, keyed by ``(dedup_key, role, prompt_version)``.
206
+
207
+ One verdict per event per role per prompt version — ``model`` is pure
208
+ provenance, recorded and reported but never part of the identity, so a
209
+ judge-backend flip never mass-re-judges the corpus. Re-recording is a
210
+ no-op, with one exception: a ``'full'``-fidelity verdict replaces a
211
+ ``'summary'`` one at the same key (any model), carrying the new model,
212
+ content, and ``canonical_key`` across — the re-judge path
213
+ :meth:`unjudged` opens with ``refresh_summary=True``. A different-model
214
+ ``'full'``-to-``'full'`` re-record is a deliberate silent no-op:
215
+ first-full-wins.
130
216
 
131
217
  Args:
132
218
  key: The judged event's dedup key.
133
219
  verdict: The structured verdict to persist.
134
220
  role: Who produced it, e.g. ``judge`` or ``auditor``.
135
221
  prompt_version: The prompt version that produced it.
136
- model: The resolved model name that produced it.
222
+ model: The resolved model name that produced it, kept as provenance.
137
223
  fidelity: Whether the judged window rendered at ``'full'`` fidelity
138
224
  or from ``'summary'`` previews.
139
225
  """
140
- await self.store.conn.execute(
141
- f"INSERT INTO {self.VERDICT_TABLE} ("
142
- f"dedup_key, role, prompt_version, model, category, {self.ACCEPTED_COLUMN}, "
143
- f"{self.SUMMARY_COLUMN}, confidence, rationale, fidelity, judged_at"
144
- ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
145
- "ON CONFLICT(dedup_key, role, prompt_version, model) DO UPDATE SET "
146
- f"category = excluded.category, {self.ACCEPTED_COLUMN} = excluded.{self.ACCEPTED_COLUMN}, "
147
- f"{self.SUMMARY_COLUMN} = excluded.{self.SUMMARY_COLUMN}, confidence = excluded.confidence, "
148
- "rationale = excluded.rationale, fidelity = excluded.fidelity, judged_at = excluded.judged_at "
149
- "WHERE fidelity = 'summary' AND excluded.fidelity = 'full'",
150
- (
151
- key,
152
- role,
153
- prompt_version,
154
- model,
155
- verdict.category,
156
- verdict.accepted,
157
- verdict.summary,
158
- verdict.confidence,
159
- verdict.rationale,
160
- fidelity,
161
- now(),
162
- ),
226
+ from cc_transcript.judge.similar import (
227
+ clear_evidence,
228
+ embed_evidence,
229
+ prepare_evidence_removal,
230
+ record_evidence,
163
231
  )
164
232
 
233
+ await self.ensure_verdict_schema()
234
+ evidence = (
235
+ await embed_evidence(
236
+ self.store, dedup_key=key, canonical_key=verdict.canonical_key, summary=verdict.summary
237
+ )
238
+ if verdict.canonical_key is not None
239
+ else None
240
+ )
241
+ removable = evidence is None and await prepare_evidence_removal(self.store)
242
+ async with self.store.transaction() as conn:
243
+ cursor = await conn.execute(
244
+ f"INSERT INTO {self.VERDICT_TABLE} ("
245
+ f"dedup_key, role, prompt_version, model, category, {self.ACCEPTED_COLUMN}, "
246
+ f"{self.SUMMARY_COLUMN}, confidence, rationale, canonical_key, fidelity, judged_at"
247
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
248
+ "ON CONFLICT(dedup_key, role, prompt_version) DO UPDATE SET "
249
+ f"model = excluded.model, category = excluded.category, "
250
+ f"{self.ACCEPTED_COLUMN} = excluded.{self.ACCEPTED_COLUMN}, "
251
+ f"{self.SUMMARY_COLUMN} = excluded.{self.SUMMARY_COLUMN}, confidence = excluded.confidence, "
252
+ "rationale = excluded.rationale, canonical_key = excluded.canonical_key, "
253
+ "fidelity = excluded.fidelity, judged_at = excluded.judged_at "
254
+ "WHERE fidelity = 'summary' AND excluded.fidelity = 'full'",
255
+ (
256
+ key,
257
+ role,
258
+ prompt_version,
259
+ model,
260
+ verdict.category,
261
+ verdict.accepted,
262
+ verdict.summary,
263
+ verdict.confidence,
264
+ verdict.rationale,
265
+ verdict.canonical_key,
266
+ fidelity,
267
+ now(),
268
+ ),
269
+ )
270
+ if cursor.rowcount > 0:
271
+ if evidence is not None:
272
+ await record_evidence(
273
+ conn, dedup_key=key, role=role, prompt_version=prompt_version, evidence=evidence
274
+ )
275
+ elif removable:
276
+ await clear_evidence(conn, dedup_key=key, role=role, prompt_version=prompt_version)
277
+
165
278
  async def unjudged(
166
279
  self,
167
280
  *,
168
281
  role: str,
169
282
  prompt_version: int,
170
- model: str,
171
283
  limit: int | None = None,
172
284
  refresh_summary: bool = False,
173
285
  ) -> list[dict[str, object]]:
174
- """Returns events lacking a verdict for ``(role, prompt_version, model)``, oldest first.
286
+ """Returns events lacking a verdict for ``(role, prompt_version)``, unjudged first.
287
+
288
+ Truly-unjudged events (no verdict at all) sort ahead of summary-refresh
289
+ rows, then by event id, so a backlog of summary rows never starves fresh
290
+ events under ``limit``.
175
291
 
176
292
  Args:
177
293
  role: The verdict role to check.
178
294
  prompt_version: The prompt version the verdict must carry.
179
- model: The resolved model name the verdict must carry.
180
295
  limit: When set, the maximum number of rows to return.
181
296
  refresh_summary: When True, also re-yields events whose verdict was
182
297
  recorded at ``fidelity='summary'``, so they can be re-judged at
183
- full fidelity once their windows hydrate again.
298
+ full fidelity once their windows hydrate again. A summary row
299
+ whose context window no longer hydrates — its transcript expired
300
+ or a ref was compacted away — is dropped, so a dead transcript
301
+ stops burning the cap.
184
302
 
185
303
  Returns:
186
304
  One dict per event with the columns needed to build its prompt.
187
305
  """
188
- miss = "(t.id IS NULL OR t.fidelity = 'summary')" if refresh_summary else "t.id IS NULL"
189
- query = (
190
- f"SELECT {EVENT_COLUMNS} FROM feedback_events e "
306
+ await self.ensure_verdict_schema()
307
+ if not refresh_summary:
308
+ sql = (
309
+ f"SELECT {EVENT_COLUMNS} FROM feedback_events e "
310
+ f"LEFT JOIN {self.VERDICT_TABLE} t ON t.dedup_key = e.dedup_key "
311
+ "AND t.role = ? AND t.prompt_version = ? "
312
+ "WHERE t.id IS NULL ORDER BY e.id"
313
+ )
314
+ params: tuple[object, ...] = (role, prompt_version)
315
+ if limit is not None:
316
+ sql += " LIMIT ?"
317
+ params = (*params, limit)
318
+ async with self.store.conn.execute(sql, params) as cur:
319
+ return [dict(row) async for row in cur]
320
+ kept: list[dict[str, object]] = []
321
+ async with self.store.conn.execute(
322
+ f"SELECT {EVENT_COLUMNS}, t.id AS verdict_id FROM feedback_events e "
191
323
  f"LEFT JOIN {self.VERDICT_TABLE} t ON t.dedup_key = e.dedup_key "
192
- "AND t.role = ? AND t.prompt_version = ? AND t.model = ? "
193
- f"WHERE {miss} ORDER BY e.id"
194
- )
195
- params: tuple[object, ...] = (role, prompt_version, model)
196
- if limit is not None:
197
- query += " LIMIT ?"
198
- params = (*params, limit)
199
- cur = await self.store.conn.execute(query, params)
200
- return [dict(row) async for row in cur]
324
+ "AND t.role = ? AND t.prompt_version = ? "
325
+ "WHERE (t.id IS NULL OR t.fidelity = 'summary') ORDER BY (t.id IS NOT NULL), e.id",
326
+ (role, prompt_version),
327
+ ) as cur:
328
+ async for raw in cur:
329
+ row = dict(raw)
330
+ if row.pop("verdict_id") is None or await hydratable(str(row["context_json"])):
331
+ kept.append(row)
332
+ if limit is not None and len(kept) >= limit:
333
+ break
334
+ return kept
201
335
 
202
336
  async def judged(self, *, role: str, prompt_version: int) -> list[dict[str, object]]:
203
337
  """Returns events joined with their ``(role, prompt_version)`` verdicts, oldest first.
@@ -214,6 +348,7 @@ class VerdictStoreMixin:
214
348
  verdict's ``category``, ``accepted``, ``confidence``, ``summary``,
215
349
  ``rationale``, and ``model``.
216
350
  """
351
+ await self.ensure_verdict_schema()
217
352
  cur = await self.store.conn.execute(
218
353
  f"SELECT {EVENT_COLUMNS}, t.category, t.{self.ACCEPTED_COLUMN} AS accepted, t.confidence, "
219
354
  f"t.{self.SUMMARY_COLUMN} AS summary, t.rationale, t.model "
@@ -431,6 +566,26 @@ class FlipReport:
431
566
  return len(self.flips) / self.common if self.common else None
432
567
 
433
568
 
569
+ def canonical_slug(text: str) -> str:
570
+ """Normalizes free text into a hyphenated slug candidate.
571
+
572
+ Lowercases ``text`` and reduces every run of non-alphanumeric characters to a
573
+ single hyphen, trimming leading and trailing hyphens — so a judge can turn a
574
+ durable-rule phrase into a stable :data:`SLUG_PATTERN` ``canonical_key``.
575
+
576
+ Example:
577
+ >>> canonical_slug("Use UV, not pip!")
578
+ 'use-uv-not-pip'
579
+ """
580
+ return re.sub(r"[^a-z0-9]+", "-", text.strip().lower()).strip("-")
581
+
582
+
583
+ async def hydratable(context_json: str) -> bool:
584
+ from cc_transcript.context import ContextWindow
585
+
586
+ return await ContextWindow.from_json(context_json).hydrate() is not None
587
+
588
+
434
589
  async def run_verdicts[V](
435
590
  rows: Sequence[Mapping[str, object]],
436
591
  prompt_for: Callable[[Mapping[str, object]], Awaitable[str]],
@@ -537,7 +537,9 @@ def question_answer_signal(
537
537
  text=text,
538
538
  cc_version=event.meta.cc_version,
539
539
  trigger_index=nearest_assistant_index(events, index),
540
- signal=weak("option_pick") if option_pick else calibrated(spec.calibrated, text, seed="freeform_answer"),
540
+ signal=weak("option_pick")
541
+ if option_pick and not pair.notes
542
+ else calibrated(spec.calibrated, text, seed="freeform_answer"),
541
543
  evidence=evidence,
542
544
  )
543
545
 
@@ -552,9 +554,7 @@ def iter_ask_user_question_signals(events: Sequence[TranscriptEvent], spec: Mini
552
554
  if (use := uses.get(block.tool_use_id)) is not None
553
555
  if use.name == "AskUserQuestion"
554
556
  if block.content.endswith(ANSWERED_TRAILER)
555
- for pair in answered_pairs(
556
- block.content[len(ANSWERED_PREFIX) : -len(ANSWERED_TRAILER)], use.input["questions"]
557
- )
557
+ for pair in answered_pairs(block.content[len(ANSWERED_PREFIX) : -len(ANSWERED_TRAILER)], use.input["questions"])
558
558
  if (signal := question_answer_signal(events, event, index, pair, spec)) is not None
559
559
  )
560
560
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cc-transcript"
3
- version = "8.1.0"
3
+ version = "9.0.1"
4
4
  description = "Grep every Claude Code session you've ever run."
5
5
  readme = "README.md"
6
6
  license = "PolyForm-Noncommercial-1.0.0"
@@ -49,6 +49,11 @@ sentiment = [
49
49
  "afinn>=0.1",
50
50
  ]
51
51
  llm = ["spawnllm>=0.5.4,<0.6"]
52
+ judge = [
53
+ "sqlite-vec>=0.1.9",
54
+ "model2vec>=0.8.2",
55
+ "numpy>=1.26",
56
+ ]
52
57
 
53
58
  [project.scripts]
54
59
  cc-transcript = "cc_transcript.cli:cli"
@@ -1362,7 +1362,7 @@ fn iter_ask_user_question<'py>(
1362
1362
  let (picked, option_pick) = resolve_pick(pair.answer, &labels);
1363
1363
  let recommended = picked.iter().any(|label| label.contains("(Recommended)"));
1364
1364
  let Some(text) = pair.notes.or(pair.answer) else { continue };
1365
- let sig = if option_pick {
1365
+ let sig = if option_pick && pair.notes.map_or(true, str::is_empty) {
1366
1366
  weak("option_pick")
1367
1367
  } else {
1368
1368
  calibrated(&spec.calibrated, text, "freeform_answer")
File without changes
File without changes
File without changes
File without changes