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/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
|
ragaudit/storage/lock.py
ADDED
|
@@ -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)
|