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,486 @@
|
|
|
1
|
+
"""v2 test-set generation: fact -> question -> de-wording -> checks -> independent verification.
|
|
2
|
+
|
|
3
|
+
Extends the v1 generate package (testset_generator.py is untouched). For
|
|
4
|
+
each chunk:
|
|
5
|
+
|
|
6
|
+
1. the generator extracts atomic facts, each with its gold answer;
|
|
7
|
+
2. the generator writes one question per fact, with relative time banned;
|
|
8
|
+
3. the generator rewrites the question so it doesn't copy the chunk's
|
|
9
|
+
wording, and lexical overlap is measured: above the threshold the
|
|
10
|
+
rewrite is retried once, then the question is rejected `too_lexical`;
|
|
11
|
+
4. a regex rejects any remaining relative-time expression
|
|
12
|
+
(`relative_time`). It runs before verification only because it's free;
|
|
13
|
+
5. a *different* verifier model checks the fact and answer are supported
|
|
14
|
+
by the chunk and the question is answerable and unambiguous.
|
|
15
|
+
|
|
16
|
+
Each question inherits its chunk's dev/test split (models/question.py).
|
|
17
|
+
Multi-hop questions are only drawn from chunk pairs in the same split; one
|
|
18
|
+
whose chunks span both splits is rejected `cross_split`.
|
|
19
|
+
|
|
20
|
+
Rejected questions are stored too (status `rejected` plus a reason), so
|
|
21
|
+
the report can show what was thrown away and why.
|
|
22
|
+
|
|
23
|
+
Generation is incremental (chunks that already have active questions, or
|
|
24
|
+
were already processed at the same content hash, are skipped) and
|
|
25
|
+
budgeted (--max-chunks, --max-llm-calls). Every run first syncs the corpus
|
|
26
|
+
version (ingest/versioning.py), marking questions on changed or deleted
|
|
27
|
+
chunks stale, so changed chunks get fresh questions. When a cap is hit the current
|
|
28
|
+
chunk's partial work is discarded, everything finished so far is already
|
|
29
|
+
saved, and the summary says how much is left.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
import unicodedata
|
|
34
|
+
import uuid
|
|
35
|
+
from collections import Counter, defaultdict
|
|
36
|
+
from datetime import datetime
|
|
37
|
+
|
|
38
|
+
from pydantic import BaseModel, Field
|
|
39
|
+
|
|
40
|
+
from ragaudit.generate import prompts
|
|
41
|
+
from ragaudit.generate.text import content_tokens, has_relative_time, jaccard, lexical_overlap
|
|
42
|
+
from ragaudit.ingest.corpus import chunk_hash, chunk_timestamp
|
|
43
|
+
from ragaudit.ingest.versioning import sync_corpus
|
|
44
|
+
from ragaudit.llm.factory import BudgetedCompleter, CallBudget, Completer, LLMBudgetExceeded
|
|
45
|
+
from ragaudit.llm.json_output import parse_json_object
|
|
46
|
+
from ragaudit.models.document import Chunk
|
|
47
|
+
from ragaudit.models.question import Question, QuestionStatus, RejectReason, split_for_chunks, utc_now
|
|
48
|
+
from ragaudit.storage.store import Store
|
|
49
|
+
|
|
50
|
+
DEFAULT_MAX_FACTS_PER_CHUNK = 3
|
|
51
|
+
DEFAULT_LEXICAL_THRESHOLD = 0.5
|
|
52
|
+
DEFAULT_MULTI_HOP_PAIRS = 10
|
|
53
|
+
MULTI_HOP_MIN_SIMILARITY = 0.15
|
|
54
|
+
MAX_CHUNKS_FOR_SIMILARITY_PAIRS = 2000
|
|
55
|
+
"""Above this, multi-hop only pairs adjacent chunks: all-pairs similarity is quadratic."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GenerateOptions(BaseModel):
|
|
59
|
+
max_facts_per_chunk: int = DEFAULT_MAX_FACTS_PER_CHUNK
|
|
60
|
+
lexical_threshold: float = DEFAULT_LEXICAL_THRESHOLD
|
|
61
|
+
multi_hop: bool = False
|
|
62
|
+
multi_hop_pairs: int = DEFAULT_MULTI_HOP_PAIRS
|
|
63
|
+
max_chunks: int | None = None
|
|
64
|
+
retry_processed: bool = False
|
|
65
|
+
"""Re-process chunks already attempted at this content hash that yielded no active question."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class GenerateSummary(BaseModel):
|
|
69
|
+
run_id: str
|
|
70
|
+
created_at: datetime
|
|
71
|
+
generator_model: str
|
|
72
|
+
verifier_model: str
|
|
73
|
+
corpus_chunks: int
|
|
74
|
+
index_version: str | None = None
|
|
75
|
+
corpus_diff: dict[str, int] = Field(default_factory=dict)
|
|
76
|
+
chunks_processed: int = 0
|
|
77
|
+
chunks_skipped: int = 0
|
|
78
|
+
chunks_remaining: int = 0
|
|
79
|
+
chunks_unparseable: int = 0
|
|
80
|
+
pairs_processed: int = 0
|
|
81
|
+
pairs_remaining: int = 0
|
|
82
|
+
generated: int = 0
|
|
83
|
+
"""New active questions from this run."""
|
|
84
|
+
multi_hop_generated: int = 0
|
|
85
|
+
rejected: int = 0
|
|
86
|
+
reject_reasons: dict[str, int] = Field(default_factory=dict)
|
|
87
|
+
split_counts: dict[str, int] = Field(default_factory=lambda: {"dev": 0, "test": 0})
|
|
88
|
+
duplicates: int = 0
|
|
89
|
+
"""Questions whose id was already in the store (same chunk content and fact)."""
|
|
90
|
+
lexical_overlaps: list[float] = Field(default_factory=list)
|
|
91
|
+
"""Final lexical overlap of every question that reached the overlap check this run."""
|
|
92
|
+
lexical_threshold: float = DEFAULT_LEXICAL_THRESHOLD
|
|
93
|
+
llm_calls: int = 0
|
|
94
|
+
stopped_reason: str | None = None
|
|
95
|
+
"""None when everything pending was processed; else "max_chunks" or "max_llm_calls"."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# --- response parsing ------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_parse_json_object = parse_json_object
|
|
102
|
+
|
|
103
|
+
MIN_FACT_WORDS = 3
|
|
104
|
+
SOURCE_REFERENCE_RE = re.compile(
|
|
105
|
+
r"\b(?:according to|based on|in|from|per)\s+(?:the|this|that|given|above|provided)"
|
|
106
|
+
r"(?:\s+(?:given|above|provided))?\s+(?:passage|text|document|context|article|excerpt|paragraph)s?\b"
|
|
107
|
+
r"|\b(?:the|this|that|given|above|provided)\s+(?:passage|excerpt)s?\b",
|
|
108
|
+
re.IGNORECASE,
|
|
109
|
+
)
|
|
110
|
+
"""A question that points at "the passage" can't be asked of a retrieval system, which has no passage yet."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _normalize(text: str) -> str:
|
|
114
|
+
return " ".join(text.lower().split()).strip(" .,;:!?\"'")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _answer_value(value) -> str | None:
|
|
118
|
+
"""Answers may come back as JSON numbers; a bool or anything else is malformed."""
|
|
119
|
+
if isinstance(value, bool):
|
|
120
|
+
return None
|
|
121
|
+
if isinstance(value, (int, float)):
|
|
122
|
+
return str(value)
|
|
123
|
+
return value.strip() if isinstance(value, str) and value.strip() else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_usable_fact(fact: str, answer: str) -> bool:
|
|
127
|
+
"""A real fact: a statement of a few words that contains its answer, not a bare value or a fragment."""
|
|
128
|
+
fact_norm, answer_norm = _normalize(fact), _normalize(answer)
|
|
129
|
+
return (
|
|
130
|
+
bool(answer_norm)
|
|
131
|
+
and len(fact_norm.split()) >= MIN_FACT_WORDS
|
|
132
|
+
and fact_norm != answer_norm
|
|
133
|
+
and re.search(rf"(?<![a-z0-9]){re.escape(answer_norm)}(?![a-z0-9])", fact_norm) is not None
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _scripts(text: str) -> set[str]:
|
|
138
|
+
"""Writing systems of the letters in text (LATIN, CJK, CYRILLIC, ...)."""
|
|
139
|
+
scripts = set()
|
|
140
|
+
for char in text:
|
|
141
|
+
if unicodedata.category(char).startswith("L"):
|
|
142
|
+
name = unicodedata.name(char, "")
|
|
143
|
+
scripts.add(name.split(" ")[0] if name else "UNKNOWN")
|
|
144
|
+
return scripts
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def question_problem(question: str, source_text: str) -> str | None:
|
|
148
|
+
"""Why a generated question can't be used as is, or None. Cheap checks run on every draft and rewrite."""
|
|
149
|
+
if "\n" in question.strip():
|
|
150
|
+
return "spans several lines (e.g. multiple-choice options)"
|
|
151
|
+
if SOURCE_REFERENCE_RE.search(question):
|
|
152
|
+
return "refers to the passage instead of naming its subject"
|
|
153
|
+
foreign = _scripts(question) - _scripts(source_text)
|
|
154
|
+
if foreign:
|
|
155
|
+
return f"uses a writing system the passage doesn't ({', '.join(sorted(foreign))})"
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def parse_facts_response(raw: str) -> list[dict[str, str]] | None:
|
|
160
|
+
"""Parse {"facts": [{"fact", "answer"}]}. None if malformed; malformed or unusable entries are dropped."""
|
|
161
|
+
data = _parse_json_object(raw)
|
|
162
|
+
if data is None or not isinstance(data.get("facts"), list):
|
|
163
|
+
return None
|
|
164
|
+
facts = []
|
|
165
|
+
for item in data["facts"]:
|
|
166
|
+
if isinstance(item, dict):
|
|
167
|
+
fact, answer = item.get("fact"), _answer_value(item.get("answer"))
|
|
168
|
+
if isinstance(fact, str) and answer is not None and is_usable_fact(fact, answer):
|
|
169
|
+
facts.append({"fact": fact.strip(), "answer": answer})
|
|
170
|
+
return facts
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def parse_question_response(raw: str) -> str | None:
|
|
174
|
+
"""Parse {"question": str}, tolerating a bare question string."""
|
|
175
|
+
data = _parse_json_object(raw)
|
|
176
|
+
if data is not None:
|
|
177
|
+
question = data.get("question")
|
|
178
|
+
return question.strip() if isinstance(question, str) and question.strip() else None
|
|
179
|
+
text = raw.strip().strip('"').strip()
|
|
180
|
+
if not text or text.startswith("{") or "\n" in text:
|
|
181
|
+
return None
|
|
182
|
+
return text
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse_verify_response(raw: str) -> dict | None:
|
|
186
|
+
data = _parse_json_object(raw)
|
|
187
|
+
if data is None:
|
|
188
|
+
return None
|
|
189
|
+
if not all(isinstance(data.get(key), bool) for key in ("supported", "answerable", "unambiguous")):
|
|
190
|
+
return None
|
|
191
|
+
return data
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# --- single steps ----------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _passages(chunks: list[Chunk]) -> list[str]:
|
|
198
|
+
return [chunk.text for chunk in chunks]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _captured(chunks: list[Chunk]) -> str | None:
|
|
202
|
+
stamps = [ts for ts in (chunk_timestamp(c) for c in chunks) if ts is not None]
|
|
203
|
+
return max(stamps).date().isoformat() if stamps else None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def extract_facts(client: Completer, chunk: Chunk, max_facts: int) -> list[dict[str, str]] | None:
|
|
207
|
+
raw = client.complete(prompts.FACTS_SYSTEM_PROMPT, prompts.build_facts_prompt(chunk.text, max_facts, _captured([chunk])))
|
|
208
|
+
facts = parse_facts_response(raw)
|
|
209
|
+
return facts[:max_facts] if facts is not None else None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _usable_question(question: str | None, chunks: list[Chunk]) -> str | None:
|
|
213
|
+
if question is None or question_problem(question, "\n\n".join(_passages(chunks))) is not None:
|
|
214
|
+
return None
|
|
215
|
+
return question
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def write_question(client: Completer, fact: str, answer: str, chunks: list[Chunk]) -> str | None:
|
|
219
|
+
"""The generator's question for a fact, or None if it is unparseable or fails `question_problem`."""
|
|
220
|
+
raw = client.complete(
|
|
221
|
+
prompts.QUESTION_SYSTEM_PROMPT, prompts.build_question_prompt(fact, answer, _passages(chunks), _captured(chunks))
|
|
222
|
+
)
|
|
223
|
+
return _usable_question(parse_question_response(raw), chunks)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def deword_question(client: Completer, question: str, chunks: list[Chunk], previous: str | None = None) -> str | None:
|
|
227
|
+
"""A rewrite of the question, or None (the caller keeps the draft) if it is unparseable or fails `question_problem`."""
|
|
228
|
+
raw = client.complete(prompts.DEWORD_SYSTEM_PROMPT, prompts.build_deword_prompt(question, _passages(chunks), previous))
|
|
229
|
+
return _usable_question(parse_question_response(raw), chunks)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def verify_question(verifier: Completer, fact: str, question: str, answer: str, chunks: list[Chunk]) -> str | None:
|
|
233
|
+
"""Return a reject reason, or None if the verifier accepts the question."""
|
|
234
|
+
raw = verifier.complete(
|
|
235
|
+
prompts.VERIFY_SYSTEM_PROMPT, prompts.build_verify_prompt(fact, question, answer, _passages(chunks))
|
|
236
|
+
)
|
|
237
|
+
verdict = parse_verify_response(raw)
|
|
238
|
+
if verdict is None:
|
|
239
|
+
return RejectReason.VERIFIER_UNPARSEABLE.value
|
|
240
|
+
if not verdict["supported"]:
|
|
241
|
+
return RejectReason.UNSUPPORTED.value
|
|
242
|
+
if not verdict["answerable"]:
|
|
243
|
+
return RejectReason.UNANSWERABLE.value
|
|
244
|
+
if not verdict["unambiguous"]:
|
|
245
|
+
return RejectReason.AMBIGUOUS.value
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def build_checked_question(
|
|
250
|
+
generator: Completer,
|
|
251
|
+
verifier: Completer,
|
|
252
|
+
chunks: list[Chunk],
|
|
253
|
+
fact: str,
|
|
254
|
+
answer: str,
|
|
255
|
+
draft: str,
|
|
256
|
+
lexical_threshold: float,
|
|
257
|
+
) -> tuple[Question, float]:
|
|
258
|
+
"""Run de-wording, the overlap check (with one retry), the relative-time check and verification.
|
|
259
|
+
|
|
260
|
+
Returns the Question (active or rejected) and its final lexical overlap.
|
|
261
|
+
"""
|
|
262
|
+
source_text = "\n\n".join(_passages(chunks))
|
|
263
|
+
cross_split = split_for_chunks([c.chunk_id for c in chunks]) is None
|
|
264
|
+
if cross_split:
|
|
265
|
+
# Chunks from both splits can never become an active question, so skip the paid checks.
|
|
266
|
+
question, overlap = draft, lexical_overlap(draft, source_text)
|
|
267
|
+
else:
|
|
268
|
+
question = deword_question(generator, draft, chunks) or draft
|
|
269
|
+
overlap = lexical_overlap(question, source_text)
|
|
270
|
+
if overlap > lexical_threshold:
|
|
271
|
+
retry = deword_question(generator, draft, chunks, previous=question)
|
|
272
|
+
if retry:
|
|
273
|
+
question, overlap = retry, lexical_overlap(retry, source_text)
|
|
274
|
+
|
|
275
|
+
if cross_split:
|
|
276
|
+
reject_reason = RejectReason.CROSS_SPLIT.value
|
|
277
|
+
elif overlap > lexical_threshold:
|
|
278
|
+
reject_reason = RejectReason.TOO_LEXICAL.value
|
|
279
|
+
elif has_relative_time(question):
|
|
280
|
+
reject_reason = RejectReason.RELATIVE_TIME.value
|
|
281
|
+
else:
|
|
282
|
+
reject_reason = verify_question(verifier, fact, question, answer, chunks)
|
|
283
|
+
|
|
284
|
+
stamps = [ts for ts in (chunk_timestamp(c) for c in chunks) if ts is not None]
|
|
285
|
+
built = Question.create(
|
|
286
|
+
chunk_ids=[c.chunk_id for c in chunks],
|
|
287
|
+
chunk_hashes=[chunk_hash(c) for c in chunks],
|
|
288
|
+
fact=fact,
|
|
289
|
+
question=question,
|
|
290
|
+
gold_answer=answer,
|
|
291
|
+
generator_model=generator.model,
|
|
292
|
+
verifier_model=verifier.model,
|
|
293
|
+
status=QuestionStatus.REJECTED if reject_reason else QuestionStatus.ACTIVE,
|
|
294
|
+
reject_reason=reject_reason,
|
|
295
|
+
source_timestamp=max(stamps) if stamps else None,
|
|
296
|
+
lexical_overlap=round(overlap, 4),
|
|
297
|
+
metadata={"draft_question": draft},
|
|
298
|
+
)
|
|
299
|
+
return built, overlap
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _questions_for_chunk(
|
|
303
|
+
generator: Completer, verifier: Completer, chunk: Chunk, options: GenerateOptions
|
|
304
|
+
) -> list[tuple[Question, float]] | None:
|
|
305
|
+
facts = extract_facts(generator, chunk, options.max_facts_per_chunk)
|
|
306
|
+
if facts is None:
|
|
307
|
+
return None
|
|
308
|
+
results = []
|
|
309
|
+
for item in facts:
|
|
310
|
+
draft = write_question(generator, item["fact"], item["answer"], [chunk])
|
|
311
|
+
if draft is None:
|
|
312
|
+
continue
|
|
313
|
+
results.append(
|
|
314
|
+
build_checked_question(
|
|
315
|
+
generator, verifier, [chunk], item["fact"], item["answer"], draft, options.lexical_threshold
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
return results
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _questions_for_pair(
|
|
322
|
+
generator: Completer, verifier: Completer, pair: list[Chunk], options: GenerateOptions
|
|
323
|
+
) -> list[tuple[Question, float]] | None:
|
|
324
|
+
raw = generator.complete(
|
|
325
|
+
prompts.MULTI_HOP_SYSTEM_PROMPT, prompts.build_multi_hop_prompt(_passages(pair), _captured(pair))
|
|
326
|
+
)
|
|
327
|
+
data = _parse_json_object(raw)
|
|
328
|
+
if data is not None:
|
|
329
|
+
data["answer"] = _answer_value(data.get("answer")) or ""
|
|
330
|
+
if data is None or not all(isinstance(data.get(k), str) and data[k].strip() for k in ("fact", "question", "answer")):
|
|
331
|
+
return None
|
|
332
|
+
if _usable_question(data["question"].strip(), pair) is None:
|
|
333
|
+
return None
|
|
334
|
+
return [
|
|
335
|
+
build_checked_question(
|
|
336
|
+
generator,
|
|
337
|
+
verifier,
|
|
338
|
+
pair,
|
|
339
|
+
data["fact"].strip(),
|
|
340
|
+
data["answer"].strip(),
|
|
341
|
+
data["question"].strip(),
|
|
342
|
+
options.lexical_threshold,
|
|
343
|
+
)
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# --- incremental bookkeeping -----------------------------------------------
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def source_key(chunks: list[Chunk]) -> str:
|
|
351
|
+
"""Identity of a generation source: chunk ids at specific content hashes."""
|
|
352
|
+
return "+".join(sorted(f"{c.chunk_id}@{chunk_hash(c)}" for c in chunks))
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def candidate_pairs(chunks: list[Chunk]) -> list[list[Chunk]]:
|
|
356
|
+
"""Chunk pairs for multi-hop questions: adjacent chunks of a document, then similar chunks across documents.
|
|
357
|
+
|
|
358
|
+
Only pairs whose chunks share a split: a cross-split pair could only
|
|
359
|
+
ever yield a cross_split reject, so it isn't worth an LLM call.
|
|
360
|
+
"""
|
|
361
|
+
scored: dict[tuple[str, str], tuple[float, list[Chunk]]] = {}
|
|
362
|
+
|
|
363
|
+
by_doc: dict[str, list[Chunk]] = defaultdict(list)
|
|
364
|
+
for chunk in chunks:
|
|
365
|
+
by_doc[chunk.doc_id].append(chunk)
|
|
366
|
+
for doc_chunks in by_doc.values():
|
|
367
|
+
doc_chunks.sort(key=lambda c: c.position)
|
|
368
|
+
for a, b in zip(doc_chunks, doc_chunks[1:]):
|
|
369
|
+
scored[(a.chunk_id, b.chunk_id)] = (1.0 + jaccard(content_tokens(a.text), content_tokens(b.text)), [a, b])
|
|
370
|
+
|
|
371
|
+
if len(chunks) <= MAX_CHUNKS_FOR_SIMILARITY_PAIRS:
|
|
372
|
+
tokens = [content_tokens(c.text) for c in chunks]
|
|
373
|
+
for i, a in enumerate(chunks):
|
|
374
|
+
for j in range(i + 1, len(chunks)):
|
|
375
|
+
b = chunks[j]
|
|
376
|
+
if a.doc_id == b.doc_id:
|
|
377
|
+
continue
|
|
378
|
+
similarity = jaccard(tokens[i], tokens[j])
|
|
379
|
+
if similarity >= MULTI_HOP_MIN_SIMILARITY:
|
|
380
|
+
scored[(a.chunk_id, b.chunk_id)] = (similarity, [a, b])
|
|
381
|
+
|
|
382
|
+
ranked = sorted(scored.items(), key=lambda item: (-item[1][0], item[0]))
|
|
383
|
+
return [pair for _, (_, pair) in ranked if split_for_chunks([c.chunk_id for c in pair]) is not None]
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _record(store: Store, summary: GenerateSummary, results: list[tuple[Question, float]], multi_hop: bool) -> None:
|
|
387
|
+
questions = [q for q, _ in results]
|
|
388
|
+
inserted_ids = {q.question_id for q in questions if store.save_questions([q])}
|
|
389
|
+
reasons: Counter = Counter(summary.reject_reasons)
|
|
390
|
+
for question, overlap in results:
|
|
391
|
+
summary.lexical_overlaps.append(round(overlap, 4))
|
|
392
|
+
if question.question_id not in inserted_ids:
|
|
393
|
+
summary.duplicates += 1
|
|
394
|
+
continue
|
|
395
|
+
if question.status == QuestionStatus.ACTIVE:
|
|
396
|
+
summary.generated += 1
|
|
397
|
+
summary.split_counts[question.split.value] += 1
|
|
398
|
+
if multi_hop:
|
|
399
|
+
summary.multi_hop_generated += 1
|
|
400
|
+
else:
|
|
401
|
+
summary.rejected += 1
|
|
402
|
+
reasons[question.reject_reason] += 1
|
|
403
|
+
summary.reject_reasons = dict(reasons)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _outcome(results: list[tuple[Question, float]]) -> str:
|
|
407
|
+
return "active" if any(q.status == QuestionStatus.ACTIVE for q, _ in results) else "no_active"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# --- orchestration ---------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def generate_questions(
|
|
414
|
+
store: Store,
|
|
415
|
+
chunks: list[Chunk],
|
|
416
|
+
generator: Completer,
|
|
417
|
+
verifier: Completer,
|
|
418
|
+
options: GenerateOptions | None = None,
|
|
419
|
+
budget: CallBudget | None = None,
|
|
420
|
+
) -> GenerateSummary:
|
|
421
|
+
"""Generate, check and verify questions for every chunk that needs them, saving as it goes."""
|
|
422
|
+
options = options or GenerateOptions()
|
|
423
|
+
budget = budget or CallBudget()
|
|
424
|
+
gen = BudgetedCompleter(generator, budget)
|
|
425
|
+
ver = BudgetedCompleter(verifier, budget)
|
|
426
|
+
summary = GenerateSummary(
|
|
427
|
+
run_id=str(uuid.uuid4()),
|
|
428
|
+
created_at=utc_now(),
|
|
429
|
+
generator_model=generator.model,
|
|
430
|
+
verifier_model=verifier.model,
|
|
431
|
+
corpus_chunks=len(chunks),
|
|
432
|
+
lexical_threshold=options.lexical_threshold,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
diff = sync_corpus(store, chunks)
|
|
436
|
+
summary.index_version = diff.index_version
|
|
437
|
+
summary.corpus_diff = diff.counts()
|
|
438
|
+
|
|
439
|
+
done = store.active_source_keys()
|
|
440
|
+
if not options.retry_processed:
|
|
441
|
+
done |= store.attempted_source_keys()
|
|
442
|
+
|
|
443
|
+
pending = [chunk for chunk in chunks if source_key([chunk]) not in done]
|
|
444
|
+
summary.chunks_skipped = len(chunks) - len(pending)
|
|
445
|
+
|
|
446
|
+
for index, chunk in enumerate(pending):
|
|
447
|
+
if options.max_chunks is not None and summary.chunks_processed >= options.max_chunks:
|
|
448
|
+
summary.stopped_reason = "max_chunks"
|
|
449
|
+
summary.chunks_remaining = len(pending) - index
|
|
450
|
+
break
|
|
451
|
+
try:
|
|
452
|
+
results = _questions_for_chunk(gen, ver, chunk, options)
|
|
453
|
+
except LLMBudgetExceeded:
|
|
454
|
+
summary.stopped_reason = "max_llm_calls"
|
|
455
|
+
summary.chunks_remaining = len(pending) - index
|
|
456
|
+
break
|
|
457
|
+
summary.chunks_processed += 1
|
|
458
|
+
if results is None:
|
|
459
|
+
summary.chunks_unparseable += 1
|
|
460
|
+
continue
|
|
461
|
+
_record(store, summary, results, multi_hop=False)
|
|
462
|
+
store.record_generation_attempt(source_key([chunk]), _outcome(results))
|
|
463
|
+
|
|
464
|
+
if options.multi_hop:
|
|
465
|
+
pending_pairs = [pair for pair in candidate_pairs(chunks) if source_key(pair) not in done]
|
|
466
|
+
pending_pairs = pending_pairs[: options.multi_hop_pairs]
|
|
467
|
+
if summary.stopped_reason is not None:
|
|
468
|
+
summary.pairs_remaining = len(pending_pairs)
|
|
469
|
+
else:
|
|
470
|
+
for index, pair in enumerate(pending_pairs):
|
|
471
|
+
try:
|
|
472
|
+
results = _questions_for_pair(gen, ver, pair, options)
|
|
473
|
+
except LLMBudgetExceeded:
|
|
474
|
+
summary.stopped_reason = "max_llm_calls"
|
|
475
|
+
summary.pairs_remaining = len(pending_pairs) - index
|
|
476
|
+
break
|
|
477
|
+
summary.pairs_processed += 1
|
|
478
|
+
if results is None:
|
|
479
|
+
store.record_generation_attempt(source_key(pair), "no_question")
|
|
480
|
+
continue
|
|
481
|
+
_record(store, summary, results, multi_hop=True)
|
|
482
|
+
store.record_generation_attempt(source_key(pair), _outcome(results))
|
|
483
|
+
|
|
484
|
+
summary.llm_calls = budget.used
|
|
485
|
+
store.save_generate_run(summary.run_id, summary.created_at, summary.model_dump_json())
|
|
486
|
+
return summary
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Prompts for v2 generation, and the user-prompt builders that pair with them.
|
|
2
|
+
|
|
3
|
+
User prompts use a fixed "Label: value" header followed by numbered
|
|
4
|
+
passages, so the offline MockLLM can parse them the same way every time.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
FACTS_SYSTEM_PROMPT = (
|
|
8
|
+
"You extract atomic, verifiable facts from a passage, for building a retrieval test set. "
|
|
9
|
+
"Each fact must be a single self-contained statement that is stated explicitly in the passage, "
|
|
10
|
+
"is specific to it (not general knowledge), and has a short, unambiguous answer: a name, number, "
|
|
11
|
+
"date, duration, place or short phrase taken from the passage. Write each fact so it makes sense "
|
|
12
|
+
"on its own, naming its subject instead of using pronouns. Never invent or infer anything that is "
|
|
13
|
+
"not written in the passage.\n"
|
|
14
|
+
"\n"
|
|
15
|
+
"Each fact is a full sentence, and its answer is copied word for word from that sentence. When the "
|
|
16
|
+
"passage gives several values for the same thing (two months, a list of names), keep them together in "
|
|
17
|
+
"one fact whose answer is the whole list, so every question has exactly one correct answer.\n"
|
|
18
|
+
"\n"
|
|
19
|
+
'Example: {"fact": "The Zentrix vault in Pellow opens every Thursday.", "answer": "every Thursday"}\n'
|
|
20
|
+
"\n"
|
|
21
|
+
"Respond with strict JSON only, no prose, no markdown fences, in the form: "
|
|
22
|
+
'{"facts": [{"fact": str, "answer": str}, ...]}'
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
QUESTION_SYSTEM_PROMPT = (
|
|
26
|
+
"You write one question that tests a single fact, for a retrieval test set. The question must be "
|
|
27
|
+
"answerable from the fact, have exactly one correct answer (the given answer), and name its subject "
|
|
28
|
+
"explicitly so it is unambiguous without the passage. Never refer to \"the passage\", \"the document\" "
|
|
29
|
+
"or \"the text\", and never include the answer in the question.\n"
|
|
30
|
+
"\n"
|
|
31
|
+
"Never use relative time words such as yesterday, today, tomorrow, now, currently, recently, lately, "
|
|
32
|
+
"latest, last week, this year or \"two years ago\". If time matters, use absolute dates or years; "
|
|
33
|
+
"the passage's capture date is given when known.\n"
|
|
34
|
+
"\n"
|
|
35
|
+
'Respond with strict JSON only, no prose, no markdown fences, in the form: {"question": str}'
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
DEWORD_SYSTEM_PROMPT = (
|
|
39
|
+
"You rewrite a test question so it does not reuse the distinctive wording of the source passage. "
|
|
40
|
+
"Phrase it the way a real user who half-remembers the topic would ask: use their own everyday words, "
|
|
41
|
+
"synonyms and a different sentence structure. Keep the exact meaning, keep names needed to make it "
|
|
42
|
+
"unambiguous, keep exactly one correct answer, and do not include the answer. Never introduce "
|
|
43
|
+
"relative time words (now, currently, recently, latest, last year, ...).\n"
|
|
44
|
+
"\n"
|
|
45
|
+
'Respond with strict JSON only, no prose, no markdown fences, in the form: {"question": str}'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
VERIFY_SYSTEM_PROMPT = (
|
|
49
|
+
"You are an independent verifier for a retrieval test set. Most generated questions have at least one "
|
|
50
|
+
"problem; your job is to find it. Given source passage(s), a fact, a question and a gold answer, check "
|
|
51
|
+
"three things strictly:\n"
|
|
52
|
+
"1. supported: the fact AND the gold answer are explicitly supported by the passage(s).\n"
|
|
53
|
+
"2. answerable: the question can be answered from the passage(s), and the gold answer is the complete "
|
|
54
|
+
"answer to it.\n"
|
|
55
|
+
"3. unambiguous: the question has exactly one correct answer and names its subject clearly enough "
|
|
56
|
+
"that a reader without the passage knows what is being asked. It is NOT unambiguous if the passage(s) "
|
|
57
|
+
"give more than one value that fits (for example two months, two dates or a list) and the gold answer "
|
|
58
|
+
"names only one, if it points at \"the passage\" or \"the text\" instead of naming its subject, or if "
|
|
59
|
+
"it is not written entirely in the passage's language.\n"
|
|
60
|
+
"\n"
|
|
61
|
+
"First explain your checks in \"reason\", then give the verdicts. Respond with strict JSON only, no "
|
|
62
|
+
"prose, no markdown fences, in the form: "
|
|
63
|
+
'{"reason": str, "supported": bool, "answerable": bool, "unambiguous": bool}'
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
MULTI_HOP_SYSTEM_PROMPT = (
|
|
67
|
+
"You write one multi-hop test question that can only be answered by combining information from BOTH "
|
|
68
|
+
"passages: neither passage alone is enough. Also state the combined fact it tests and the short gold "
|
|
69
|
+
"answer. Name subjects explicitly, never refer to \"the passage\", never include the answer in the "
|
|
70
|
+
"question, and never use relative time words (now, currently, recently, latest, last year, ...).\n"
|
|
71
|
+
"\n"
|
|
72
|
+
"Respond with strict JSON only, no prose, no markdown fences, in the form: "
|
|
73
|
+
'{"fact": str, "question": str, "answer": str}'
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _one_line(value: str) -> str:
|
|
78
|
+
return " ".join(value.split())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _passages_block(passages: list[str]) -> str:
|
|
82
|
+
if len(passages) == 1:
|
|
83
|
+
return f"Passage:\n{passages[0]}"
|
|
84
|
+
return "\n\n".join(f"Passage {i}:\n{text}" for i, text in enumerate(passages, start=1))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_facts_prompt(passage: str, max_facts: int, captured: str | None) -> str:
|
|
88
|
+
return f"Captured: {captured or 'unknown'}\nMaximum facts: {max_facts}\n\n{_passages_block([passage])}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_question_prompt(fact: str, answer: str, passages: list[str], captured: str | None) -> str:
|
|
92
|
+
return (
|
|
93
|
+
f"Captured: {captured or 'unknown'}\nFact: {_one_line(fact)}\nAnswer: {_one_line(answer)}\n\n"
|
|
94
|
+
f"{_passages_block(passages)}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_deword_prompt(question: str, passages: list[str], previous_attempt: str | None = None) -> str:
|
|
99
|
+
header = f"Question: {_one_line(question)}\n"
|
|
100
|
+
if previous_attempt is not None:
|
|
101
|
+
header += (
|
|
102
|
+
f"Previous rewrite (still too close to the passage wording, change it more): "
|
|
103
|
+
f"{_one_line(previous_attempt)}\n"
|
|
104
|
+
)
|
|
105
|
+
return f"{header}\n{_passages_block(passages)}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_verify_prompt(fact: str, question: str, gold_answer: str, passages: list[str]) -> str:
|
|
109
|
+
return (
|
|
110
|
+
f"Fact: {_one_line(fact)}\nQuestion: {_one_line(question)}\nGold answer: {_one_line(gold_answer)}\n\n"
|
|
111
|
+
f"{_passages_block(passages)}"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_multi_hop_prompt(passages: list[str], captured: str | None) -> str:
|
|
116
|
+
return f"Captured: {captured or 'unknown'}\n\n{_passages_block(passages)}"
|