ragaudit-cli 0.3.0__py3-none-any.whl
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.
- ragaudit/__init__.py +1 -0
- ragaudit/adapters/__init__.py +0 -0
- ragaudit/adapters/base.py +109 -0
- ragaudit/adapters/callable_adapter.py +15 -0
- ragaudit/cli.py +540 -0
- ragaudit/cli_easy.py +458 -0
- ragaudit/cli_v2.py +507 -0
- ragaudit/env.py +46 -0
- ragaudit/generate/__init__.py +0 -0
- ragaudit/generate/pipeline.py +486 -0
- ragaudit/generate/prompts.py +116 -0
- ragaudit/generate/testset_generator.py +146 -0
- ragaudit/generate/text.py +75 -0
- ragaudit/ingest/__init__.py +0 -0
- ragaudit/ingest/chunker.py +53 -0
- ragaudit/ingest/corpus.py +120 -0
- ragaudit/ingest/loader.py +49 -0
- ragaudit/ingest/versioning.py +92 -0
- ragaudit/llm/__init__.py +0 -0
- ragaudit/llm/client.py +86 -0
- ragaudit/llm/factory.py +70 -0
- ragaudit/llm/json_output.py +72 -0
- ragaudit/llm/mock.py +552 -0
- ragaudit/models/__init__.py +0 -0
- ragaudit/models/adapter.py +39 -0
- ragaudit/models/document.py +28 -0
- ragaudit/models/evaluation.py +70 -0
- ragaudit/models/question.py +152 -0
- ragaudit/models/run.py +44 -0
- ragaudit/models/scoring.py +82 -0
- ragaudit/models/testset.py +30 -0
- ragaudit/models/tune.py +56 -0
- ragaudit/report/__init__.py +0 -0
- ragaudit/report/generator.py +155 -0
- ragaudit/report/plain_summary.py +122 -0
- ragaudit/report/templates/_v2_tooltip.jinja +43 -0
- ragaudit/report/templates/generate_report.html.jinja +175 -0
- ragaudit/report/templates/report.html.jinja +252 -0
- ragaudit/report/templates/style.css +1095 -0
- ragaudit/report/templates/tune_report.html.jinja +158 -0
- ragaudit/report/templates/v2.css +286 -0
- ragaudit/report/v2.py +380 -0
- ragaudit/runner/__init__.py +0 -0
- ragaudit/runner/ablation.py +46 -0
- ragaudit/runner/evaluate.py +325 -0
- ragaudit/runner/executor.py +98 -0
- ragaudit/runner/locked.py +37 -0
- ragaudit/runner/summary.py +45 -0
- ragaudit/scaffold.py +252 -0
- ragaudit/scoring/__init__.py +0 -0
- ragaudit/scoring/correctness.py +94 -0
- ragaudit/scoring/groundedness.py +86 -0
- ragaudit/scoring/retrieval.py +42 -0
- ragaudit/scoring/text_match.py +51 -0
- ragaudit/scoring/verdict.py +14 -0
- ragaudit/storage/__init__.py +0 -0
- ragaudit/storage/db.py +81 -0
- ragaudit/storage/lock.py +36 -0
- ragaudit/storage/repository.py +216 -0
- ragaudit/storage/store.py +536 -0
- ragaudit/tune/__init__.py +7 -0
- ragaudit/tune/devset.py +16 -0
- ragaudit/tune/loop.py +208 -0
- ragaudit/tune/search_space.py +140 -0
- ragaudit/tune/strategies.py +197 -0
- ragaudit/yaml_io.py +199 -0
- ragaudit_cli-0.3.0.dist-info/METADATA +429 -0
- ragaudit_cli-0.3.0.dist-info/RECORD +71 -0
- ragaudit_cli-0.3.0.dist-info/WHEEL +4 -0
- ragaudit_cli-0.3.0.dist-info/entry_points.txt +2 -0
- ragaudit_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""The v2 store: questions, corpus versions, cached eval results, and tuning logs.
|
|
2
|
+
|
|
3
|
+
A separate SQLite file from v1's `ragaudit.db` (default `.ragaudit/store.db`)
|
|
4
|
+
so v1 tables and behaviour are never touched. Uses the stdlib `sqlite3`
|
|
5
|
+
module only, with a one-row `schema_version` table so a future schema
|
|
6
|
+
change can tell old stores apart from new ones. Opening a schema-1 store
|
|
7
|
+
migrates it (see `_migrate_v1_to_v2`).
|
|
8
|
+
|
|
9
|
+
Every read that could return test-split questions or results goes through
|
|
10
|
+
a guard: unless the final evaluation path in `ragaudit.runner.locked` has
|
|
11
|
+
unlocked the test split, it raises LockedTestSetError. Rejected questions
|
|
12
|
+
are exempt, since they are never scored and so can't leak anything.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sqlite3
|
|
17
|
+
from collections import Counter
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from ragaudit.models.evaluation import EvalSummary, QuestionResult
|
|
23
|
+
from ragaudit.models.question import Question, QuestionStatus, QuestionType, RejectReason, Split, split_for_chunks
|
|
24
|
+
from ragaudit.models.tune import Trial, TuneResult
|
|
25
|
+
from ragaudit.storage.lock import LockedTestSetError, is_test_split_unlocked
|
|
26
|
+
|
|
27
|
+
DEFAULT_STORE_PATH = Path(".ragaudit") / "store.db"
|
|
28
|
+
SCHEMA_VERSION = 2
|
|
29
|
+
"""2: splits are per chunk, and `questions.split` is NULL for cross-split rejects."""
|
|
30
|
+
|
|
31
|
+
_QUESTIONS_TABLE = """
|
|
32
|
+
CREATE TABLE IF NOT EXISTS questions (
|
|
33
|
+
question_id TEXT PRIMARY KEY,
|
|
34
|
+
chunk_ids_json TEXT NOT NULL,
|
|
35
|
+
chunk_hashes_json TEXT NOT NULL,
|
|
36
|
+
fact TEXT NOT NULL,
|
|
37
|
+
question TEXT NOT NULL,
|
|
38
|
+
gold_answer TEXT NOT NULL,
|
|
39
|
+
question_type TEXT NOT NULL,
|
|
40
|
+
split TEXT,
|
|
41
|
+
status TEXT NOT NULL,
|
|
42
|
+
reject_reason TEXT,
|
|
43
|
+
created_at TEXT NOT NULL,
|
|
44
|
+
source_timestamp TEXT,
|
|
45
|
+
generator_model TEXT NOT NULL,
|
|
46
|
+
verifier_model TEXT,
|
|
47
|
+
lexical_overlap REAL,
|
|
48
|
+
metadata_json TEXT NOT NULL
|
|
49
|
+
);
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
_QUESTIONS_INDEX = "CREATE INDEX IF NOT EXISTS idx_questions_split_status ON questions (split, status)"
|
|
53
|
+
|
|
54
|
+
SCHEMA = f"""
|
|
55
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
56
|
+
version INTEGER NOT NULL
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
{_QUESTIONS_TABLE.strip()}
|
|
60
|
+
{_QUESTIONS_INDEX};
|
|
61
|
+
|
|
62
|
+
CREATE TABLE IF NOT EXISTS corpus_versions (
|
|
63
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
64
|
+
index_version TEXT NOT NULL,
|
|
65
|
+
created_at TEXT NOT NULL,
|
|
66
|
+
chunk_map_json TEXT NOT NULL
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
CREATE TABLE IF NOT EXISTS generation_attempts (
|
|
70
|
+
source_key TEXT PRIMARY KEY,
|
|
71
|
+
created_at TEXT NOT NULL,
|
|
72
|
+
outcome TEXT NOT NULL
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
CREATE TABLE IF NOT EXISTS generate_runs (
|
|
76
|
+
run_id TEXT PRIMARY KEY,
|
|
77
|
+
created_at TEXT NOT NULL,
|
|
78
|
+
summary_json TEXT NOT NULL
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE TABLE IF NOT EXISTS eval_results (
|
|
82
|
+
question_id TEXT NOT NULL,
|
|
83
|
+
config_hash TEXT NOT NULL,
|
|
84
|
+
index_version TEXT NOT NULL,
|
|
85
|
+
split TEXT NOT NULL,
|
|
86
|
+
created_at TEXT NOT NULL,
|
|
87
|
+
data_json TEXT NOT NULL,
|
|
88
|
+
PRIMARY KEY (question_id, config_hash, index_version)
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
CREATE TABLE IF NOT EXISTS eval_runs (
|
|
92
|
+
run_id TEXT PRIMARY KEY,
|
|
93
|
+
created_at TEXT NOT NULL,
|
|
94
|
+
config_hash TEXT NOT NULL,
|
|
95
|
+
config_json TEXT NOT NULL,
|
|
96
|
+
index_version TEXT NOT NULL,
|
|
97
|
+
split TEXT NOT NULL,
|
|
98
|
+
mode TEXT NOT NULL,
|
|
99
|
+
summary_json TEXT NOT NULL
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
CREATE TABLE IF NOT EXISTS tune_runs (
|
|
103
|
+
tune_id TEXT PRIMARY KEY,
|
|
104
|
+
created_at TEXT NOT NULL,
|
|
105
|
+
finished_at TEXT,
|
|
106
|
+
strategy TEXT NOT NULL,
|
|
107
|
+
search_space_json TEXT NOT NULL,
|
|
108
|
+
result_json TEXT
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
CREATE TABLE IF NOT EXISTS tune_trials (
|
|
112
|
+
tune_id TEXT NOT NULL,
|
|
113
|
+
trial_index INTEGER NOT NULL,
|
|
114
|
+
created_at TEXT NOT NULL,
|
|
115
|
+
config_hash TEXT NOT NULL,
|
|
116
|
+
data_json TEXT NOT NULL,
|
|
117
|
+
PRIMARY KEY (tune_id, trial_index)
|
|
118
|
+
);
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
_QUESTION_COLUMNS = (
|
|
122
|
+
"question_id, chunk_ids_json, chunk_hashes_json, fact, question, gold_answer, question_type, split, "
|
|
123
|
+
"status, reject_reason, created_at, source_timestamp, generator_model, verifier_model, lexical_overlap, "
|
|
124
|
+
"metadata_json"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class StoreSchemaError(Exception):
|
|
129
|
+
"""Raised when the store was written by a newer, incompatible ragaudit."""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _require_unlocked_for(splits: set[Split], statuses: set[QuestionStatus]) -> None:
|
|
133
|
+
if Split.TEST not in splits or statuses == {QuestionStatus.REJECTED}:
|
|
134
|
+
return
|
|
135
|
+
if not is_test_split_unlocked():
|
|
136
|
+
raise LockedTestSetError(
|
|
137
|
+
"test-split questions and results are locked: they are only readable through the final "
|
|
138
|
+
"evaluation path (ragaudit.runner.locked). Use the dev split for tuning."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _question_to_row(q: Question) -> tuple:
|
|
143
|
+
return (
|
|
144
|
+
q.question_id,
|
|
145
|
+
json.dumps(q.chunk_ids),
|
|
146
|
+
json.dumps(q.chunk_hashes),
|
|
147
|
+
q.fact,
|
|
148
|
+
q.question,
|
|
149
|
+
q.gold_answer,
|
|
150
|
+
q.question_type.value,
|
|
151
|
+
q.split.value if q.split is not None else None,
|
|
152
|
+
q.status.value,
|
|
153
|
+
q.reject_reason,
|
|
154
|
+
q.created_at.isoformat(),
|
|
155
|
+
q.source_timestamp.isoformat() if q.source_timestamp else None,
|
|
156
|
+
q.generator_model,
|
|
157
|
+
q.verifier_model,
|
|
158
|
+
q.lexical_overlap,
|
|
159
|
+
json.dumps(q.metadata),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _row_to_question(r: tuple) -> Question:
|
|
164
|
+
return Question(
|
|
165
|
+
question_id=r[0],
|
|
166
|
+
chunk_ids=json.loads(r[1]),
|
|
167
|
+
chunk_hashes=json.loads(r[2]),
|
|
168
|
+
fact=r[3],
|
|
169
|
+
question=r[4],
|
|
170
|
+
gold_answer=r[5],
|
|
171
|
+
question_type=QuestionType(r[6]),
|
|
172
|
+
split=Split(r[7]) if r[7] is not None else None,
|
|
173
|
+
status=QuestionStatus(r[8]),
|
|
174
|
+
reject_reason=r[9],
|
|
175
|
+
created_at=datetime.fromisoformat(r[10]),
|
|
176
|
+
source_timestamp=datetime.fromisoformat(r[11]) if r[11] else None,
|
|
177
|
+
generator_model=r[12],
|
|
178
|
+
verifier_model=r[13],
|
|
179
|
+
lexical_overlap=r[14],
|
|
180
|
+
metadata=json.loads(r[15]),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Store:
|
|
185
|
+
"""A v2 store backed by one SQLite file. Use as a context manager to close it."""
|
|
186
|
+
|
|
187
|
+
def __init__(self, path: str | Path = DEFAULT_STORE_PATH) -> None:
|
|
188
|
+
self.path = path
|
|
189
|
+
if str(path) != ":memory:":
|
|
190
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
self._conn = sqlite3.connect(str(path))
|
|
192
|
+
self._conn.executescript(SCHEMA)
|
|
193
|
+
self._check_schema_version()
|
|
194
|
+
self._conn.commit()
|
|
195
|
+
|
|
196
|
+
def _check_schema_version(self) -> None:
|
|
197
|
+
row = self._conn.execute("SELECT version FROM schema_version").fetchone()
|
|
198
|
+
if row is None:
|
|
199
|
+
self._conn.execute("INSERT INTO schema_version (version) VALUES (?)", (SCHEMA_VERSION,))
|
|
200
|
+
elif row[0] == 1:
|
|
201
|
+
self._migrate_v1_to_v2()
|
|
202
|
+
elif row[0] > SCHEMA_VERSION:
|
|
203
|
+
raise StoreSchemaError(
|
|
204
|
+
f"store {self.path} has schema version {row[0]}, but this ragaudit only understands "
|
|
205
|
+
f"up to {SCHEMA_VERSION}. Upgrade ragaudit."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _migrate_v1_to_v2(self) -> None:
|
|
209
|
+
"""Re-split a schema-1 store per chunk.
|
|
210
|
+
|
|
211
|
+
Schema 1 split per question, so one chunk's questions could sit in
|
|
212
|
+
both splits. This recomputes every question's split from its chunk
|
|
213
|
+
ids; a multi-hop question whose chunks now disagree becomes a
|
|
214
|
+
cross_split reject with no split. The questions table is rebuilt to
|
|
215
|
+
allow that NULL. Cached results of questions whose split changed are
|
|
216
|
+
dropped (they're recomputed on the next evaluate); past eval and tune
|
|
217
|
+
run summaries are kept as history.
|
|
218
|
+
"""
|
|
219
|
+
conn = self._conn
|
|
220
|
+
conn.commit()
|
|
221
|
+
isolation_level = conn.isolation_level
|
|
222
|
+
conn.isolation_level = None # manage the transaction by hand so the table rebuild is all-or-nothing
|
|
223
|
+
try:
|
|
224
|
+
conn.execute("BEGIN")
|
|
225
|
+
rows = conn.execute(f"SELECT {_QUESTION_COLUMNS} FROM questions").fetchall()
|
|
226
|
+
conn.execute("DROP INDEX IF EXISTS idx_questions_split_status")
|
|
227
|
+
conn.execute("ALTER TABLE questions RENAME TO questions_v1")
|
|
228
|
+
conn.execute(_QUESTIONS_TABLE)
|
|
229
|
+
conn.execute(_QUESTIONS_INDEX)
|
|
230
|
+
moved = []
|
|
231
|
+
for row in rows:
|
|
232
|
+
row = list(row)
|
|
233
|
+
split = split_for_chunks(json.loads(row[1]))
|
|
234
|
+
if split is None:
|
|
235
|
+
row[8], row[9] = QuestionStatus.REJECTED.value, RejectReason.CROSS_SPLIT.value
|
|
236
|
+
new_split = split.value if split is not None else None
|
|
237
|
+
if new_split != row[7]:
|
|
238
|
+
moved.append(row[0])
|
|
239
|
+
row[7] = new_split
|
|
240
|
+
conn.execute(f"INSERT INTO questions ({_QUESTION_COLUMNS}) VALUES ({', '.join('?' * 16)})", row)
|
|
241
|
+
conn.execute("DROP TABLE questions_v1")
|
|
242
|
+
conn.executemany("DELETE FROM eval_results WHERE question_id = ?", [(qid,) for qid in moved])
|
|
243
|
+
conn.execute("UPDATE schema_version SET version = ?", (SCHEMA_VERSION,))
|
|
244
|
+
conn.execute("COMMIT")
|
|
245
|
+
except BaseException:
|
|
246
|
+
conn.execute("ROLLBACK")
|
|
247
|
+
raise
|
|
248
|
+
finally:
|
|
249
|
+
conn.isolation_level = isolation_level
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def schema_version(self) -> int:
|
|
253
|
+
return self._conn.execute("SELECT version FROM schema_version").fetchone()[0]
|
|
254
|
+
|
|
255
|
+
def close(self) -> None:
|
|
256
|
+
self._conn.close()
|
|
257
|
+
|
|
258
|
+
def __enter__(self) -> "Store":
|
|
259
|
+
return self
|
|
260
|
+
|
|
261
|
+
def __exit__(self, *exc_info) -> None:
|
|
262
|
+
self.close()
|
|
263
|
+
|
|
264
|
+
# --- questions ---------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
def save_questions(self, questions: Iterable[Question]) -> int:
|
|
267
|
+
"""Insert questions, keeping any existing row with the same id. Returns how many were new.
|
|
268
|
+
|
|
269
|
+
The id is a hash of the source content and fact, so an existing row
|
|
270
|
+
with the same id is the same question; the first write wins.
|
|
271
|
+
"""
|
|
272
|
+
inserted = 0
|
|
273
|
+
for q in questions:
|
|
274
|
+
cursor = self._conn.execute(
|
|
275
|
+
f"INSERT OR IGNORE INTO questions ({_QUESTION_COLUMNS}) VALUES ({', '.join('?' * 16)})",
|
|
276
|
+
_question_to_row(q),
|
|
277
|
+
)
|
|
278
|
+
inserted += cursor.rowcount
|
|
279
|
+
self._conn.commit()
|
|
280
|
+
return inserted
|
|
281
|
+
|
|
282
|
+
def load_questions(
|
|
283
|
+
self,
|
|
284
|
+
split: Split | None = None,
|
|
285
|
+
statuses: Iterable[QuestionStatus] = (QuestionStatus.ACTIVE,),
|
|
286
|
+
) -> list[Question]:
|
|
287
|
+
"""Load questions by split and status, oldest first.
|
|
288
|
+
|
|
289
|
+
split=None means both splits. Any request that could include
|
|
290
|
+
non-rejected test-split questions raises LockedTestSetError unless
|
|
291
|
+
the final evaluation path has unlocked the test split.
|
|
292
|
+
"""
|
|
293
|
+
status_set = set(statuses)
|
|
294
|
+
split_set = {split} if split is not None else set(Split)
|
|
295
|
+
_require_unlocked_for(split_set, status_set)
|
|
296
|
+
|
|
297
|
+
split_marks = ", ".join("?" * len(split_set))
|
|
298
|
+
status_marks = ", ".join("?" * len(status_set))
|
|
299
|
+
rows = self._conn.execute(
|
|
300
|
+
f"SELECT {_QUESTION_COLUMNS} FROM questions "
|
|
301
|
+
f"WHERE split IN ({split_marks}) AND status IN ({status_marks}) "
|
|
302
|
+
"ORDER BY created_at, question_id",
|
|
303
|
+
(*[s.value for s in split_set], *[s.value for s in status_set]),
|
|
304
|
+
).fetchall()
|
|
305
|
+
return [_row_to_question(r) for r in rows]
|
|
306
|
+
|
|
307
|
+
def dev_questions(self) -> list[Question]:
|
|
308
|
+
"""Active dev-split questions, with answers. The only question view `tune` is given."""
|
|
309
|
+
return self.load_questions(split=Split.DEV)
|
|
310
|
+
|
|
311
|
+
def question_ids(self, split: Split, status: QuestionStatus = QuestionStatus.ACTIVE) -> list[str]:
|
|
312
|
+
"""Ids only (no text, no answers), so safe for either split."""
|
|
313
|
+
rows = self._conn.execute(
|
|
314
|
+
"SELECT question_id FROM questions WHERE split = ? AND status = ? ORDER BY created_at, question_id",
|
|
315
|
+
(split.value, status.value),
|
|
316
|
+
).fetchall()
|
|
317
|
+
return [r[0] for r in rows]
|
|
318
|
+
|
|
319
|
+
def question_counts(self) -> dict:
|
|
320
|
+
"""Counts by split/status and by reject reason. No question text, so safe for either split.
|
|
321
|
+
|
|
322
|
+
Split "none" holds cross_split rejects, which belong to neither split.
|
|
323
|
+
"""
|
|
324
|
+
by_split_status: dict[str, dict[str, int]] = {
|
|
325
|
+
key: {st.value: 0 for st in QuestionStatus} for key in [s.value for s in Split] + ["none"]
|
|
326
|
+
}
|
|
327
|
+
for split, status, count in self._conn.execute(
|
|
328
|
+
"SELECT split, status, COUNT(*) FROM questions GROUP BY split, status"
|
|
329
|
+
):
|
|
330
|
+
by_split_status[split or "none"][status] = count
|
|
331
|
+
reasons = Counter(
|
|
332
|
+
{
|
|
333
|
+
reason: count
|
|
334
|
+
for reason, count in self._conn.execute(
|
|
335
|
+
"SELECT reject_reason, COUNT(*) FROM questions WHERE status = ? GROUP BY reject_reason",
|
|
336
|
+
(QuestionStatus.REJECTED.value,),
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
)
|
|
340
|
+
return {"by_split_status": by_split_status, "reject_reasons": dict(reasons)}
|
|
341
|
+
|
|
342
|
+
def set_question_status(
|
|
343
|
+
self, question_ids: Iterable[str], status: QuestionStatus, reject_reason: str | None = None
|
|
344
|
+
) -> int:
|
|
345
|
+
"""Set status (and reject_reason) on the given questions. Returns how many rows changed."""
|
|
346
|
+
changed = 0
|
|
347
|
+
for question_id in question_ids:
|
|
348
|
+
cursor = self._conn.execute(
|
|
349
|
+
"UPDATE questions SET status = ?, reject_reason = ? WHERE question_id = ?",
|
|
350
|
+
(status.value, reject_reason, question_id),
|
|
351
|
+
)
|
|
352
|
+
changed += cursor.rowcount
|
|
353
|
+
self._conn.commit()
|
|
354
|
+
return changed
|
|
355
|
+
|
|
356
|
+
def active_source_keys(self) -> set[str]:
|
|
357
|
+
"""Generation source keys ("chunk_id@hash" joined by "+") that already have active questions."""
|
|
358
|
+
keys = set()
|
|
359
|
+
for ids_json, hashes_json in self._conn.execute(
|
|
360
|
+
"SELECT chunk_ids_json, chunk_hashes_json FROM questions WHERE status = ?",
|
|
361
|
+
(QuestionStatus.ACTIVE.value,),
|
|
362
|
+
):
|
|
363
|
+
pairs = zip(json.loads(ids_json), json.loads(hashes_json))
|
|
364
|
+
keys.add("+".join(sorted(f"{chunk_id}@{chunk_hash}" for chunk_id, chunk_hash in pairs)))
|
|
365
|
+
return keys
|
|
366
|
+
|
|
367
|
+
# --- generation bookkeeping ---------------------------------------------
|
|
368
|
+
|
|
369
|
+
def attempted_source_keys(self) -> set[str]:
|
|
370
|
+
return {r[0] for r in self._conn.execute("SELECT source_key FROM generation_attempts")}
|
|
371
|
+
|
|
372
|
+
def record_generation_attempt(self, source_key: str, outcome: str) -> None:
|
|
373
|
+
self._conn.execute(
|
|
374
|
+
"INSERT OR REPLACE INTO generation_attempts (source_key, created_at, outcome) VALUES (?, ?, ?)",
|
|
375
|
+
(source_key, datetime.now(timezone.utc).isoformat(), outcome),
|
|
376
|
+
)
|
|
377
|
+
self._conn.commit()
|
|
378
|
+
|
|
379
|
+
def save_generate_run(self, run_id: str, created_at: datetime, summary_json: str) -> None:
|
|
380
|
+
self._conn.execute(
|
|
381
|
+
"INSERT OR REPLACE INTO generate_runs (run_id, created_at, summary_json) VALUES (?, ?, ?)",
|
|
382
|
+
(run_id, created_at.isoformat(), summary_json),
|
|
383
|
+
)
|
|
384
|
+
self._conn.commit()
|
|
385
|
+
|
|
386
|
+
def load_generate_run(self, run_id: str | None = None) -> str | None:
|
|
387
|
+
"""Summary JSON of a generate run (the latest if run_id is None), or None."""
|
|
388
|
+
if run_id is None:
|
|
389
|
+
row = self._conn.execute("SELECT summary_json FROM generate_runs ORDER BY created_at DESC LIMIT 1").fetchone()
|
|
390
|
+
else:
|
|
391
|
+
row = self._conn.execute("SELECT summary_json FROM generate_runs WHERE run_id = ?", (run_id,)).fetchone()
|
|
392
|
+
return row[0] if row else None
|
|
393
|
+
|
|
394
|
+
# --- corpus versions and staleness --------------------------------------
|
|
395
|
+
|
|
396
|
+
def latest_corpus_version(self) -> tuple[str, dict[str, str]] | None:
|
|
397
|
+
"""(index_version, chunk_id -> chunk_hash) of the last recorded corpus, or None."""
|
|
398
|
+
row = self._conn.execute(
|
|
399
|
+
"SELECT index_version, chunk_map_json FROM corpus_versions ORDER BY seq DESC LIMIT 1"
|
|
400
|
+
).fetchone()
|
|
401
|
+
return (row[0], json.loads(row[1])) if row else None
|
|
402
|
+
|
|
403
|
+
def record_corpus_version(self, index_version: str, chunk_map: dict[str, str]) -> None:
|
|
404
|
+
self._conn.execute(
|
|
405
|
+
"INSERT INTO corpus_versions (index_version, created_at, chunk_map_json) VALUES (?, ?, ?)",
|
|
406
|
+
(index_version, datetime.now(timezone.utc).isoformat(), json.dumps(chunk_map, sort_keys=True)),
|
|
407
|
+
)
|
|
408
|
+
self._conn.commit()
|
|
409
|
+
|
|
410
|
+
def corpus_version_count(self) -> int:
|
|
411
|
+
return self._conn.execute("SELECT COUNT(*) FROM corpus_versions").fetchone()[0]
|
|
412
|
+
|
|
413
|
+
def refresh_staleness(self, current_chunks: dict[str, str]) -> tuple[int, int]:
|
|
414
|
+
"""Mark active questions stale if any source chunk changed or vanished; revive stale ones whose sources are back.
|
|
415
|
+
|
|
416
|
+
Reads only ids and hashes, never question text or answers, so it
|
|
417
|
+
runs over both splits. Returns (marked_stale, revived).
|
|
418
|
+
"""
|
|
419
|
+
to_stale, to_revive = [], []
|
|
420
|
+
for question_id, ids_json, hashes_json, status in self._conn.execute(
|
|
421
|
+
"SELECT question_id, chunk_ids_json, chunk_hashes_json, status FROM questions WHERE status IN (?, ?)",
|
|
422
|
+
(QuestionStatus.ACTIVE.value, QuestionStatus.STALE.value),
|
|
423
|
+
):
|
|
424
|
+
sources_present = all(
|
|
425
|
+
current_chunks.get(chunk_id) == chunk_hash
|
|
426
|
+
for chunk_id, chunk_hash in zip(json.loads(ids_json), json.loads(hashes_json))
|
|
427
|
+
)
|
|
428
|
+
if status == QuestionStatus.ACTIVE.value and not sources_present:
|
|
429
|
+
to_stale.append(question_id)
|
|
430
|
+
elif status == QuestionStatus.STALE.value and sources_present:
|
|
431
|
+
to_revive.append(question_id)
|
|
432
|
+
self.set_question_status(to_stale, QuestionStatus.STALE)
|
|
433
|
+
self.set_question_status(to_revive, QuestionStatus.ACTIVE)
|
|
434
|
+
return len(to_stale), len(to_revive)
|
|
435
|
+
|
|
436
|
+
# --- evaluation results (the cache) ------------------------------------
|
|
437
|
+
|
|
438
|
+
def save_result(self, result: QuestionResult, config_hash: str, index_version: str) -> None:
|
|
439
|
+
"""Cache one result under (question_id, config_hash, index_version)."""
|
|
440
|
+
self._conn.execute(
|
|
441
|
+
"INSERT OR REPLACE INTO eval_results "
|
|
442
|
+
"(question_id, config_hash, index_version, split, created_at, data_json) VALUES (?, ?, ?, ?, ?, ?)",
|
|
443
|
+
(
|
|
444
|
+
result.question_id,
|
|
445
|
+
config_hash,
|
|
446
|
+
index_version,
|
|
447
|
+
result.split,
|
|
448
|
+
datetime.now(timezone.utc).isoformat(),
|
|
449
|
+
result.model_dump_json(),
|
|
450
|
+
),
|
|
451
|
+
)
|
|
452
|
+
self._conn.commit()
|
|
453
|
+
|
|
454
|
+
def load_result(self, question_id: str, config_hash: str, index_version: str) -> QuestionResult | None:
|
|
455
|
+
"""A cached result, or None. Test-split results are locked like test questions (judge rationales can echo answers)."""
|
|
456
|
+
row = self._conn.execute(
|
|
457
|
+
"SELECT split, data_json FROM eval_results WHERE question_id = ? AND config_hash = ? AND index_version = ?",
|
|
458
|
+
(question_id, config_hash, index_version),
|
|
459
|
+
).fetchone()
|
|
460
|
+
if row is None:
|
|
461
|
+
return None
|
|
462
|
+
_require_unlocked_for({Split(row[0])}, {QuestionStatus.ACTIVE})
|
|
463
|
+
return QuestionResult.model_validate_json(row[1])
|
|
464
|
+
|
|
465
|
+
def result_question_ids(self, config_hash: str, split: Split) -> set[str]:
|
|
466
|
+
"""Ids of questions with a cached result for this config at any index version. Ids only, so safe."""
|
|
467
|
+
rows = self._conn.execute(
|
|
468
|
+
"SELECT DISTINCT question_id FROM eval_results WHERE config_hash = ? AND split = ?",
|
|
469
|
+
(config_hash, split.value),
|
|
470
|
+
)
|
|
471
|
+
return {r[0] for r in rows}
|
|
472
|
+
|
|
473
|
+
def save_eval_run(self, summary: EvalSummary) -> None:
|
|
474
|
+
self._conn.execute(
|
|
475
|
+
"INSERT OR REPLACE INTO eval_runs "
|
|
476
|
+
"(run_id, created_at, config_hash, config_json, index_version, split, mode, summary_json) "
|
|
477
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
478
|
+
(
|
|
479
|
+
summary.run_id,
|
|
480
|
+
summary.created_at.isoformat(),
|
|
481
|
+
summary.config_hash,
|
|
482
|
+
json.dumps(summary.config, sort_keys=True),
|
|
483
|
+
summary.index_version,
|
|
484
|
+
summary.split,
|
|
485
|
+
summary.mode,
|
|
486
|
+
summary.model_dump_json(),
|
|
487
|
+
),
|
|
488
|
+
)
|
|
489
|
+
self._conn.commit()
|
|
490
|
+
|
|
491
|
+
def last_eval_run(self, config_hash: str, split: Split) -> EvalSummary | None:
|
|
492
|
+
"""The latest evaluation summary for this config and split (aggregates only, so safe for test)."""
|
|
493
|
+
row = self._conn.execute(
|
|
494
|
+
"SELECT summary_json FROM eval_runs WHERE config_hash = ? AND split = ? ORDER BY created_at DESC LIMIT 1",
|
|
495
|
+
(config_hash, split.value),
|
|
496
|
+
).fetchone()
|
|
497
|
+
return EvalSummary.model_validate_json(row[0]) if row else None
|
|
498
|
+
|
|
499
|
+
# --- tuning logs -------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
def save_tune_run(self, tune_id: str, created_at: datetime, strategy: str, search_space: dict) -> None:
|
|
502
|
+
self._conn.execute(
|
|
503
|
+
"INSERT OR REPLACE INTO tune_runs (tune_id, created_at, strategy, search_space_json) VALUES (?, ?, ?, ?)",
|
|
504
|
+
(tune_id, created_at.isoformat(), strategy, json.dumps(search_space)),
|
|
505
|
+
)
|
|
506
|
+
self._conn.commit()
|
|
507
|
+
|
|
508
|
+
def save_tune_trial(self, tune_id: str, trial: Trial) -> None:
|
|
509
|
+
self._conn.execute(
|
|
510
|
+
"INSERT OR REPLACE INTO tune_trials (tune_id, trial_index, created_at, config_hash, data_json) "
|
|
511
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
512
|
+
(tune_id, trial.index, trial.created_at.isoformat(), trial.config_hash, trial.model_dump_json()),
|
|
513
|
+
)
|
|
514
|
+
self._conn.commit()
|
|
515
|
+
|
|
516
|
+
def finish_tune_run(self, tune_id: str, finished_at: datetime, result_json: str) -> None:
|
|
517
|
+
self._conn.execute(
|
|
518
|
+
"UPDATE tune_runs SET finished_at = ?, result_json = ? WHERE tune_id = ?",
|
|
519
|
+
(finished_at.isoformat(), result_json, tune_id),
|
|
520
|
+
)
|
|
521
|
+
self._conn.commit()
|
|
522
|
+
|
|
523
|
+
def load_tune_result(self, tune_id: str | None = None) -> TuneResult | None:
|
|
524
|
+
"""A finished tune run (the latest if tune_id is None), or None. Test scores in it are aggregates only."""
|
|
525
|
+
query = "SELECT result_json FROM tune_runs WHERE result_json IS NOT NULL"
|
|
526
|
+
if tune_id is None:
|
|
527
|
+
row = self._conn.execute(f"{query} ORDER BY created_at DESC LIMIT 1").fetchone()
|
|
528
|
+
else:
|
|
529
|
+
row = self._conn.execute(f"{query} AND tune_id = ?", (tune_id,)).fetchone()
|
|
530
|
+
return TuneResult.model_validate_json(row[0]) if row else None
|
|
531
|
+
|
|
532
|
+
def load_tune_trials(self, tune_id: str) -> list[Trial]:
|
|
533
|
+
rows = self._conn.execute(
|
|
534
|
+
"SELECT data_json FROM tune_trials WHERE tune_id = ? ORDER BY trial_index", (tune_id,)
|
|
535
|
+
).fetchall()
|
|
536
|
+
return [Trial.model_validate_json(r[0]) for r in rows]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""`ragaudit tune`: closed-loop config search on the dev split, scored once on the locked test split.
|
|
2
|
+
|
|
3
|
+
Nothing in this package may import `ragaudit.storage.lock` or read the
|
|
4
|
+
store directly; it sees questions only through `DevSet` and test results
|
|
5
|
+
only as aggregate scores from `ragaudit.runner.locked`. A unit test
|
|
6
|
+
enforces this.
|
|
7
|
+
"""
|
ragaudit/tune/devset.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""The data-access layer `tune` uses: dev-split questions only."""
|
|
2
|
+
|
|
3
|
+
from ragaudit.models.question import Question, Split
|
|
4
|
+
from ragaudit.storage.store import Store
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DevSet:
|
|
8
|
+
"""Active dev-split questions, with their gold answers. Never contains a test question."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, store: Store) -> None:
|
|
11
|
+
self.questions: list[Question] = store.dev_questions()
|
|
12
|
+
if any(q.split != Split.DEV for q in self.questions):
|
|
13
|
+
raise AssertionError("DevSet received a non-dev question")
|
|
14
|
+
|
|
15
|
+
def __len__(self) -> int:
|
|
16
|
+
return len(self.questions)
|