cc-transcript 8.0.0__tar.gz → 9.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/PKG-INFO +6 -2
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/filterspec.py +4 -1
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/judge/__init__.py +13 -0
- cc_transcript-9.0.0/cc_transcript/judge/similar.py +371 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/judge/verdicts.py +202 -47
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/__init__.py +8 -1
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/signals.py +161 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/sourcekind.py +2 -1
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/spec.py +3 -1
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/pyproject.toml +7 -2
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/mining.rs +288 -2
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/protocol.rs +2 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/Cargo.lock +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/Cargo.toml +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/LICENSE +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/README.md +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/__init__.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/__main__.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/_parser_rs.pyi +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/activity.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/backend.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/builders.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/cli.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/command.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/context.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/corrections.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/corrections_cli.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/cost.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/decisions.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/discovery.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/disktruth.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/evidence.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/extract/__init__.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/extract/correct.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/facts.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/ids.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/judge/llm.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/candidates.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/confidence.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/engine.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/filterspec.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/formats.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/store.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/models.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/parser.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/py.typed +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/query.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/render.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/rust.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/sentiment/__init__.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/sentiment/buckets.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/sentiment/engine.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/sentiment/lexicon.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/sentiment/scorespec.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/store.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/tools.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/Cargo.toml +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/data/afinn-en-165.tsv +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/data/domain_overrides.tsv +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/command.rs +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/event.rs +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/filter.rs +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/lexicon.rs +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/lib.rs +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/model.rs +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/score.rs +0 -0
- {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/value.rs +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cc-transcript
|
|
3
|
-
Version:
|
|
3
|
+
Version: 9.0.0
|
|
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:
|
|
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'
|
|
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
|
|
@@ -70,10 +70,13 @@ INTERRUPT_MARKER_GROUPS: tuple[tuple[str, str], ...] = (("interrupt", r"^\s*\[Re
|
|
|
70
70
|
STOP_HOOK_GROUPS: tuple[tuple[str, str], ...] = (("stop_hook", r"Stop hook feedback:"),)
|
|
71
71
|
|
|
72
72
|
# Raw CC-injected protocol strings carried in tool-result content: the denial banner
|
|
73
|
-
# and the markers that wrap the user's verbatim instruction in a rejected tool use
|
|
73
|
+
# and the markers that wrap the user's verbatim instruction in a rejected tool use,
|
|
74
|
+
# and the banner pair that wraps an answered AskUserQuestion round.
|
|
74
75
|
DENIAL_PREFIX = "The user doesn't want to proceed with this tool use. The tool use was rejected"
|
|
75
76
|
USER_SAID_MARKER = "To tell you how to proceed, the user said:\n"
|
|
76
77
|
USER_SAID_TRAILER = "Note: The user's next message"
|
|
78
|
+
ANSWERED_PREFIX = "Your questions have been answered: "
|
|
79
|
+
ANSWERED_TRAILER = ". You can now continue with these answers in mind."
|
|
77
80
|
|
|
78
81
|
# Approve-and-advance directives: a user telling the agent to proceed/commit/push or
|
|
79
82
|
# to resume killed work. They follow an assistant turn but advance it rather than
|
|
@@ -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,
|
|
@@ -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
|
+
)
|