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.
Files changed (71) hide show
  1. ragaudit/__init__.py +1 -0
  2. ragaudit/adapters/__init__.py +0 -0
  3. ragaudit/adapters/base.py +109 -0
  4. ragaudit/adapters/callable_adapter.py +15 -0
  5. ragaudit/cli.py +540 -0
  6. ragaudit/cli_easy.py +458 -0
  7. ragaudit/cli_v2.py +507 -0
  8. ragaudit/env.py +46 -0
  9. ragaudit/generate/__init__.py +0 -0
  10. ragaudit/generate/pipeline.py +486 -0
  11. ragaudit/generate/prompts.py +116 -0
  12. ragaudit/generate/testset_generator.py +146 -0
  13. ragaudit/generate/text.py +75 -0
  14. ragaudit/ingest/__init__.py +0 -0
  15. ragaudit/ingest/chunker.py +53 -0
  16. ragaudit/ingest/corpus.py +120 -0
  17. ragaudit/ingest/loader.py +49 -0
  18. ragaudit/ingest/versioning.py +92 -0
  19. ragaudit/llm/__init__.py +0 -0
  20. ragaudit/llm/client.py +86 -0
  21. ragaudit/llm/factory.py +70 -0
  22. ragaudit/llm/json_output.py +72 -0
  23. ragaudit/llm/mock.py +552 -0
  24. ragaudit/models/__init__.py +0 -0
  25. ragaudit/models/adapter.py +39 -0
  26. ragaudit/models/document.py +28 -0
  27. ragaudit/models/evaluation.py +70 -0
  28. ragaudit/models/question.py +152 -0
  29. ragaudit/models/run.py +44 -0
  30. ragaudit/models/scoring.py +82 -0
  31. ragaudit/models/testset.py +30 -0
  32. ragaudit/models/tune.py +56 -0
  33. ragaudit/report/__init__.py +0 -0
  34. ragaudit/report/generator.py +155 -0
  35. ragaudit/report/plain_summary.py +122 -0
  36. ragaudit/report/templates/_v2_tooltip.jinja +43 -0
  37. ragaudit/report/templates/generate_report.html.jinja +175 -0
  38. ragaudit/report/templates/report.html.jinja +252 -0
  39. ragaudit/report/templates/style.css +1095 -0
  40. ragaudit/report/templates/tune_report.html.jinja +158 -0
  41. ragaudit/report/templates/v2.css +286 -0
  42. ragaudit/report/v2.py +380 -0
  43. ragaudit/runner/__init__.py +0 -0
  44. ragaudit/runner/ablation.py +46 -0
  45. ragaudit/runner/evaluate.py +325 -0
  46. ragaudit/runner/executor.py +98 -0
  47. ragaudit/runner/locked.py +37 -0
  48. ragaudit/runner/summary.py +45 -0
  49. ragaudit/scaffold.py +252 -0
  50. ragaudit/scoring/__init__.py +0 -0
  51. ragaudit/scoring/correctness.py +94 -0
  52. ragaudit/scoring/groundedness.py +86 -0
  53. ragaudit/scoring/retrieval.py +42 -0
  54. ragaudit/scoring/text_match.py +51 -0
  55. ragaudit/scoring/verdict.py +14 -0
  56. ragaudit/storage/__init__.py +0 -0
  57. ragaudit/storage/db.py +81 -0
  58. ragaudit/storage/lock.py +36 -0
  59. ragaudit/storage/repository.py +216 -0
  60. ragaudit/storage/store.py +536 -0
  61. ragaudit/tune/__init__.py +7 -0
  62. ragaudit/tune/devset.py +16 -0
  63. ragaudit/tune/loop.py +208 -0
  64. ragaudit/tune/search_space.py +140 -0
  65. ragaudit/tune/strategies.py +197 -0
  66. ragaudit/yaml_io.py +199 -0
  67. ragaudit_cli-0.3.0.dist-info/METADATA +429 -0
  68. ragaudit_cli-0.3.0.dist-info/RECORD +71 -0
  69. ragaudit_cli-0.3.0.dist-info/WHEEL +4 -0
  70. ragaudit_cli-0.3.0.dist-info/entry_points.txt +2 -0
  71. 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)}"