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
ragaudit/llm/mock.py ADDED
@@ -0,0 +1,552 @@
1
+ """A deterministic, offline stand-in for an LLM, used by tests and the offline v2 demo.
2
+
3
+ `MockLLM` answers every prompt ragaudit sends (fact extraction, question
4
+ writing, de-wording, verification, multi-hop, the correctness and
5
+ groundedness judges, tuning proposals) and the generic "Context: ...
6
+ Question: ..." answering prompt the demo adapter uses. Same input, same
7
+ output, no network. It works with sentence splitting, regexes and a small
8
+ synonym table, so its questions read like terse search-box queries rather
9
+ than polished prose.
10
+
11
+ It is deliberately imperfect, the way a real generator is, so the checks
12
+ downstream have something to catch:
13
+
14
+ - about one fact in ten gets a wrong number in its gold answer (the
15
+ verifier should reject it as `unsupported`);
16
+ - its first de-wording attempt is a light synonym swap that usually
17
+ still copies the passage (forcing the retry), and about one question in
18
+ nine resists rewording entirely (`too_lexical`);
19
+ - it copies relative time phrases ("last quarter") from the passage
20
+ instead of converting them (the relative-time validator should reject
21
+ those).
22
+
23
+ It has no world knowledge: asked to answer without context, it says it
24
+ doesn't know.
25
+ """
26
+
27
+ import hashlib
28
+ import json
29
+ import re
30
+
31
+ from ragaudit.generate import prompts
32
+ from ragaudit.generate.text import STOPWORDS, content_tokens, find_relative_time, tokenize
33
+
34
+ NO_CONTEXT_MARKER = "using only your own internal knowledge"
35
+ PROPOSER_MARKER = "You are tuning a RAG pipeline"
36
+ LEAD_ANSWER_MARKER = "first sentence"
37
+
38
+ _MONTHS = "January|February|March|April|May|June|July|August|September|October|November|December"
39
+ _DATE_RE = re.compile(
40
+ rf"\b(?:(?:{_MONTHS})\s+\d{{1,2}},\s*\d{{4}}|(?:{_MONTHS})\s+\d{{4}}|\d{{4}}-\d{{2}}-\d{{2}})\b"
41
+ )
42
+ _TIME_RE = re.compile(r"(?<![\d:])\d{1,2}:\d{2}(?![\d:])")
43
+ _YEAR_RE = re.compile(r"(?:19|20)\d{2}")
44
+ _UNITS = (
45
+ "percent|business days|calendar days|days?|hours?|minutes?|weeks?|months?|years?|seconds?|"
46
+ "kilometres|kilometers|metres|meters|centimetres|km/h|kWh|km|kg|euros?|dollars?|"
47
+ "people|passengers|stops|trams|buses|vehicles|technicians|controllers|drivers|employees|staff|"
48
+ "members|units|seats|routes|lines|boardings|trips"
49
+ )
50
+ _NUMBER_RE = re.compile(
51
+ rf"(?<![A-Za-z0-9.,-])(?:[$€£]\s?)?\d+(?:,\d{{3}})*(?:\.\d+)?(?:\s(?:million|billion))?(?:\s?%|\s(?:{_UNITS})\b)?(?![\d,.]\d)"
52
+ )
53
+ _NAME_RE = re.compile(r"\b[A-Z][a-zA-Z0-9&'-]*(?:\s+(?:of\s+|the\s+)?[A-Z0-9][a-zA-Z0-9&'-]*)*")
54
+ _SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9\"'(])")
55
+ _WH_WORDS = {"what", "which", "who", "whom", "whose", "when", "where", "why", "how"}
56
+ _OPENERS = ("quick q: ", "remind me, ", "anyone know: ", "trying to recall: ")
57
+ _WEEKDAYS = {"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}
58
+
59
+ _SYNONYMS = {
60
+ "established": "set up",
61
+ "founded": "started",
62
+ "opened": "started running",
63
+ "employs": "has on staff",
64
+ "employ": "have on staff",
65
+ "governed": "overseen",
66
+ "chair": "head",
67
+ "held": "happen",
68
+ "ridership": "passenger numbers",
69
+ "reached": "hit",
70
+ "carried": "moved",
71
+ "busiest": "most crowded",
72
+ "handles": "sees",
73
+ "publishes": "puts out",
74
+ "timetables": "schedules",
75
+ "answers": "picks up",
76
+ "property": "items",
77
+ "oldest": "first",
78
+ "runs": "goes",
79
+ "run": "come",
80
+ "leaves": "departs",
81
+ "departs": "heads out",
82
+ "journey": "ride",
83
+ "trip": "ride",
84
+ "takes": "lasts",
85
+ "resignalled": "got new signals",
86
+ "cost": "price",
87
+ "costs": "is priced at",
88
+ "closed": "shut",
89
+ "maintained": "looked after",
90
+ "floods": "goes underwater",
91
+ "replacement": "backup",
92
+ "standby": "reserve",
93
+ "connects": "links",
94
+ "long": "in length",
95
+ "quarter": "three months",
96
+ "performance": "punctuality",
97
+ "caused": "due",
98
+ "exclusively": "only",
99
+ "limited": "capped",
100
+ "newest": "most recent",
101
+ "built": "constructed",
102
+ "operate": "run",
103
+ "section": "stretch",
104
+ "uses": "relies on",
105
+ "smallest": "tiniest",
106
+ "trialling": "testing",
107
+ "fare": "ticket",
108
+ "valid": "good",
109
+ "transfers": "changes",
110
+ "pass": "ticket",
111
+ "pay": "are charged",
112
+ "travel": "ride",
113
+ "penalty": "fine",
114
+ "issued": "handed out",
115
+ "accepted": "taken",
116
+ "capped": "limited",
117
+ "spending": "charges",
118
+ "largest": "biggest",
119
+ "space": "room",
120
+ "houses": "contains",
121
+ "stores": "parks",
122
+ "extended": "enlarged",
123
+ "towed": "hauled",
124
+ "garage": "depot",
125
+ "charging": "plug-in",
126
+ "converted": "switched",
127
+ "fleet": "vehicles",
128
+ "consists": "is made up",
129
+ "scheduled": "planned",
130
+ "retirement": "withdrawal",
131
+ "carries": "holds",
132
+ "seated": "sitting",
133
+ "battery": "cell pack",
134
+ "range": "distance per charge",
135
+ "ordered": "bought",
136
+ "order": "purchase",
137
+ "worth": "valued at",
138
+ "receives": "gets",
139
+ "inspection": "check",
140
+ "inspections": "checks",
141
+ "overhaul": "rebuild",
142
+ "reprofiled": "reshaped",
143
+ "carried out": "done",
144
+ "technicians": "engineers",
145
+ "inspected": "checked",
146
+ "replaced": "swapped",
147
+ "warranty": "guarantee",
148
+ "access": "entry",
149
+ "raised": "lifted",
150
+ "assistance": "help",
151
+ "book": "reserve",
152
+ "ramp": "slope",
153
+ "announcements": "callouts",
154
+ "monitors": "watches",
155
+ "staffed": "crewed",
156
+ "controllers": "dispatchers",
157
+ "recorded": "logged",
158
+ "collisions": "crashes",
159
+ "training": "instruction",
160
+ "reassessment": "re-test",
161
+ "report": "flag",
162
+ "tested": "checked",
163
+ "probation": "trial period",
164
+ "entitled": "allowed",
165
+ "leave": "holiday",
166
+ "shifts": "duties",
167
+ "break": "pause",
168
+ "discount": "reduction",
169
+ "claims": "requests",
170
+ "submitted": "sent in",
171
+ "approval": "sign-off",
172
+ "paid": "reimbursed",
173
+ "lengthen": "extend",
174
+ "adding": "with",
175
+ "construction": "building work",
176
+ "budget": "funding",
177
+ "expects": "forecasts",
178
+ "redesign": "rebuild",
179
+ "merge": "combine",
180
+ "approved": "signed off",
181
+ "replace": "swap out",
182
+ "estimates": "reckons",
183
+ "energy": "power",
184
+ "every": "each",
185
+ "must": "have to",
186
+ "within": "inside",
187
+ "maximum": "most",
188
+ "minimum": "least",
189
+ "annual": "yearly",
190
+ "customer": "client",
191
+ "employees": "staff",
192
+ }
193
+
194
+
195
+ def _h(text: str) -> int:
196
+ return int(hashlib.sha256(text.encode("utf-8")).hexdigest()[:8], 16)
197
+
198
+
199
+ def _field(user: str, label: str) -> str | None:
200
+ match = re.search(rf"^{re.escape(label)}: (.*)$", user, re.MULTILINE)
201
+ return match.group(1).strip() if match else None
202
+
203
+
204
+ def _passages(user: str) -> list[str]:
205
+ numbered = re.split(r"^Passage \d+:\n", user, flags=re.MULTILINE)
206
+ if len(numbered) > 1:
207
+ return [p.strip() for p in numbered[1:]]
208
+ single = user.split("Passage:\n", 1)
209
+ return [single[1].strip()] if len(single) == 2 else []
210
+
211
+
212
+ def _sentences(text: str) -> list[str]:
213
+ lines = [line.strip() for line in text.splitlines() if line.strip() and not line.strip().startswith("#")]
214
+ joined = " ".join(lines)
215
+ return [s.strip() for s in _SENTENCE_SPLIT_RE.split(joined) if s.strip()]
216
+
217
+
218
+ def _normalize_text(text: str) -> str:
219
+ return " ".join(tokenize(text))
220
+
221
+
222
+ def _contains(haystack: str, needle: str) -> bool:
223
+ needle_norm = _normalize_text(needle)
224
+ return bool(needle_norm) and f" {needle_norm} " in f" {_normalize_text(haystack)} "
225
+
226
+
227
+ def _is_name_number(sentence: str, start: int) -> bool:
228
+ """A number right after a capitalized word ("Line 2", "Varda T-40") is part of a name."""
229
+ before = sentence[:start].rstrip()
230
+ previous_word = before.split()[-1] if before.split() else ""
231
+ return bool(previous_word) and previous_word[0].isupper() and before == sentence[:start].rstrip()
232
+
233
+
234
+ def _answer_span(sentence: str) -> str | None:
235
+ """A short answer span: a date, a time, a number (with unit), else the last non-initial name."""
236
+ for regex in (_DATE_RE, _TIME_RE):
237
+ match = regex.search(sentence)
238
+ if match:
239
+ return match.group(0)
240
+ for match in _NUMBER_RE.finditer(sentence):
241
+ if not _is_name_number(sentence, match.start()):
242
+ return match.group(0).strip()
243
+ names = [
244
+ m.group(0)
245
+ for m in _NAME_RE.finditer(sentence)
246
+ if m.start() > 0 and not m.group(0)[0].isdigit() and not m.group(0).endswith("'s")
247
+ ]
248
+ return names[-1] if names else None
249
+
250
+
251
+ def _perturb_number(answer: str) -> str:
252
+ """Bump the last digit run by one: the mock generator's deliberate hallucination."""
253
+ last = list(re.finditer(r"\d+", answer))[-1]
254
+ bumped = str(int(last.group(0)) + 1).zfill(len(last.group(0)))
255
+ return answer[: last.start()] + bumped + answer[last.end() :]
256
+
257
+
258
+ def _extract_facts(passage: str, max_facts: int) -> list[dict[str, str]]:
259
+ facts = []
260
+ for sentence in _sentences(passage):
261
+ if len(sentence.split()) < 6:
262
+ continue
263
+ answer = _answer_span(sentence)
264
+ if answer is None or len(answer) >= len(sentence) * 0.5:
265
+ continue
266
+ fact = sentence
267
+ if re.search(r"\d", answer) and _h(f"perturb:{sentence}") % 10 == 0:
268
+ wrong = _perturb_number(answer)
269
+ fact = sentence.replace(answer, wrong, 1)
270
+ answer = wrong
271
+ facts.append({"fact": fact, "answer": answer})
272
+ if len(facts) >= max_facts:
273
+ break
274
+ return facts
275
+
276
+
277
+ def _write_question(fact: str, answer: str) -> str:
278
+ """The mock's first draft: the fact with its answer replaced by a wh-word. Copies the wording on purpose."""
279
+ wh = "when" if _DATE_RE.fullmatch(answer) else "what"
280
+ body = fact.replace(answer, wh, 1).rstrip(" .!")
281
+ return body[0].upper() + body[1:] + "?"
282
+
283
+
284
+ def _swap(word: str) -> str:
285
+ return _SYNONYMS.get(word.lower(), word)
286
+
287
+
288
+ def _mild_paraphrase(question: str) -> str:
289
+ out = []
290
+ for word in question.rstrip("?").split():
291
+ bare = word.strip(",.;:")
292
+ if bare.lower() in {"the", "a", "an"}:
293
+ continue
294
+ out.append(_swap(bare) if bare.lower() in _SYNONYMS else word)
295
+ text = " ".join(out)
296
+ return text[0].upper() + text[1:] + "?"
297
+
298
+
299
+ def _ask(fact: str, answer: str) -> str:
300
+ """How a user would ask for this kind of answer."""
301
+ lowered = answer.lower()
302
+ before = fact[: fact.find(answer)].lower().split()
303
+ if _TIME_RE.fullmatch(answer):
304
+ return "what time"
305
+ if _DATE_RE.fullmatch(answer):
306
+ return "what date"
307
+ if _YEAR_RE.fullmatch(answer):
308
+ return "which year"
309
+ if lowered in _WEEKDAYS:
310
+ return "which day"
311
+ if lowered[:1] in "$€£" or re.search(r"\b(euros?|dollars?)$", lowered):
312
+ return "how much"
313
+ if lowered.endswith(("%", "percent")):
314
+ return "what percentage"
315
+ if re.search(r"\b(minutes?|hours?|days?|weeks?|months?|years?|seconds?)$", lowered):
316
+ return "how often" if before and before[-1] == "every" else "how long"
317
+ if re.search(r"\b(kilometres|kilometers|metres|meters|centimetres|km)$", lowered):
318
+ return "how long"
319
+ unit = re.search(r"\d\s+([a-z]+)$", lowered)
320
+ if unit:
321
+ return f"how many {unit.group(1)}"
322
+ if re.search(r"\d", answer):
323
+ return "what number"
324
+ return "what is the name"
325
+
326
+
327
+ def _is_real_name(name: str, at_start: bool) -> bool:
328
+ """Drop capitalized sentence-initial common words ("Board", "Annual") that aren't names."""
329
+ if not at_start:
330
+ return True
331
+ return len(name.split()) > 1 or sum(c.isupper() for c in name) > 1 or any(c.isdigit() for c in name)
332
+
333
+
334
+ def _subject(fact: str, answer: str) -> str:
335
+ """The named entity the fact is about: the shortest real name that isn't the answer.
336
+
337
+ Shortest, because a real user abbreviates ("HVTA", not the full
338
+ authority name) and long names would dominate lexical overlap.
339
+ """
340
+ text = fact.replace(answer, " ")
341
+ names = []
342
+ for match in _NAME_RE.finditer(text):
343
+ name = re.sub(r"^(The|A|An|Every|All|Each|In|This|Last|Its|It)\s+", "", match.group(0))
344
+ if name in {"The", "A", "An", "Every", "All", "Each", "In", "This", "Last", "Its", "It"}:
345
+ continue
346
+ if _is_real_name(name, match.start() == 0 and name == match.group(0)) and not name.endswith("'s"):
347
+ names.append(name)
348
+ return min(names, key=lambda n: (len(n.split()), -len(n))) if names else ""
349
+
350
+
351
+ def _context_words(fact: str, answer: str, subject: str) -> list[str]:
352
+ """Up to two content words just before the answer and one just after, synonym-swapped."""
353
+ index = fact.find(answer)
354
+ subject_tokens = content_tokens(subject)
355
+ words_before = [w.strip(",.;:()") for w in fact[:index].split()]
356
+ words_after = [w.strip(",.;:()") for w in fact[index + len(answer) :].split()]
357
+
358
+ def keep(word: str) -> bool:
359
+ return bool(word) and word.lower() not in STOPWORDS and not (content_tokens(word) & subject_tokens)
360
+
361
+ before = [w for w in words_before if keep(w)][-3:]
362
+ after = [w for w in words_after if keep(w)][:2]
363
+ # Closest to the answer first: the last words before it, then the first after it.
364
+ candidates = list(reversed(before)) + after
365
+ reworded = [w for w in candidates if w.lower() in _SYNONYMS][:1]
366
+ verbatim = [w for w in candidates if w.lower() not in _SYNONYMS][:1]
367
+ chosen = [w for w in candidates if w in reworded + verbatim] or candidates[:1]
368
+ return [_swap(w).lower() for w in chosen]
369
+
370
+
371
+ def _strong_rewrite(fact: str, answer: str) -> str:
372
+ """A terse, half-remembered query: subject, what's being asked, a couple of swapped context words."""
373
+ subject = _subject(fact, answer)
374
+ if not subject:
375
+ answer_tokens = content_tokens(answer)
376
+ subject = " ".join([w for w in fact.split() if content_tokens(w) and not content_tokens(w) & answer_tokens][:2])
377
+ ask = _ask(fact, answer)
378
+ context = " ".join(_context_words(fact, answer, subject))
379
+ opener = _OPENERS[_h(f"opener:{fact}") % len(_OPENERS)]
380
+ question = " ".join(part for part in (f"{opener}{subject}".strip(), ask, context) if part) + "?"
381
+ relative = find_relative_time(fact)
382
+ if relative and not find_relative_time(question):
383
+ question = f"{relative} {question}"
384
+ return question[0].upper() + question[1:]
385
+
386
+
387
+ def _match_draft(draft: str, passages: list[str]) -> tuple[str, str] | None:
388
+ """Recover (fact, answer) for a draft question written by _write_question from one of these passages."""
389
+ for passage in passages:
390
+ for sentence in _sentences(passage):
391
+ answer = _answer_span(sentence)
392
+ if answer and _write_question(sentence, answer) == draft:
393
+ return sentence, answer
394
+ return None
395
+
396
+
397
+ def _deword(user: str) -> str:
398
+ draft = _field(user, "Question") or ""
399
+ is_retry = _field(user, "Previous rewrite (still too close to the passage wording, change it more)") is not None
400
+ if not is_retry or _h(f"stubborn:{draft}") % 9 == 0:
401
+ return _mild_paraphrase(draft)
402
+ matched = _match_draft(draft, _passages(user))
403
+ if matched is None:
404
+ return _mild_paraphrase(_mild_paraphrase(draft))
405
+ return _strong_rewrite(*matched)
406
+
407
+
408
+ def _verify(fact: str, question: str, gold: str, passages: list[str]) -> dict:
409
+ source = "\n".join(passages)
410
+ gold_parts = [p.strip() for p in gold.split(";") if p.strip()]
411
+ fact_tokens = content_tokens(fact)
412
+ fact_support = len(fact_tokens & content_tokens(source)) / len(fact_tokens) if fact_tokens else 0.0
413
+ supported = bool(gold_parts) and all(_contains(source, part) for part in gold_parts) and fact_support >= 0.8
414
+ answer_leaked = any(_contains(question, part) for part in gold_parts)
415
+ answerable = not answer_leaked and bool(content_tokens(question) & content_tokens(source))
416
+ gold_tokens = set().union(*(content_tokens(p) for p in gold_parts)) if gold_parts else set()
417
+ anchors = content_tokens(question) & (fact_tokens - gold_tokens)
418
+ unambiguous = len(anchors) >= 2
419
+
420
+ if not supported:
421
+ reason = "the gold answer or fact is not stated in the passage"
422
+ elif not answerable:
423
+ reason = "the question gives away the answer" if answer_leaked else "the question is not answerable"
424
+ elif not unambiguous:
425
+ reason = "the question does not pin down which fact it asks about"
426
+ else:
427
+ reason = "ok"
428
+ return {"supported": supported, "answerable": answerable, "unambiguous": unambiguous, "reason": reason}
429
+
430
+
431
+ def _multi_hop(passages: list[str]) -> dict | None:
432
+ picked = []
433
+ for passage in passages[:2]:
434
+ facts = [f for f in _extract_facts(passage, 4) if _contains(passage, f["answer"])]
435
+ if not facts:
436
+ return None
437
+ picked.append(facts[-1] if picked and facts[0]["answer"] == picked[0]["answer"] else facts[0])
438
+ first, second = picked
439
+ part_a = _strong_rewrite(first["fact"], first["answer"]).rstrip("?")
440
+ part_b = _strong_rewrite(second["fact"], second["answer"]).rstrip("?")
441
+ return {
442
+ "fact": f"{first['fact']} {second['fact']}",
443
+ "question": f"{part_a}, and {part_b[0].lower()}{part_b[1:]}?",
444
+ "answer": f"{first['answer']}; {second['answer']}",
445
+ }
446
+
447
+
448
+ def _judge_correctness(user: str) -> dict:
449
+ expected = _field(user, "Expected answer") or ""
450
+ candidate = _field(user, "Candidate answer") or ""
451
+ parts = [p.strip() for p in expected.split(";") if p.strip()]
452
+ candidate_tokens = set(tokenize(candidate))
453
+ correct = bool(parts) and all((content_tokens(part) or set(tokenize(part))) <= candidate_tokens for part in parts)
454
+ return {
455
+ "is_correct": correct,
456
+ "score": 1.0 if correct else 0.0,
457
+ "rationale": "expected answer found in candidate" if correct else "expected answer missing from candidate",
458
+ }
459
+
460
+
461
+ def _judge_groundedness(user: str) -> dict:
462
+ contexts_part, _, answer_part = user.partition("\n\nAnswer:\n")
463
+ answer = answer_part.split("\n\nIs the answer grounded", 1)[0]
464
+ answer_tokens = content_tokens(answer)
465
+ support = len(answer_tokens & content_tokens(contexts_part)) / len(answer_tokens) if answer_tokens else 0.0
466
+ return {"is_grounded": support >= 0.8, "score": round(support, 3), "rationale": "token support check"}
467
+
468
+
469
+ def _answer_from_context(system: str, user: str) -> str:
470
+ """Extractive answering: the context sentence sharing the most words with the question."""
471
+ match = re.search(r"Context:\n(.*?)\n\nQuestion: (.*)", user, re.DOTALL)
472
+ if not match:
473
+ return "I don't know."
474
+ context_block, question = match.group(1), match.group(2)
475
+ contexts = [c for c in context_block.split("\n---\n") if c.strip() and c.strip() != "(no context retrieved)"]
476
+ if not contexts:
477
+ return "I don't know."
478
+ if LEAD_ANSWER_MARKER in system.lower():
479
+ sentences = _sentences(contexts[0])
480
+ return sentences[0] if sentences else "I don't know."
481
+
482
+ question_tokens = content_tokens(question)
483
+ best, best_score = None, 0
484
+ for context in contexts:
485
+ for sentence in _sentences(context):
486
+ score = len(question_tokens & content_tokens(sentence))
487
+ if score > best_score:
488
+ best, best_score = sentence, score
489
+ return best or "I don't know."
490
+
491
+
492
+ class MockLLM:
493
+ """Deterministic offline Completer. See the module docstring for its deliberate flaws."""
494
+
495
+ def __init__(self, model: str = "mock-llm") -> None:
496
+ self.model = model
497
+ self.calls = 0
498
+
499
+ def complete(self, system: str, user: str) -> str:
500
+ self.calls += 1
501
+ return self._dispatch(system, user)
502
+
503
+ def _dispatch(self, system: str, user: str) -> str:
504
+ # Imported lazily so importing the llm package never drags in scoring or generation.
505
+ from ragaudit.generate import testset_generator
506
+ from ragaudit.scoring import correctness, groundedness
507
+
508
+ if system == prompts.FACTS_SYSTEM_PROMPT:
509
+ max_facts = int(_field(user, "Maximum facts") or 3)
510
+ passages = _passages(user)
511
+ return json.dumps({"facts": _extract_facts(passages[0], max_facts) if passages else []})
512
+
513
+ if system == prompts.QUESTION_SYSTEM_PROMPT:
514
+ return json.dumps({"question": _write_question(_field(user, "Fact") or "", _field(user, "Answer") or "")})
515
+
516
+ if system == prompts.DEWORD_SYSTEM_PROMPT:
517
+ return json.dumps({"question": _deword(user)})
518
+
519
+ if system == prompts.VERIFY_SYSTEM_PROMPT:
520
+ return json.dumps(
521
+ _verify(
522
+ _field(user, "Fact") or "",
523
+ _field(user, "Question") or "",
524
+ _field(user, "Gold answer") or "",
525
+ _passages(user),
526
+ )
527
+ )
528
+
529
+ if system == prompts.MULTI_HOP_SYSTEM_PROMPT:
530
+ result = _multi_hop(_passages(user))
531
+ return json.dumps(result) if result else "no multi-hop question possible"
532
+
533
+ if system == correctness.SYSTEM_PROMPT:
534
+ return json.dumps(_judge_correctness(user))
535
+
536
+ if system == groundedness.SYSTEM_PROMPT:
537
+ return json.dumps(_judge_groundedness(user))
538
+
539
+ if system.startswith(PROPOSER_MARKER):
540
+ from ragaudit.tune import strategies
541
+
542
+ return json.dumps(strategies.mock_proposal(user))
543
+
544
+ if system == testset_generator.SYSTEM_PROMPT:
545
+ facts = _extract_facts(user.split("\n\n", 1)[-1], 1)
546
+ questions = [{"question": _strong_rewrite(f["fact"], f["answer"]), "answer": f["answer"]} for f in facts]
547
+ return json.dumps({"questions": questions})
548
+
549
+ if NO_CONTEXT_MARKER in system:
550
+ return "I don't know."
551
+
552
+ return _answer_from_context(system, user)
File without changes
@@ -0,0 +1,39 @@
1
+ """The contract users implement to plug their RAG pipeline into ragaudit."""
2
+
3
+ from typing import Protocol
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class RAGResponse(BaseModel):
9
+ """The contract every adapter must return for a single query.
10
+
11
+ retrieved_chunk_ids must be the chunk IDs ragaudit assigned during
12
+ `ragaudit ingest` — the pipeline under test has to be indexed against
13
+ ragaudit's chunks for retrieval scoring to mean anything. See README.
14
+ """
15
+
16
+ answer: str
17
+ retrieved_chunk_ids: list[str] = Field(default_factory=list)
18
+ contexts: list[str] = Field(default_factory=list)
19
+ latency_ms: float | None = None
20
+ raw: dict | None = None
21
+ """Optional adapter-specific debug payload."""
22
+
23
+
24
+ class Adapter(Protocol):
25
+ """The interface users implement to plug their RAG pipeline in."""
26
+
27
+ def query(self, question: str) -> RAGResponse: ...
28
+
29
+
30
+ class ConfigurableAdapter(Protocol):
31
+ """Optional v2 extension: an adapter that can be reconfigured, e.g. by `ragaudit tune`.
32
+
33
+ Alternatively an adapter file can define `make_adapter(config: dict) -> Adapter`.
34
+ Plain v1 adapters keep working everywhere a config isn't needed.
35
+ """
36
+
37
+ def query(self, question: str) -> RAGResponse: ...
38
+
39
+ def configure(self, config: dict) -> None: ...
@@ -0,0 +1,28 @@
1
+ """Models for ingested source documents and their chunks."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class Document(BaseModel):
7
+ """A single ingested source file."""
8
+
9
+ doc_id: str
10
+ """Stable hash of source_path + content_hash."""
11
+ source_path: str
12
+ content: str
13
+ content_hash: str
14
+ """Sha256 of raw content, used to detect re-ingestion changes."""
15
+ metadata: dict[str, str] = Field(default_factory=dict)
16
+
17
+
18
+ class Chunk(BaseModel):
19
+ """A stable, addressable slice of a Document."""
20
+
21
+ chunk_id: str
22
+ """Deterministic: hash(doc_id, position, text) — same corpus, same ids."""
23
+ doc_id: str
24
+ position: int
25
+ """0-indexed order within the document."""
26
+ text: str
27
+ token_count: int | None = None
28
+ metadata: dict[str, str] = Field(default_factory=dict)