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/scaffold.py ADDED
@@ -0,0 +1,252 @@
1
+ """`ragaudit init`: starter files for plugging a pipeline into ragaudit.
2
+
3
+ Each adapter template has exactly one TODO, at the spot where the user
4
+ connects their own pipeline; everything around it (building the
5
+ RAGResponse, timing, a clear error while the TODO is unfilled) is done.
6
+
7
+ The `demo` template is the exception: a complete toy pipeline (keyword
8
+ search over the docs folder, answered by the configured LLM) that works
9
+ with no edits, for people who want to see a report before wiring up
10
+ their own pipeline.
11
+ """
12
+
13
+ import os
14
+ from pathlib import Path
15
+
16
+ FRAMEWORKS = ("demo", "plain", "langchain", "llamaindex")
17
+ DEMO_DOCS_ENV = "RAGAUDIT_DEMO_DOCS"
18
+ """`ragaudit check` sets this so the demo adapter searches the same folder it writes questions from."""
19
+ DEFAULT_DEMO_DOCS = Path("docs")
20
+ DEFAULT_ADAPTER_PATH = Path("ragaudit_adapter.py")
21
+ ENV_EXAMPLE_NAME = ".env.example"
22
+
23
+ _HEADER = '''"""ragaudit adapter: connects ragaudit to your RAG pipeline ({framework}).
24
+
25
+ ragaudit calls query(question) once per test question and scores what you return:
26
+
27
+ - answer: your pipeline's final answer.
28
+ - contexts: the text of each passage your pipeline retrieved, best first.
29
+ `ragaudit check` (and --match-by text) decides whether retrieval
30
+ found the right passage by comparing these texts with the
31
+ passage each question was written from, so your pipeline can
32
+ keep its own chunking and ids.
33
+ - retrieved_chunk_ids: only used with --match-by id, when your index was
34
+ built from ragaudit's own chunks. Fine to leave empty.
35
+
36
+ Check it with: ragaudit doctor --adapter {filename}
37
+ Then run: ragaudit check --docs ./your-docs --adapter {filename}
38
+ """
39
+
40
+ import time
41
+
42
+ from ragaudit.models.adapter import RAGResponse
43
+ '''
44
+
45
+ _PLAIN = '''
46
+
47
+ def ask_my_pipeline(question: str) -> tuple[str, list[str]]:
48
+ """Return (answer, texts of the retrieved passages) for one question."""
49
+ # TODO: call your pipeline here and return its answer and retrieved passages, e.g.
50
+ # result = my_rag.ask(question)
51
+ # return result.answer, [source.text for source in result.sources]
52
+ raise NotImplementedError("{filename} is not filled in yet: connect your pipeline at the TODO in ask_my_pipeline().")
53
+
54
+
55
+ def query(question: str) -> RAGResponse:
56
+ started = time.perf_counter()
57
+ answer, contexts = ask_my_pipeline(question)
58
+ return RAGResponse(
59
+ answer=str(answer),
60
+ contexts=[str(text) for text in contexts],
61
+ latency_ms=(time.perf_counter() - started) * 1000,
62
+ )
63
+ '''
64
+
65
+ _LANGCHAIN = '''
66
+ # Your pipeline's retriever and chat model. Any LangChain retriever works
67
+ # (vectorstore.as_retriever(), a hybrid or multi-query retriever, ...), and
68
+ # any chat model or LLM (ChatOpenAI, ChatOllama, ...).
69
+ # TODO: build or import your own retriever and llm here, e.g.
70
+ # from my_app.rag import vectorstore, llm
71
+ # retriever = vectorstore.as_retriever(search_kwargs={{"k": 4}})
72
+ retriever = None
73
+ llm = None
74
+
75
+ # The prompt your pipeline uses to answer from the retrieved context. If
76
+ # you already have a chain, copy its prompt here so ragaudit tests the
77
+ # same thing your users get.
78
+ PROMPT = (
79
+ "Answer the question using only the context below. If the context does not "
80
+ "contain the answer, say you don't know.\\n\\nContext:\\n{{context}}\\n\\nQuestion: {{question}}"
81
+ )
82
+
83
+
84
+ def query(question: str) -> RAGResponse:
85
+ if retriever is None or llm is None:
86
+ raise NotImplementedError("{filename} is not filled in yet: set `retriever` and `llm` at the TODO.")
87
+ started = time.perf_counter()
88
+ docs = retriever.invoke(question)
89
+ contexts = [doc.page_content for doc in docs]
90
+ reply = llm.invoke(PROMPT.format(context="\\n\\n".join(contexts), question=question))
91
+ answer = getattr(reply, "content", reply) # chat models return a message, plain LLMs a string
92
+ return RAGResponse(
93
+ answer=str(answer),
94
+ contexts=contexts,
95
+ retrieved_chunk_ids=[str(doc.metadata["chunk_id"]) for doc in docs if "chunk_id" in doc.metadata],
96
+ latency_ms=(time.perf_counter() - started) * 1000,
97
+ )
98
+ '''
99
+
100
+ _LLAMAINDEX = '''
101
+ # Your pipeline's query engine: index.as_query_engine(), a RetrieverQueryEngine,
102
+ # or anything whose .query(question) returns a Response with source_nodes.
103
+ # TODO: build or import your own query engine here, e.g.
104
+ # from my_app.rag import index
105
+ # query_engine = index.as_query_engine(similarity_top_k=4)
106
+ query_engine = None
107
+
108
+
109
+ def query(question: str) -> RAGResponse:
110
+ if query_engine is None:
111
+ raise NotImplementedError("{filename} is not filled in yet: set `query_engine` at the TODO.")
112
+ started = time.perf_counter()
113
+ response = query_engine.query(question)
114
+ nodes = [scored.node for scored in (response.source_nodes or [])]
115
+ return RAGResponse(
116
+ answer=str(response),
117
+ contexts=[node.get_content() for node in nodes],
118
+ retrieved_chunk_ids=[str(node.metadata["chunk_id"]) for node in nodes if "chunk_id" in node.metadata],
119
+ latency_ms=(time.perf_counter() - started) * 1000,
120
+ )
121
+ '''
122
+
123
+ _DEMO = r'''"""ragaudit demo adapter: a tiny, complete RAG pipeline that works with no edits.
124
+
125
+ Retrieval: every .txt/.md file under the docs folder is split into
126
+ paragraphs, and the 3 paragraphs sharing the most words with the
127
+ question are returned. Generation: the configured ragaudit LLM
128
+ (RAGAUDIT_API_BASE / RAGAUDIT_API_KEY / RAGAUDIT_MODEL, or --llm mock)
129
+ answers from those paragraphs.
130
+
131
+ It exists to show what a ragaudit report looks like. Keyword search is
132
+ deliberately simple, so expect some retrieval failures and lucky passes.
133
+ To test your own pipeline: ragaudit init --framework plain --force
134
+
135
+ Run it: ragaudit check --docs __DOCS__ --adapter __FILENAME__
136
+ """
137
+
138
+ import os
139
+ import re
140
+ import time
141
+ from pathlib import Path
142
+
143
+ from ragaudit.llm.factory import make_client
144
+ from ragaudit.models.adapter import RAGResponse
145
+
146
+ USES_RAGAUDIT_LLM = True # tells `ragaudit check` that each answer costs one call to its LLM
147
+ TOP_K = 3
148
+
149
+ # `ragaudit check --docs X` points this at X; otherwise the folder below
150
+ # (relative to this file) is used.
151
+ DOCS = Path(os.environ.get("__DOCS_ENV__") or Path(__file__).parent / "__DOCS__")
152
+
153
+ SYSTEM_PROMPT = (
154
+ "Answer the question using only the context. If the context does not contain the answer, "
155
+ "say you don't know. Answer in one or two sentences."
156
+ )
157
+ STOPWORDS = {
158
+ "a", "an", "and", "are", "as", "at", "be", "by", "does", "for", "from", "how", "in", "is", "it",
159
+ "of", "on", "or", "that", "the", "this", "to", "was", "what", "when", "where", "which", "who",
160
+ "why", "with",
161
+ }
162
+
163
+ _paragraphs: list[str] | None = None
164
+ _llm = None
165
+
166
+
167
+ def words(text: str) -> set[str]:
168
+ return {word for word in re.findall(r"[a-z0-9]+", text.lower()) if word not in STOPWORDS}
169
+
170
+
171
+ def paragraphs() -> list[str]:
172
+ """Every paragraph of every .txt/.md file under DOCS, read once."""
173
+ global _paragraphs
174
+ if _paragraphs is None:
175
+ if not DOCS.is_dir():
176
+ raise FileNotFoundError(f"docs folder not found: {DOCS}. Put your .txt/.md files there.")
177
+ files = sorted(p for p in DOCS.rglob("*") if p.suffix.lower() in (".txt", ".md") and p.is_file())
178
+ _paragraphs = [
179
+ " ".join(block.split())
180
+ for path in files
181
+ for block in re.split(r"\n\s*\n", path.read_text(encoding="utf-8", errors="replace"))
182
+ if block.strip()
183
+ ]
184
+ return _paragraphs
185
+
186
+
187
+ def retrieve(question: str, k: int = TOP_K) -> list[str]:
188
+ asked = words(question)
189
+ scored = [(len(asked & words(text)), -i, text) for i, text in enumerate(paragraphs())]
190
+ return [text for _, _, text in sorted(scored, reverse=True)[:k]] # like any search: always the top k
191
+
192
+
193
+ def query(question: str) -> RAGResponse:
194
+ global _llm
195
+ started = time.perf_counter()
196
+ contexts = retrieve(question)
197
+ if _llm is None:
198
+ _llm = make_client(mock_model="mock-answerer")
199
+ context_block = "\n---\n".join(contexts) or "(no context retrieved)"
200
+ answer = _llm.complete(SYSTEM_PROMPT, f"Context:\n{context_block}\n\nQuestion: {question}")
201
+ return RAGResponse(answer=answer.strip(), contexts=contexts, latency_ms=(time.perf_counter() - started) * 1000)
202
+ '''
203
+
204
+ _BODIES = {"plain": _PLAIN, "langchain": _LANGCHAIN, "llamaindex": _LLAMAINDEX}
205
+
206
+ ENV_EXAMPLE = """# ragaudit reads these settings from the environment, or from a .env file in
207
+ # the folder you run it from. Copy this file to .env and keep ONE block:
208
+ # cp .env.example .env
209
+ # .env holds your API key: keep it out of git.
210
+
211
+ # Option A: OpenAI, or any OpenAI-compatible API (Azure, Together, Groq, vLLM, ...).
212
+ RAGAUDIT_API_BASE=https://api.openai.com/v1
213
+ RAGAUDIT_API_KEY=sk-your-key-here
214
+ RAGAUDIT_MODEL=gpt-4o-mini
215
+
216
+ # Option B: local Ollama. Free, and nothing leaves your machine.
217
+ # Install Ollama, run `ollama pull llama3.1:8b`, then use these instead of Option A:
218
+ # RAGAUDIT_API_BASE=http://localhost:11434/v1
219
+ # RAGAUDIT_API_KEY=ollama
220
+ # RAGAUDIT_MODEL=llama3.1:8b
221
+ """
222
+
223
+
224
+ def adapter_template(framework: str, filename: str, docs: str = str(DEFAULT_DEMO_DOCS)) -> str:
225
+ """The adapter file's text. `docs` (demo only) is the docs folder, relative to the adapter's folder."""
226
+ if framework not in FRAMEWORKS:
227
+ raise ValueError(f"--framework must be one of {', '.join(FRAMEWORKS)}, got {framework!r}")
228
+ if framework == "demo":
229
+ return (
230
+ _DEMO.replace("__DOCS_ENV__", DEMO_DOCS_ENV)
231
+ .replace("__FILENAME__", filename)
232
+ .replace("__DOCS__", Path(docs).as_posix())
233
+ )
234
+ label = {"plain": "plain Python", "langchain": "LangChain", "llamaindex": "LlamaIndex"}[framework]
235
+ return _HEADER.format(framework=label, filename=filename) + _BODIES[framework].format(filename=filename)
236
+
237
+
238
+ def write_starter_files(
239
+ framework: str, adapter_path: Path, force: bool = False, docs: Path = DEFAULT_DEMO_DOCS
240
+ ) -> list[Path]:
241
+ """Write the adapter template and .env.example (next to it). Refuses to overwrite unless force."""
242
+ env_path = adapter_path.parent / ENV_EXAMPLE_NAME
243
+ docs_from_adapter = os.path.relpath(docs.resolve(), adapter_path.parent.resolve())
244
+ content = adapter_template(framework, adapter_path.name, docs_from_adapter)
245
+ existing = [path for path in (adapter_path, env_path) if path.exists()]
246
+ if existing and not force:
247
+ names = ", ".join(str(path) for path in existing)
248
+ raise FileExistsError(f"{names} already exists; pass --force to overwrite, or --out to pick another file.")
249
+ adapter_path.parent.mkdir(parents=True, exist_ok=True)
250
+ adapter_path.write_text(content)
251
+ env_path.write_text(ENV_EXAMPLE)
252
+ return [adapter_path, env_path]
File without changes
@@ -0,0 +1,94 @@
1
+ """LLM judge for answer correctness against the expected answer."""
2
+
3
+ import json
4
+ import logging
5
+ from typing import Protocol
6
+
7
+ from ragaudit.models.scoring import CorrectnessResult
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ SYSTEM_PROMPT = (
12
+ "You are judging whether a candidate answer correctly answers a question, "
13
+ "given a reference (expected) answer. Respond with strict JSON only, no "
14
+ "prose, no markdown fences, in the form: "
15
+ '{"is_correct": bool, "score": float between 0 and 1, "rationale": str}'
16
+ )
17
+
18
+
19
+ class Completer(Protocol):
20
+ """The subset of LLMClient this module depends on — easy to stub in tests."""
21
+
22
+ model: str
23
+
24
+ def complete(self, system: str, user: str) -> str: ...
25
+
26
+
27
+ def _build_user_prompt(question: str, expected_answer: str, candidate_answer: str) -> str:
28
+ return (
29
+ f"Question: {question}\n"
30
+ f"Expected answer: {expected_answer}\n"
31
+ f"Candidate answer: {candidate_answer}\n\n"
32
+ "Is the candidate answer correct?"
33
+ )
34
+
35
+
36
+ def parse_correctness_response(raw: str) -> dict | None:
37
+ """Parse the judge's JSON response. Returns None on any malformed input."""
38
+ try:
39
+ data = json.loads(raw)
40
+ except json.JSONDecodeError:
41
+ return None
42
+ if not isinstance(data, dict):
43
+ return None
44
+
45
+ is_correct = data.get("is_correct")
46
+ if not isinstance(is_correct, bool):
47
+ return None
48
+
49
+ score = data.get("score")
50
+ if score is not None and not isinstance(score, (int, float)):
51
+ return None
52
+
53
+ rationale = data.get("rationale")
54
+ if rationale is not None and not isinstance(rationale, str):
55
+ return None
56
+
57
+ return {
58
+ "is_correct": is_correct,
59
+ "score": float(score) if score is not None else None,
60
+ "rationale": rationale,
61
+ }
62
+
63
+
64
+ def judge_correctness(
65
+ client: Completer,
66
+ question: str,
67
+ expected_answer: str,
68
+ candidate_answer: str,
69
+ ) -> CorrectnessResult:
70
+ """Judge whether candidate_answer correctly answers question, vs expected_answer.
71
+
72
+ On unparseable judge output, conservatively returns is_correct=False
73
+ with a rationale noting the parse failure, rather than raising — one
74
+ bad judge call must not crash a run.
75
+ """
76
+ raw = client.complete(SYSTEM_PROMPT, _build_user_prompt(question, expected_answer, candidate_answer))
77
+ parsed = parse_correctness_response(raw)
78
+
79
+ if parsed is None:
80
+ logger.warning("Correctness judge returned unparseable JSON for question: %s", question)
81
+ return CorrectnessResult(
82
+ is_correct=False,
83
+ score=None,
84
+ rationale="judge returned unparseable JSON",
85
+ judge_model=client.model,
86
+ judge_parse_failed=True,
87
+ )
88
+
89
+ return CorrectnessResult(
90
+ is_correct=parsed["is_correct"],
91
+ score=parsed["score"],
92
+ rationale=parsed["rationale"],
93
+ judge_model=client.model,
94
+ )
@@ -0,0 +1,86 @@
1
+ """LLM judge for whether an answer is grounded in the contexts it was given."""
2
+
3
+ import json
4
+ import logging
5
+ from typing import Protocol
6
+
7
+ from ragaudit.models.scoring import GroundednessResult
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ SYSTEM_PROMPT = (
12
+ "You are judging whether an answer is faithfully grounded in the "
13
+ "provided contexts — i.e. every claim in the answer is supported by "
14
+ "the contexts, regardless of whether the contexts are actually relevant "
15
+ "to the question. Respond with strict JSON only, no prose, no markdown "
16
+ 'fences, in the form: {"is_grounded": bool, "score": float between 0 '
17
+ 'and 1, "rationale": str}'
18
+ )
19
+
20
+
21
+ class Completer(Protocol):
22
+ """The subset of LLMClient this module depends on — easy to stub in tests."""
23
+
24
+ model: str
25
+
26
+ def complete(self, system: str, user: str) -> str: ...
27
+
28
+
29
+ def _build_user_prompt(answer: str, contexts: list[str]) -> str:
30
+ joined_contexts = "\n---\n".join(contexts) if contexts else "(no contexts provided)"
31
+ return f"Contexts:\n{joined_contexts}\n\nAnswer:\n{answer}\n\nIs the answer grounded in the contexts?"
32
+
33
+
34
+ def parse_groundedness_response(raw: str) -> dict | None:
35
+ """Parse the judge's JSON response. Returns None on any malformed input."""
36
+ try:
37
+ data = json.loads(raw)
38
+ except json.JSONDecodeError:
39
+ return None
40
+ if not isinstance(data, dict):
41
+ return None
42
+
43
+ is_grounded = data.get("is_grounded")
44
+ if not isinstance(is_grounded, bool):
45
+ return None
46
+
47
+ score = data.get("score")
48
+ if score is not None and not isinstance(score, (int, float)):
49
+ return None
50
+
51
+ rationale = data.get("rationale")
52
+ if rationale is not None and not isinstance(rationale, str):
53
+ return None
54
+
55
+ return {
56
+ "is_grounded": is_grounded,
57
+ "score": float(score) if score is not None else None,
58
+ "rationale": rationale,
59
+ }
60
+
61
+
62
+ def judge_groundedness(client: Completer, answer: str, contexts: list[str]) -> GroundednessResult:
63
+ """Judge whether answer is faithfully supported by contexts.
64
+
65
+ On unparseable judge output, conservatively returns is_grounded=False
66
+ with a rationale noting the parse failure, rather than raising.
67
+ """
68
+ raw = client.complete(SYSTEM_PROMPT, _build_user_prompt(answer, contexts))
69
+ parsed = parse_groundedness_response(raw)
70
+
71
+ if parsed is None:
72
+ logger.warning("Groundedness judge returned unparseable JSON for answer: %s", answer)
73
+ return GroundednessResult(
74
+ is_grounded=False,
75
+ score=None,
76
+ rationale="judge returned unparseable JSON",
77
+ judge_model=client.model,
78
+ judge_parse_failed=True,
79
+ )
80
+
81
+ return GroundednessResult(
82
+ is_grounded=parsed["is_grounded"],
83
+ score=parsed["score"],
84
+ rationale=parsed["rationale"],
85
+ judge_model=client.model,
86
+ )
@@ -0,0 +1,42 @@
1
+ """Pure retrieval scoring — no LLM, no network. By chunk id (set membership) or by text overlap."""
2
+
3
+ from ragaudit.models.scoring import RetrievalResult
4
+ from ragaudit.scoring.text_match import first_matching_context
5
+
6
+
7
+ def score_retrieval(expected_chunk_id: str, retrieved_chunk_ids: list[str]) -> RetrievalResult:
8
+ """Check whether the expected chunk appears among the retrieved chunk IDs.
9
+
10
+ rank is the first matching index in retrieved_chunk_ids, or None if the
11
+ expected chunk was not retrieved.
12
+ """
13
+ rank = retrieved_chunk_ids.index(expected_chunk_id) if expected_chunk_id in retrieved_chunk_ids else None
14
+ return RetrievalResult(
15
+ hit=rank is not None,
16
+ expected_chunk_id=expected_chunk_id,
17
+ retrieved_chunk_ids=retrieved_chunk_ids,
18
+ rank=rank,
19
+ )
20
+
21
+
22
+ def score_retrieval_by_text(
23
+ expected_chunk_id: str,
24
+ gold_text: str,
25
+ retrieved_chunk_ids: list[str],
26
+ contexts: list[str],
27
+ threshold: float,
28
+ ) -> RetrievalResult:
29
+ """A hit if any returned context overlaps the gold chunk's text at or above threshold.
30
+
31
+ rank is the index of the first matching context. retrieved_chunk_ids
32
+ are kept as returned (they may be the pipeline's own ids).
33
+ """
34
+ rank, best = first_matching_context(gold_text, contexts, threshold)
35
+ return RetrievalResult(
36
+ hit=rank is not None,
37
+ expected_chunk_id=expected_chunk_id,
38
+ retrieved_chunk_ids=retrieved_chunk_ids,
39
+ rank=rank,
40
+ match_by="text",
41
+ match_score=round(best, 4),
42
+ )
@@ -0,0 +1,51 @@
1
+ """Retrieval scoring by text overlap, for pipelines that don't carry ragaudit's chunk ids.
2
+
3
+ `--match-by id` (the default) counts a retrieval hit only when the adapter
4
+ returns the gold chunk's id, so the pipeline has to be indexed against
5
+ ragaudit's chunks. `--match-by text` instead compares the gold chunk's
6
+ *text* with the contexts the adapter already returns, so a pipeline that
7
+ chunks the corpus its own way works without re-indexing.
8
+
9
+ The score is the overlap coefficient of word bigrams: shared bigrams
10
+ divided by the bigram count of the smaller side. It is high when most of
11
+ the gold passage appears inside a returned context (the pipeline's chunks
12
+ are bigger) or most of a context appears inside the gold passage (they are
13
+ smaller), whatever the chunk boundaries. Bigrams rather than single words
14
+ keep two passages on the same topic from matching just because they share
15
+ vocabulary. The denominator never drops below MIN_BIGRAMS, so a context of
16
+ a few words can't match everything.
17
+ """
18
+
19
+ import re
20
+
21
+ DEFAULT_MATCH_THRESHOLD = 0.5
22
+ MIN_BIGRAMS = 8
23
+ MATCH_MODES = ("id", "text")
24
+
25
+ _TOKEN_RE = re.compile(r"[a-z0-9]+")
26
+
27
+
28
+ def _bigrams(text: str) -> set[tuple[str, str]]:
29
+ tokens = _TOKEN_RE.findall(text.lower())
30
+ return set(zip(tokens, tokens[1:]))
31
+
32
+
33
+ def text_match_score(gold_text: str, context: str) -> float:
34
+ """Overlap of gold_text and context in [0, 1] (see the module docstring)."""
35
+ gold, ctx = _bigrams(gold_text), _bigrams(context)
36
+ if not gold or not ctx:
37
+ return 0.0
38
+ return len(gold & ctx) / max(min(len(gold), len(ctx)), MIN_BIGRAMS)
39
+
40
+
41
+ def first_matching_context(gold_text: str, contexts: list[str], threshold: float) -> tuple[int | None, float]:
42
+ """(index of the first context scoring >= threshold or None, best score over all contexts)."""
43
+ scores = [text_match_score(gold_text, context) for context in contexts]
44
+ rank = next((i for i, score in enumerate(scores) if score >= threshold), None)
45
+ return rank, max(scores, default=0.0)
46
+
47
+
48
+ def validate_match_by(match_by: str) -> str:
49
+ if match_by not in MATCH_MODES:
50
+ raise ValueError(f"--match-by must be id or text, got {match_by!r}")
51
+ return match_by
@@ -0,0 +1,14 @@
1
+ """Pure function cross-referencing answer correctness against retrieval."""
2
+
3
+ from ragaudit.models.scoring import Verdict
4
+
5
+
6
+ def compute_verdict(is_correct: bool, retrieval_hit: bool) -> Verdict:
7
+ """Cross-reference correctness against retrieval into a single Verdict."""
8
+ if is_correct and retrieval_hit:
9
+ return Verdict.TRUE_PASS
10
+ if is_correct and not retrieval_hit:
11
+ return Verdict.LUCKY_PASS
12
+ if not is_correct and retrieval_hit:
13
+ return Verdict.GENERATION_FAILURE
14
+ return Verdict.RETRIEVAL_FAILURE
File without changes
ragaudit/storage/db.py ADDED
@@ -0,0 +1,81 @@
1
+ """SQLite connection management and schema creation.
2
+
3
+ Migrations are intentionally trivial: every table is created with
4
+ `CREATE TABLE IF NOT EXISTS`, so re-opening an existing database is a
5
+ no-op. No migration framework, no versioning — if the schema needs to
6
+ change later, that's a deliberate future decision, not something this
7
+ module tries to anticipate.
8
+ """
9
+
10
+ import sqlite3
11
+ from pathlib import Path
12
+
13
+ SCHEMA = """
14
+ CREATE TABLE IF NOT EXISTS documents (
15
+ doc_id TEXT PRIMARY KEY,
16
+ source_path TEXT NOT NULL,
17
+ content TEXT NOT NULL,
18
+ content_hash TEXT NOT NULL,
19
+ metadata_json TEXT NOT NULL
20
+ );
21
+
22
+ CREATE TABLE IF NOT EXISTS chunks (
23
+ chunk_id TEXT PRIMARY KEY,
24
+ doc_id TEXT NOT NULL,
25
+ position INTEGER NOT NULL,
26
+ text TEXT NOT NULL,
27
+ token_count INTEGER,
28
+ metadata_json TEXT NOT NULL
29
+ );
30
+
31
+ CREATE TABLE IF NOT EXISTS testsets (
32
+ testset_id TEXT PRIMARY KEY,
33
+ corpus_hash TEXT NOT NULL,
34
+ created_at TEXT NOT NULL,
35
+ generation_model TEXT NOT NULL
36
+ );
37
+
38
+ CREATE TABLE IF NOT EXISTS test_cases (
39
+ test_id TEXT PRIMARY KEY,
40
+ testset_id TEXT NOT NULL,
41
+ position INTEGER NOT NULL,
42
+ question TEXT NOT NULL,
43
+ expected_answer TEXT NOT NULL,
44
+ source_chunk_id TEXT NOT NULL,
45
+ source_doc_id TEXT NOT NULL,
46
+ generation_model TEXT NOT NULL,
47
+ created_at TEXT NOT NULL,
48
+ metadata_json TEXT NOT NULL
49
+ );
50
+
51
+ CREATE TABLE IF NOT EXISTS runs (
52
+ run_id TEXT PRIMARY KEY,
53
+ testset_id TEXT NOT NULL,
54
+ adapter_path TEXT NOT NULL,
55
+ correctness_judge_model TEXT NOT NULL,
56
+ groundedness_judge_model TEXT NOT NULL,
57
+ run_ablation INTEGER NOT NULL,
58
+ started_at TEXT NOT NULL,
59
+ finished_at TEXT,
60
+ summary_json TEXT
61
+ );
62
+
63
+ CREATE TABLE IF NOT EXISTS scored_cases (
64
+ run_id TEXT NOT NULL,
65
+ test_id TEXT NOT NULL,
66
+ position INTEGER NOT NULL,
67
+ data_json TEXT NOT NULL,
68
+ PRIMARY KEY (run_id, test_id)
69
+ );
70
+ """
71
+
72
+
73
+ def get_connection(db_path: str | Path) -> sqlite3.Connection:
74
+ """Open a SQLite connection at db_path and ensure the schema exists.
75
+
76
+ db_path may be ":memory:" for an ephemeral in-process database.
77
+ """
78
+ connection = sqlite3.connect(str(db_path))
79
+ connection.executescript(SCHEMA)
80
+ connection.commit()
81
+ return connection
@@ -0,0 +1,36 @@
1
+ """The test-split lock: test gold answers are readable only inside the final evaluation path.
2
+
3
+ The store consults `is_test_split_unlocked()` before returning any test-split
4
+ question or result. The only code that unlocks it is
5
+ `ragaudit.runner.locked`, which returns aggregate scores, never answers.
6
+ The `ragaudit.tune` package must never import this module; a unit test
7
+ enforces that.
8
+
9
+ Python can't make this tamper-proof, so the lock exists to stop
10
+ accidents (a tuning loop that quietly reads test answers), not a
11
+ determined author.
12
+ """
13
+
14
+ from contextlib import contextmanager
15
+ from contextvars import ContextVar
16
+ from typing import Iterator
17
+
18
+ _unlocked: ContextVar[bool] = ContextVar("ragaudit_test_split_unlocked", default=False)
19
+
20
+
21
+ class LockedTestSetError(Exception):
22
+ """Raised when code outside the final evaluation path asks for test-split data."""
23
+
24
+
25
+ def is_test_split_unlocked() -> bool:
26
+ return _unlocked.get()
27
+
28
+
29
+ @contextmanager
30
+ def _unlock_test_split() -> Iterator[None]:
31
+ """Unlock test-split reads for the duration of the block. Final evaluation path only."""
32
+ token = _unlocked.set(True)
33
+ try:
34
+ yield
35
+ finally:
36
+ _unlocked.reset(token)