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
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)
|