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.
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/PKG-INFO +5 -1
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/judge/__init__.py +13 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/judge/llm.py +2 -1
- cc_transcript-9.0.1/cc_transcript/judge/similar.py +371 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/judge/verdicts.py +202 -47
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/signals.py +4 -4
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/pyproject.toml +6 -1
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/mining.rs +1 -1
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/Cargo.lock +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/Cargo.toml +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/LICENSE +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/README.md +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/__init__.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/__main__.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/_parser_rs.pyi +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/activity.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/backend.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/builders.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/cli.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/command.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/context.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/corrections.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/corrections_cli.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/cost.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/decisions.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/discovery.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/disktruth.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/evidence.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/extract/__init__.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/extract/correct.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/facts.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/filterspec.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/ids.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/__init__.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/candidates.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/confidence.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/engine.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/filterspec.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/formats.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/sourcekind.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/spec.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/mining/store.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/models.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/parser.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/py.typed +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/query.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/render.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/rust.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/sentiment/__init__.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/sentiment/buckets.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/sentiment/engine.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/sentiment/lexicon.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/sentiment/scorespec.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/store.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/cc_transcript/tools.py +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/Cargo.toml +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/data/afinn-en-165.tsv +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/data/domain_overrides.tsv +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/command.rs +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/event.rs +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/filter.rs +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/lexicon.rs +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/lib.rs +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/model.rs +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/protocol.rs +0 -0
- {cc_transcript-8.1.0 → cc_transcript-9.0.1}/rust/src/score.rs +0 -0
- {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:
|
|
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
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
``
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 = ?
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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")
|
|
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 = "
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|