ragvidence 0.1.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.
- ragvidence/__init__.py +4 -0
- ragvidence/cli.py +373 -0
- ragvidence/comparison.py +35 -0
- ragvidence/config.py +16 -0
- ragvidence/data.py +72 -0
- ragvidence/diagnosis.py +40 -0
- ragvidence/evaluation/__init__.py +19 -0
- ragvidence/evaluation/evaluator.py +54 -0
- ragvidence/evaluation/metrics.py +131 -0
- ragvidence/exercises/__init__.py +1 -0
- ragvidence/exercises/heading_context/README.md +5 -0
- ragvidence/exercises/heading_context/integrity.json +4 -0
- ragvidence/exercises/heading_context/questions.jsonl +12 -0
- ragvidence/exercises/heading_context/sources.jsonl +8 -0
- ragvidence/generation.py +60 -0
- ragvidence/health.py +67 -0
- ragvidence/importer.py +132 -0
- ragvidence/models.py +101 -0
- ragvidence/providers/__init__.py +12 -0
- ragvidence/providers/base.py +12 -0
- ragvidence/providers/fastembed.py +19 -0
- ragvidence/providers/remote.py +114 -0
- ragvidence/reporting/__init__.py +6 -0
- ragvidence/reporting/html.py +15 -0
- ragvidence/reporting/markdown.py +72 -0
- ragvidence/reporting/payload.py +32 -0
- ragvidence/reporting/templates/report.html.j2 +689 -0
- ragvidence/representation.py +25 -0
- ragvidence/runner.py +83 -0
- ragvidence/strategies/__init__.py +6 -0
- ragvidence/strategies/base.py +12 -0
- ragvidence/strategies/bm25.py +49 -0
- ragvidence/strategies/semantic.py +86 -0
- ragvidence/verification.py +115 -0
- ragvidence/workspace.py +33 -0
- ragvidence-0.1.0.dist-info/METADATA +213 -0
- ragvidence-0.1.0.dist-info/RECORD +40 -0
- ragvidence-0.1.0.dist-info/WHEEL +4 -0
- ragvidence-0.1.0.dist-info/entry_points.txt +2 -0
- ragvidence-0.1.0.dist-info/licenses/LICENSE.md +74 -0
ragvidence/__init__.py
ADDED
ragvidence/cli.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Command-line interface for local evidence-level RAG experiments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from ragvidence import __edition__, __version__
|
|
14
|
+
from ragvidence.comparison import compare_evaluations
|
|
15
|
+
from ragvidence.data import DatasetError, load_dataset
|
|
16
|
+
from ragvidence.diagnosis import diagnose as build_diagnosis
|
|
17
|
+
from ragvidence.evaluation.evaluator import evaluate_run
|
|
18
|
+
from ragvidence.generation import generate_answers
|
|
19
|
+
from ragvidence.health import run_health
|
|
20
|
+
from ragvidence.importer import import_retrieval_run
|
|
21
|
+
from ragvidence.models import ExperimentRun, StrategyConfig
|
|
22
|
+
from ragvidence.reporting import build_report, render_markdown
|
|
23
|
+
from ragvidence.reporting.html import render_html
|
|
24
|
+
from ragvidence.runner import run_experiment, write_run
|
|
25
|
+
from ragvidence.verification import run_verification
|
|
26
|
+
from ragvidence.workspace import create_workspace, restore_workspace
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(
|
|
29
|
+
name="ragvi",
|
|
30
|
+
help="Diagnose RAG retrieval with evidence-level experiments.",
|
|
31
|
+
no_args_is_help=True,
|
|
32
|
+
)
|
|
33
|
+
workspace_app = typer.Typer(help="Manage recoverable experiment workspaces.")
|
|
34
|
+
app.add_typer(workspace_app, name="workspace")
|
|
35
|
+
console = Console()
|
|
36
|
+
DEFAULT_ROOT = Path("workspaces")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fail(message: str) -> None:
|
|
40
|
+
"""Exit with one actionable error and no internal traceback."""
|
|
41
|
+
console.print(f"[red]FAIL[/red] {message}")
|
|
42
|
+
raise typer.Exit(1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def version_callback(value: bool) -> None:
|
|
46
|
+
if value:
|
|
47
|
+
console.print(__version__)
|
|
48
|
+
raise typer.Exit()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.callback()
|
|
52
|
+
def main(
|
|
53
|
+
version: Annotated[
|
|
54
|
+
bool | None, typer.Option("--version", callback=version_callback, is_eager=True)
|
|
55
|
+
] = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""RAGvidence CLI."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command()
|
|
61
|
+
def edition() -> None:
|
|
62
|
+
"""Show the installed product edition and activation requirement."""
|
|
63
|
+
if __edition__ == "Free":
|
|
64
|
+
console.print("Free — no account or activation required")
|
|
65
|
+
else:
|
|
66
|
+
console.print(__edition__)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def health(root: Path = DEFAULT_ROOT) -> None:
|
|
71
|
+
"""Check runtime, bundled data, workspace access and semantic support."""
|
|
72
|
+
checks = run_health(root)
|
|
73
|
+
table = Table("Status", "Check", "Detail")
|
|
74
|
+
colors = {"PASS": "green", "WARN": "yellow", "FAIL": "red"} # nosec B105
|
|
75
|
+
for check in checks:
|
|
76
|
+
table.add_row(f"[{colors[check.status]}]{check.status}[/]", check.name, check.detail)
|
|
77
|
+
console.print(table)
|
|
78
|
+
if any(item.status == "FAIL" for item in checks):
|
|
79
|
+
raise typer.Exit(1)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def verify() -> None:
|
|
84
|
+
"""Run deterministic release and installation fixtures."""
|
|
85
|
+
results = run_verification()
|
|
86
|
+
table = Table("Status", "Fixture", "Detail")
|
|
87
|
+
colors = {"PASS": "green", "FAIL": "red"} # nosec B105
|
|
88
|
+
for result in results:
|
|
89
|
+
table.add_row(
|
|
90
|
+
f"[{colors[result.status]}]{result.status}[/]",
|
|
91
|
+
result.name,
|
|
92
|
+
result.detail,
|
|
93
|
+
)
|
|
94
|
+
console.print(table)
|
|
95
|
+
if any(item.status == "FAIL" for item in results):
|
|
96
|
+
raise typer.Exit(1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def quickstart(root: Path = DEFAULT_ROOT) -> None:
|
|
101
|
+
"""Create the guided exercise without overwriting existing work."""
|
|
102
|
+
try:
|
|
103
|
+
target = create_workspace(root, "heading-context")
|
|
104
|
+
except FileExistsError:
|
|
105
|
+
target = root / "heading-context"
|
|
106
|
+
console.print(f"[yellow]EXISTS[/yellow] {target}")
|
|
107
|
+
console.print(f"[green]READY[/green] {target}\nNext: ragvi validate {target}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command()
|
|
111
|
+
def tutorial(
|
|
112
|
+
root: Path = DEFAULT_ROOT,
|
|
113
|
+
name: Annotated[str, typer.Option(help="Name for the new tutorial workspace")] = "first-run",
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Run the complete local BM25 tutorial and create a shareable HTML report."""
|
|
116
|
+
try:
|
|
117
|
+
workspace = create_workspace(root, name)
|
|
118
|
+
evaluations = []
|
|
119
|
+
for representation in ("body", "heading_context"):
|
|
120
|
+
config = StrategyConfig(
|
|
121
|
+
name=f"bm25-{representation}",
|
|
122
|
+
strategy="bm25",
|
|
123
|
+
representation=representation,
|
|
124
|
+
k=2,
|
|
125
|
+
)
|
|
126
|
+
console.print(f"Running [cyan]{config.name}[/cyan] at k={config.k}")
|
|
127
|
+
run = run_experiment(workspace, config)
|
|
128
|
+
run_path = workspace / "runs" / f"{config.name}.json"
|
|
129
|
+
write_run(run, run_path)
|
|
130
|
+
_, questions = load_dataset(workspace)
|
|
131
|
+
evaluation = evaluate_run(run, questions)
|
|
132
|
+
evaluation_path = workspace / "evaluations" / f"{config.name}.json"
|
|
133
|
+
evaluation_path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
evaluation_path.write_text(json.dumps(evaluation, indent=2), encoding="utf-8")
|
|
135
|
+
evaluations.append(evaluation)
|
|
136
|
+
|
|
137
|
+
report_path = workspace / "reports" / "tutorial.html"
|
|
138
|
+
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
template = Path(__file__).parent / "reporting/templates/report.html.j2"
|
|
140
|
+
report_path.write_text(
|
|
141
|
+
render_html(build_report(evaluations[0], evaluations[1]), template),
|
|
142
|
+
encoding="utf-8",
|
|
143
|
+
)
|
|
144
|
+
except (DatasetError, FileExistsError, OSError, RuntimeError, ValueError) as exc:
|
|
145
|
+
if isinstance(exc, FileExistsError):
|
|
146
|
+
fail(
|
|
147
|
+
f"Tutorial workspace already exists: {root / name}. "
|
|
148
|
+
"Inspect it or choose another name with --name."
|
|
149
|
+
)
|
|
150
|
+
fail(str(exc))
|
|
151
|
+
|
|
152
|
+
console.print(
|
|
153
|
+
f"[green]COMPLETE[/green] Guided comparison finished.\n"
|
|
154
|
+
f"[bold]Report:[/bold] {report_path}\n"
|
|
155
|
+
f"[bold]Next:[/bold] Open the report, inspect changed questions, then read "
|
|
156
|
+
"the Getting started guide included with your download."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command("init")
|
|
161
|
+
def init_workspace(name: str, root: Path = DEFAULT_ROOT) -> None:
|
|
162
|
+
"""Create an editable experiment from the bundled template."""
|
|
163
|
+
try:
|
|
164
|
+
target = create_workspace(root, name)
|
|
165
|
+
except (FileExistsError, ValueError) as exc:
|
|
166
|
+
fail(str(exc))
|
|
167
|
+
console.print(f"[green]CREATED[/green] {target}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.command()
|
|
171
|
+
def validate(workspace: Path) -> None:
|
|
172
|
+
"""Validate data and evidence-reference integrity."""
|
|
173
|
+
try:
|
|
174
|
+
sources, questions = load_dataset(workspace)
|
|
175
|
+
except DatasetError as exc:
|
|
176
|
+
console.print(f"[red]FAIL[/red] {exc}")
|
|
177
|
+
raise typer.Exit(1) from exc
|
|
178
|
+
evidence = {item for source in sources for item in source.evidence_ids}
|
|
179
|
+
console.print(
|
|
180
|
+
f"[green]PASS[/green] {len(sources)} sources, "
|
|
181
|
+
f"{len(questions)} questions, {len(evidence)} evidence IDs"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@app.command()
|
|
186
|
+
def benchmark(
|
|
187
|
+
workspace: Path,
|
|
188
|
+
strategies: str = "bm25",
|
|
189
|
+
k: int = 2,
|
|
190
|
+
model: str = "BAAI/bge-small-en-v1.5",
|
|
191
|
+
provider: str = "fastembed",
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Run body and heading-context representations."""
|
|
194
|
+
requested = [value.strip() for value in strategies.split(",") if value.strip()]
|
|
195
|
+
unsupported = set(requested) - {"bm25", "semantic"}
|
|
196
|
+
if unsupported:
|
|
197
|
+
raise typer.BadParameter(f"Unsupported strategies: {sorted(unsupported)}")
|
|
198
|
+
for strategy in requested:
|
|
199
|
+
for representation in ("body", "heading_context"):
|
|
200
|
+
name = f"{strategy}-{representation}"
|
|
201
|
+
config = StrategyConfig(
|
|
202
|
+
name=name,
|
|
203
|
+
strategy=strategy,
|
|
204
|
+
representation=representation,
|
|
205
|
+
k=k,
|
|
206
|
+
model=model if strategy == "semantic" else None,
|
|
207
|
+
provider=provider if strategy == "semantic" else None,
|
|
208
|
+
)
|
|
209
|
+
if strategy == "semantic" and provider != "fastembed":
|
|
210
|
+
console.print(
|
|
211
|
+
f"[yellow]DISCLOSURE[/yellow] {provider} will receive source text and queries."
|
|
212
|
+
)
|
|
213
|
+
console.print(f"Running [cyan]{name}[/cyan] at k={k}")
|
|
214
|
+
try:
|
|
215
|
+
run = run_experiment(workspace, config)
|
|
216
|
+
destination = workspace / "runs" / f"{name}.json"
|
|
217
|
+
write_run(run, destination)
|
|
218
|
+
except (DatasetError, FileExistsError, RuntimeError) as exc:
|
|
219
|
+
fail(str(exc))
|
|
220
|
+
console.print(f"[green]SAVED[/green] {destination}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.command("import-run")
|
|
224
|
+
def import_run(
|
|
225
|
+
workspace: Path,
|
|
226
|
+
input_path: Path,
|
|
227
|
+
name: Annotated[str, typer.Option(help="Stable name for the external strategy")],
|
|
228
|
+
k: Annotated[int, typer.Option(min=1)] = 2,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Validate and normalize ranked JSONL from another RAG stack."""
|
|
231
|
+
try:
|
|
232
|
+
run = import_retrieval_run(workspace, input_path, name=name, k=k)
|
|
233
|
+
destination = workspace / "runs" / f"{name}.json"
|
|
234
|
+
write_run(run, destination)
|
|
235
|
+
except (DatasetError, FileExistsError) as exc:
|
|
236
|
+
fail(str(exc))
|
|
237
|
+
console.print(f"[green]SAVED[/green] {destination}")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@app.command()
|
|
241
|
+
def evaluate(workspace: Path, run_file: Path) -> None:
|
|
242
|
+
"""Evaluate a run using reviewed evidence labels."""
|
|
243
|
+
try:
|
|
244
|
+
_, questions = load_dataset(workspace)
|
|
245
|
+
run = ExperimentRun.model_validate_json(run_file.read_text(encoding="utf-8"))
|
|
246
|
+
result = evaluate_run(run, questions)
|
|
247
|
+
except (DatasetError, OSError, ValueError, NotImplementedError) as exc:
|
|
248
|
+
fail(str(exc) or "Evaluation could not be completed")
|
|
249
|
+
destination = workspace / "evaluations" / f"{run.manifest.config.name}.json"
|
|
250
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
if destination.exists():
|
|
252
|
+
fail(f"Refusing to overwrite: {destination}")
|
|
253
|
+
destination.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
|
254
|
+
console.print(f"[green]SAVED[/green] {destination}")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@app.command()
|
|
258
|
+
def generate(workspace: Path, run_file: Path, output: Path) -> None:
|
|
259
|
+
"""Generate optional answers with a user-configured provider."""
|
|
260
|
+
from ragvidence.config import Settings
|
|
261
|
+
|
|
262
|
+
settings = Settings()
|
|
263
|
+
console.print(
|
|
264
|
+
f"[yellow]DISCLOSURE[/yellow] {settings.generation_provider or 'unconfigured provider'} "
|
|
265
|
+
"will receive each question and its retrieved source text."
|
|
266
|
+
)
|
|
267
|
+
try:
|
|
268
|
+
run = ExperimentRun.model_validate_json(run_file.read_text(encoding="utf-8"))
|
|
269
|
+
result = generate_answers(workspace, run, settings)
|
|
270
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
271
|
+
fail(str(exc))
|
|
272
|
+
if output.exists():
|
|
273
|
+
fail(f"Refusing to overwrite: {output}")
|
|
274
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
275
|
+
output.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
|
276
|
+
console.print(f"[green]SAVED[/green] {output}")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@app.command()
|
|
280
|
+
def compare(baseline: Path, intervention: Path, output: Path) -> None:
|
|
281
|
+
"""Compare two evaluated runs and retain all change classes."""
|
|
282
|
+
try:
|
|
283
|
+
result = compare_evaluations(
|
|
284
|
+
json.loads(baseline.read_text()), json.loads(intervention.read_text())
|
|
285
|
+
)
|
|
286
|
+
except (OSError, ValueError, json.JSONDecodeError, NotImplementedError) as exc:
|
|
287
|
+
fail(str(exc) or "Comparison could not be completed")
|
|
288
|
+
if output.exists():
|
|
289
|
+
fail(f"Refusing to overwrite: {output}")
|
|
290
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
output.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
|
292
|
+
console.print_json(data=result["summary"])
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@app.command()
|
|
296
|
+
def diagnose(comparison: Path) -> None:
|
|
297
|
+
"""Explain an observable pattern and bounded next step."""
|
|
298
|
+
try:
|
|
299
|
+
result = build_diagnosis(json.loads(comparison.read_text(encoding="utf-8")))
|
|
300
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
301
|
+
fail(str(exc))
|
|
302
|
+
for key, value in result.items():
|
|
303
|
+
console.print(f"[bold]{key.replace('_', ' ').title()}:[/bold] {value}")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@app.command()
|
|
307
|
+
def inspect(
|
|
308
|
+
run_file: Path,
|
|
309
|
+
question: Annotated[str, typer.Option("--question", help="Stable question ID")],
|
|
310
|
+
) -> None:
|
|
311
|
+
"""Inspect ranked evidence for one question."""
|
|
312
|
+
try:
|
|
313
|
+
run = ExperimentRun.model_validate_json(run_file.read_text(encoding="utf-8"))
|
|
314
|
+
except (OSError, ValueError) as exc:
|
|
315
|
+
fail(str(exc))
|
|
316
|
+
query = next((item for item in run.queries if item.question_id == question), None)
|
|
317
|
+
if query is None:
|
|
318
|
+
raise typer.BadParameter(f"Question not found: {question}")
|
|
319
|
+
table = Table("Rank", "Source", "Evidence", "Score", "Preview")
|
|
320
|
+
for hit in query.hits:
|
|
321
|
+
table.add_row(
|
|
322
|
+
str(hit.rank),
|
|
323
|
+
hit.source_unit_id,
|
|
324
|
+
", ".join(sorted(hit.evidence_ids)) or "—",
|
|
325
|
+
f"{hit.score:.4f}",
|
|
326
|
+
hit.preview,
|
|
327
|
+
)
|
|
328
|
+
console.print(table)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@app.command()
|
|
332
|
+
def report(
|
|
333
|
+
baseline: Path,
|
|
334
|
+
output: Path,
|
|
335
|
+
intervention: Annotated[Path | None, typer.Option()] = None,
|
|
336
|
+
format: Annotated[str, typer.Option()] = "markdown",
|
|
337
|
+
template: Annotated[Path | None, typer.Option()] = None,
|
|
338
|
+
) -> None:
|
|
339
|
+
"""Create JSON, Markdown or HTML from authoritative evaluation data."""
|
|
340
|
+
if output.exists():
|
|
341
|
+
fail(f"Refusing to overwrite: {output}")
|
|
342
|
+
if format not in {"json", "markdown", "html"}:
|
|
343
|
+
raise typer.BadParameter("format must be json, markdown, or html")
|
|
344
|
+
try:
|
|
345
|
+
baseline_data = json.loads(baseline.read_text(encoding="utf-8"))
|
|
346
|
+
intervention_data = (
|
|
347
|
+
json.loads(intervention.read_text(encoding="utf-8")) if intervention else None
|
|
348
|
+
)
|
|
349
|
+
payload = build_report(baseline_data, intervention_data)
|
|
350
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
351
|
+
fail(str(exc))
|
|
352
|
+
if format == "json":
|
|
353
|
+
content = json.dumps(payload, indent=2)
|
|
354
|
+
elif format == "markdown":
|
|
355
|
+
content = render_markdown(payload)
|
|
356
|
+
else:
|
|
357
|
+
selected = template or Path(__file__).parent / "reporting/templates/report.html.j2"
|
|
358
|
+
if not selected.is_file():
|
|
359
|
+
raise typer.BadParameter("The installed HTML report template is missing")
|
|
360
|
+
content = render_html(payload, selected)
|
|
361
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
362
|
+
output.write_text(content, encoding="utf-8")
|
|
363
|
+
console.print(f"[green]SAVED[/green] {output}")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@workspace_app.command("restore")
|
|
367
|
+
def workspace_restore(name: str, root: Path = DEFAULT_ROOT) -> None:
|
|
368
|
+
"""Back up a workspace and replace it with a clean exercise."""
|
|
369
|
+
try:
|
|
370
|
+
target, backup = restore_workspace(root, name)
|
|
371
|
+
except (OSError, ValueError) as exc:
|
|
372
|
+
fail(str(exc))
|
|
373
|
+
console.print(f"[green]RESTORED[/green] {target}\n[yellow]BACKUP[/yellow] {backup}")
|
ragvidence/comparison.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Compare evaluated runs without hiding regressions."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ragvidence.evaluation.metrics import classify_change
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compare_evaluations(baseline: dict[str, Any], intervention: dict[str, Any]) -> dict[str, Any]:
|
|
9
|
+
baseline_manifest = baseline["manifest"]
|
|
10
|
+
intervention_manifest = intervention["manifest"]
|
|
11
|
+
if baseline_manifest["dataset_hash"] != intervention_manifest["dataset_hash"]:
|
|
12
|
+
raise ValueError("Runs use different dataset hashes")
|
|
13
|
+
baseline_k = baseline_manifest["config"]["k"]
|
|
14
|
+
intervention_k = intervention_manifest["config"]["k"]
|
|
15
|
+
if baseline_k != intervention_k:
|
|
16
|
+
raise ValueError("Runs use different retrieval depths (k)")
|
|
17
|
+
before = {item["question_id"]: item for item in baseline["questions"]}
|
|
18
|
+
after = {item["question_id"]: item for item in intervention["questions"]}
|
|
19
|
+
if before.keys() != after.keys():
|
|
20
|
+
raise ValueError("Runs contain different question IDs")
|
|
21
|
+
changes = []
|
|
22
|
+
for question_id in sorted(before):
|
|
23
|
+
old = before[question_id]["sufficient_evidence"]
|
|
24
|
+
new = after[question_id]["sufficient_evidence"]
|
|
25
|
+
status = None if old is None or new is None else classify_change(old, new)
|
|
26
|
+
changes.append({"question_id": question_id, "classification": status})
|
|
27
|
+
return {
|
|
28
|
+
"baseline": baseline["manifest"]["config"],
|
|
29
|
+
"intervention": intervention["manifest"]["config"],
|
|
30
|
+
"summary": {
|
|
31
|
+
key: sum(item["classification"] == key for item in changes)
|
|
32
|
+
for key in ("fail_to_pass", "pass_to_fail", "pass_to_pass", "fail_to_fail")
|
|
33
|
+
},
|
|
34
|
+
"changes": changes,
|
|
35
|
+
}
|
ragvidence/config.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Explicit environment configuration with secret values kept redacted."""
|
|
2
|
+
|
|
3
|
+
from pydantic import SecretStr
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Settings(BaseSettings):
|
|
8
|
+
model_config = SettingsConfigDict(env_prefix="RAGVIDENCE_", env_file=".env", extra="ignore")
|
|
9
|
+
embedding_provider: str = "fastembed"
|
|
10
|
+
embedding_model: str = "BAAI/bge-small-en-v1.5"
|
|
11
|
+
embedding_base_url: str | None = None
|
|
12
|
+
embedding_api_key: SecretStr | None = None
|
|
13
|
+
generation_provider: str | None = None
|
|
14
|
+
generation_model: str | None = None
|
|
15
|
+
generation_base_url: str | None = None
|
|
16
|
+
generation_api_key: SecretStr | None = None
|
ragvidence/data.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""JSONL loading and cross-record integrity validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ValidationError
|
|
9
|
+
|
|
10
|
+
from ragvidence.models import QuestionCase, SourceUnit
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DatasetError(ValueError):
|
|
14
|
+
"""A dataset cannot be evaluated safely."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_jsonl[ModelT: BaseModel](path: Path, model: type[ModelT]) -> list[ModelT]:
|
|
18
|
+
records: list[ModelT] = []
|
|
19
|
+
try:
|
|
20
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
21
|
+
except OSError as exc:
|
|
22
|
+
raise DatasetError(f"Cannot read {path}: {exc}") from exc
|
|
23
|
+
for line_number, raw in enumerate(lines, start=1):
|
|
24
|
+
if not raw.strip():
|
|
25
|
+
continue
|
|
26
|
+
try:
|
|
27
|
+
records.append(model.model_validate_json(raw))
|
|
28
|
+
except (ValidationError, ValueError) as exc:
|
|
29
|
+
raise DatasetError(f"{path}:{line_number}: {exc}") from exc
|
|
30
|
+
if not records:
|
|
31
|
+
raise DatasetError(f"{path}: expected at least one record")
|
|
32
|
+
return records
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_dataset(root: Path) -> tuple[list[SourceUnit], list[QuestionCase]]:
|
|
36
|
+
sources = load_jsonl(root / "sources.jsonl", SourceUnit)
|
|
37
|
+
questions = load_jsonl(root / "questions.jsonl", QuestionCase)
|
|
38
|
+
validate_dataset(sources, questions)
|
|
39
|
+
return sources, questions
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_dataset(sources: list[SourceUnit], questions: list[QuestionCase]) -> None:
|
|
43
|
+
source_ids = [item.source_unit_id for item in sources]
|
|
44
|
+
question_ids = [item.question_id for item in questions]
|
|
45
|
+
duplicate_sources = _duplicates(source_ids)
|
|
46
|
+
duplicate_questions = _duplicates(question_ids)
|
|
47
|
+
if duplicate_sources:
|
|
48
|
+
raise DatasetError(f"Duplicate source_unit_id values: {sorted(duplicate_sources)}")
|
|
49
|
+
if duplicate_questions:
|
|
50
|
+
raise DatasetError(f"Duplicate question_id values: {sorted(duplicate_questions)}")
|
|
51
|
+
|
|
52
|
+
evidence = {evidence_id for source in sources for evidence_id in source.evidence_ids}
|
|
53
|
+
missing = {
|
|
54
|
+
evidence_id
|
|
55
|
+
for question in questions
|
|
56
|
+
for evidence_id in question.required_evidence_ids
|
|
57
|
+
if evidence_id not in evidence
|
|
58
|
+
}
|
|
59
|
+
if missing:
|
|
60
|
+
raise DatasetError(f"Questions reference unknown evidence IDs: {sorted(missing)}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def canonical_dataset_bytes(sources: list[SourceUnit], questions: list[QuestionCase]) -> bytes:
|
|
64
|
+
value = {
|
|
65
|
+
"sources": [item.model_dump(mode="json") for item in sources],
|
|
66
|
+
"questions": [item.model_dump(mode="json") for item in questions],
|
|
67
|
+
}
|
|
68
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _duplicates(values: list[str]) -> set[str]:
|
|
72
|
+
return {value for value in values if values.count(value) > 1}
|
ragvidence/diagnosis.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Small evidence-backed diagnostic rules for the guided comparison."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def diagnose(comparison: dict[str, Any]) -> dict[str, str]:
|
|
7
|
+
summary = comparison["summary"]
|
|
8
|
+
if summary["fail_to_pass"] and not summary["pass_to_fail"]:
|
|
9
|
+
return {
|
|
10
|
+
"pattern": "Missing structural context in the baseline representation",
|
|
11
|
+
"evidence": (
|
|
12
|
+
f"{summary['fail_to_pass']} failed queries passed after enrichment "
|
|
13
|
+
"with no regressions."
|
|
14
|
+
),
|
|
15
|
+
"next_experiment": (
|
|
16
|
+
"Repeat on a frozen slice of your own corpus and inspect every changed rank."
|
|
17
|
+
),
|
|
18
|
+
"confidence": "medium",
|
|
19
|
+
"limit": (
|
|
20
|
+
"This rule identifies correlation in a small labeled comparison, "
|
|
21
|
+
"not universal causation."
|
|
22
|
+
),
|
|
23
|
+
}
|
|
24
|
+
if summary["pass_to_fail"]:
|
|
25
|
+
return {
|
|
26
|
+
"pattern": "Representation change introduced retrieval regressions",
|
|
27
|
+
"evidence": f"{summary['pass_to_fail']} previously successful queries now fail.",
|
|
28
|
+
"next_experiment": "Inspect regressed queries before adopting the intervention.",
|
|
29
|
+
"confidence": "high",
|
|
30
|
+
"limit": "The rule does not identify the underlying ranking mechanism.",
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
"pattern": "No clear heading-context benefit at the selected depth",
|
|
34
|
+
"evidence": "The comparison contains no clean fail-to-pass signal.",
|
|
35
|
+
"next_experiment": (
|
|
36
|
+
"Inspect failures and test a different diagnosed intervention without changing labels."
|
|
37
|
+
),
|
|
38
|
+
"confidence": "medium",
|
|
39
|
+
"limit": "Results apply only to this dataset, strategy and retrieval depth.",
|
|
40
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Evidence-level evaluation utilities."""
|
|
2
|
+
|
|
3
|
+
from ragvidence.evaluation.metrics import (
|
|
4
|
+
classify_change,
|
|
5
|
+
evidence_coverage_at_k,
|
|
6
|
+
first_relevant_rank,
|
|
7
|
+
precision_at_k,
|
|
8
|
+
reciprocal_rank,
|
|
9
|
+
sufficient_evidence_at_k,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"classify_change",
|
|
14
|
+
"evidence_coverage_at_k",
|
|
15
|
+
"first_relevant_rank",
|
|
16
|
+
"precision_at_k",
|
|
17
|
+
"reciprocal_rank",
|
|
18
|
+
"sufficient_evidence_at_k",
|
|
19
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Compose evidence-level metrics into a reproducible evaluation result."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ragvidence.evaluation.metrics import (
|
|
8
|
+
evidence_coverage_at_k,
|
|
9
|
+
first_relevant_rank,
|
|
10
|
+
precision_at_k,
|
|
11
|
+
reciprocal_rank,
|
|
12
|
+
sufficient_evidence_at_k,
|
|
13
|
+
)
|
|
14
|
+
from ragvidence.models import ExperimentRun, QuestionCase
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def evaluate_run(run: ExperimentRun, questions: list[QuestionCase]) -> dict[str, Any]:
|
|
18
|
+
by_id = {item.question_id: item for item in questions}
|
|
19
|
+
results = []
|
|
20
|
+
for query in run.queries:
|
|
21
|
+
question = by_id[query.question_id]
|
|
22
|
+
ranking = [hit.evidence_ids for hit in query.hits]
|
|
23
|
+
required = question.required_evidence_ids
|
|
24
|
+
results.append(
|
|
25
|
+
{
|
|
26
|
+
"question_id": query.question_id,
|
|
27
|
+
"split": question.split,
|
|
28
|
+
"answerable": question.answerable,
|
|
29
|
+
"required_evidence_ids": sorted(required),
|
|
30
|
+
"sufficient_evidence": sufficient_evidence_at_k(
|
|
31
|
+
required, ranking, run.manifest.config.k
|
|
32
|
+
),
|
|
33
|
+
"evidence_coverage": evidence_coverage_at_k(
|
|
34
|
+
required, ranking, run.manifest.config.k
|
|
35
|
+
),
|
|
36
|
+
"first_relevant_rank": first_relevant_rank(
|
|
37
|
+
required, ranking, run.manifest.config.k
|
|
38
|
+
),
|
|
39
|
+
"reciprocal_rank": reciprocal_rank(required, ranking, run.manifest.config.k),
|
|
40
|
+
"precision_at_k": precision_at_k(required, ranking, run.manifest.config.k),
|
|
41
|
+
"hits": [hit.model_dump(mode="json") for hit in query.hits],
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
answerable = [item for item in results if item["answerable"]]
|
|
45
|
+
successes = sum(item["sufficient_evidence"] is True for item in answerable)
|
|
46
|
+
return {
|
|
47
|
+
"manifest": run.manifest.model_dump(mode="json"),
|
|
48
|
+
"summary": {
|
|
49
|
+
"successful": successes,
|
|
50
|
+
"answerable": len(answerable),
|
|
51
|
+
"sufficient_evidence_rate": successes / len(answerable) if answerable else None,
|
|
52
|
+
},
|
|
53
|
+
"questions": results,
|
|
54
|
+
}
|