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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ragaudit: audits RAG pipelines by checking whether retrieval did the work."""
|
|
File without changes
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""The Adapter Protocol and loaders for user-supplied adapter files."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import ModuleType
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from ragaudit.models.adapter import Adapter, ConfigurableAdapter, RAGResponse
|
|
9
|
+
|
|
10
|
+
from .callable_adapter import CallableAdapter
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Adapter",
|
|
14
|
+
"ConfigurableAdapter",
|
|
15
|
+
"RAGResponse",
|
|
16
|
+
"AdapterLoadError",
|
|
17
|
+
"AdapterFactory",
|
|
18
|
+
"load_adapter",
|
|
19
|
+
"load_adapter_factory",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
AdapterFactory = Callable[[dict], Adapter]
|
|
23
|
+
"""config dict -> Adapter configured with it."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AdapterLoadError(Exception):
|
|
27
|
+
"""Raised when an adapter file can't be loaded or doesn't expose an adapter."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_adapter(path: str) -> Adapter:
|
|
31
|
+
"""Load an Adapter from a Python file, for the `--adapter` CLI flag.
|
|
32
|
+
|
|
33
|
+
The file must define either a module-level `adapter` object satisfying
|
|
34
|
+
the Adapter Protocol, or a module-level `query(question: str) -> RAGResponse`
|
|
35
|
+
function, which is wrapped into an Adapter via CallableAdapter.
|
|
36
|
+
"""
|
|
37
|
+
module = _import_adapter_module(path)
|
|
38
|
+
|
|
39
|
+
adapter = getattr(module, "adapter", None)
|
|
40
|
+
if adapter is not None:
|
|
41
|
+
return adapter
|
|
42
|
+
|
|
43
|
+
query_fn = getattr(module, "query", None)
|
|
44
|
+
if callable(query_fn):
|
|
45
|
+
return CallableAdapter(query_fn)
|
|
46
|
+
|
|
47
|
+
raise AdapterLoadError(
|
|
48
|
+
f"Adapter file '{path}' must define a module-level `adapter` object "
|
|
49
|
+
"or a `query(question: str) -> RAGResponse` function."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_adapter_factory(path: str) -> AdapterFactory:
|
|
54
|
+
"""Load an adapter file as a factory that builds a pipeline from a config dict (v2 evaluate/tune).
|
|
55
|
+
|
|
56
|
+
Checked in order:
|
|
57
|
+
|
|
58
|
+
1. a module-level `make_adapter(config: dict) -> Adapter` function;
|
|
59
|
+
2. an `adapter` object (or `query` function) whose adapter has a
|
|
60
|
+
`configure(config: dict)` method, called before returning it;
|
|
61
|
+
3. a plain v1 adapter, which only accepts an empty config.
|
|
62
|
+
"""
|
|
63
|
+
module = _import_adapter_module(path)
|
|
64
|
+
|
|
65
|
+
make_adapter = getattr(module, "make_adapter", None)
|
|
66
|
+
if callable(make_adapter):
|
|
67
|
+
return lambda config: make_adapter(dict(config))
|
|
68
|
+
|
|
69
|
+
query_fn = getattr(module, "query", None)
|
|
70
|
+
if getattr(module, "adapter", None) is not None:
|
|
71
|
+
base = module.adapter
|
|
72
|
+
elif callable(query_fn):
|
|
73
|
+
base = CallableAdapter(query_fn)
|
|
74
|
+
else:
|
|
75
|
+
raise AdapterLoadError(
|
|
76
|
+
f"Adapter file '{path}' must define `make_adapter(config)`, a module-level `adapter` object, "
|
|
77
|
+
"or a `query(question: str) -> RAGResponse` function."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def factory(config: dict) -> Adapter:
|
|
81
|
+
configure = getattr(base, "configure", None)
|
|
82
|
+
if callable(configure):
|
|
83
|
+
configure(dict(config))
|
|
84
|
+
return base
|
|
85
|
+
if config:
|
|
86
|
+
raise AdapterLoadError(
|
|
87
|
+
f"Adapter file '{path}' doesn't accept a config: define `make_adapter(config)` or give the "
|
|
88
|
+
"adapter a `configure(config)` method to use --config or `ragaudit tune`."
|
|
89
|
+
)
|
|
90
|
+
return base
|
|
91
|
+
|
|
92
|
+
return factory
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _import_adapter_module(path: str) -> ModuleType:
|
|
96
|
+
file_path = Path(path)
|
|
97
|
+
if not file_path.is_file():
|
|
98
|
+
raise AdapterLoadError(f"Adapter file not found: {path}")
|
|
99
|
+
|
|
100
|
+
spec = importlib.util.spec_from_file_location(file_path.stem, file_path)
|
|
101
|
+
if spec is None or spec.loader is None:
|
|
102
|
+
raise AdapterLoadError(f"Could not load adapter module from: {path}")
|
|
103
|
+
|
|
104
|
+
module = importlib.util.module_from_spec(spec)
|
|
105
|
+
try:
|
|
106
|
+
spec.loader.exec_module(module)
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
raise AdapterLoadError(f"Adapter file raised an error on import: {path}") from exc
|
|
109
|
+
return module
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Wraps a plain callable into the Adapter Protocol."""
|
|
2
|
+
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from ragaudit.models.adapter import RAGResponse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CallableAdapter:
|
|
9
|
+
"""Adapts a plain `query(question: str) -> RAGResponse` callable to Adapter."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, fn: Callable[[str], RAGResponse]) -> None:
|
|
12
|
+
self._fn = fn
|
|
13
|
+
|
|
14
|
+
def query(self, question: str) -> RAGResponse:
|
|
15
|
+
return self._fn(question)
|
ragaudit/cli.py
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
"""The ragaudit CLI: ingest -> generate -> run -> report.
|
|
2
|
+
|
|
3
|
+
This module only wires together already-implemented modules (loading,
|
|
4
|
+
chunking, generation, running, ablation, scoring aggregation, storage,
|
|
5
|
+
and reporting) behind a Typer app. It contains no scoring or business
|
|
6
|
+
logic of its own.
|
|
7
|
+
|
|
8
|
+
v2 adds `generate --corpus`, `evaluate` and `tune`; their bodies live in
|
|
9
|
+
cli_v2.py. Without --corpus, `generate` runs exactly the v1 code path.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import functools
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from ragaudit import cli_easy, cli_v2
|
|
21
|
+
from ragaudit.adapters.base import AdapterLoadError, load_adapter
|
|
22
|
+
from ragaudit.generate.pipeline import (
|
|
23
|
+
DEFAULT_LEXICAL_THRESHOLD,
|
|
24
|
+
DEFAULT_MAX_FACTS_PER_CHUNK,
|
|
25
|
+
DEFAULT_MULTI_HOP_PAIRS,
|
|
26
|
+
)
|
|
27
|
+
from ragaudit.generate.testset_generator import generate_test_set
|
|
28
|
+
from ragaudit.ingest import chunker
|
|
29
|
+
from ragaudit.ingest.loader import load_documents as load_documents_from_disk
|
|
30
|
+
from ragaudit.llm.client import LLMClient, LLMClientError
|
|
31
|
+
from ragaudit.models.run import Run, RunConfig
|
|
32
|
+
from ragaudit.models.scoring import Verdict
|
|
33
|
+
from ragaudit.report.generator import generate_report
|
|
34
|
+
from ragaudit.runner.ablation import run_ablation
|
|
35
|
+
from ragaudit.runner.evaluate import DEFAULT_K, DEFAULT_REGRESSION_SAMPLE
|
|
36
|
+
from ragaudit.runner.executor import run_test_set, score_by_id
|
|
37
|
+
from ragaudit.runner.summary import compute_run_summary
|
|
38
|
+
from ragaudit.scoring.correctness import judge_correctness
|
|
39
|
+
from ragaudit.scoring.groundedness import judge_groundedness
|
|
40
|
+
from ragaudit.scoring.retrieval import score_retrieval_by_text
|
|
41
|
+
from ragaudit.scoring.text_match import DEFAULT_MATCH_THRESHOLD, validate_match_by
|
|
42
|
+
from ragaudit.storage import db as db_module
|
|
43
|
+
from ragaudit.storage import repository
|
|
44
|
+
from ragaudit.env import load_dotenv
|
|
45
|
+
from ragaudit.scaffold import DEFAULT_ADAPTER_PATH
|
|
46
|
+
from ragaudit.storage.store import DEFAULT_STORE_PATH
|
|
47
|
+
from ragaudit.tune.loop import DEFAULT_MAX_TRIALS, DEFAULT_OVERFIT_THRESHOLD
|
|
48
|
+
|
|
49
|
+
app = typer.Typer(help="Audits RAG pipelines by checking whether retrieval actually did the work.")
|
|
50
|
+
|
|
51
|
+
DB_OPTION = typer.Option(Path("ragaudit.db"), "--db", help="SQLite database path.")
|
|
52
|
+
STORE_OPTION = typer.Option(DEFAULT_STORE_PATH, "--store", help="v2 store path.")
|
|
53
|
+
LLM_OPTION = typer.Option(
|
|
54
|
+
None, "--llm", help="v2 LLM provider: openai (default, RAGAUDIT_API_*) or mock (offline). Env: RAGAUDIT_LLM."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
NO_CONTEXT_SYSTEM_PROMPT = (
|
|
58
|
+
"Answer the question using only your own internal knowledge. You have not "
|
|
59
|
+
"been given any retrieved context or documents to work from."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _error_exit(message: str) -> None:
|
|
64
|
+
"""Print a clean, colored error to stderr and exit with status 1."""
|
|
65
|
+
typer.secho(f"Error: {message}", fg=typer.colors.RED, err=True)
|
|
66
|
+
raise typer.Exit(code=1)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _cli_safe(func):
|
|
70
|
+
"""Convert any exception that escapes a command into a clean error, never a stack trace."""
|
|
71
|
+
|
|
72
|
+
@functools.wraps(func)
|
|
73
|
+
def wrapper(*args, **kwargs):
|
|
74
|
+
try:
|
|
75
|
+
return func(*args, **kwargs)
|
|
76
|
+
except typer.Exit:
|
|
77
|
+
raise
|
|
78
|
+
except AdapterLoadError as exc:
|
|
79
|
+
_error_exit(str(exc))
|
|
80
|
+
except LLMClientError as exc:
|
|
81
|
+
_error_exit(f"LLM request failed: {exc}")
|
|
82
|
+
except KeyError as exc:
|
|
83
|
+
_error_exit(f"missing required environment variable: {exc}")
|
|
84
|
+
except Exception as exc: # last-resort safety net
|
|
85
|
+
_error_exit(f"unexpected error: {exc}")
|
|
86
|
+
|
|
87
|
+
return wrapper
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.callback()
|
|
91
|
+
def _load_settings() -> None:
|
|
92
|
+
# RAGAUDIT_* settings can live in ./.env (see `ragaudit init`); exported variables win.
|
|
93
|
+
load_dotenv()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.command()
|
|
97
|
+
@_cli_safe
|
|
98
|
+
def init(
|
|
99
|
+
framework: str = typer.Option(
|
|
100
|
+
"plain", "--framework",
|
|
101
|
+
help="Adapter template: plain, langchain, llamaindex, or demo (a ready-to-run toy pipeline, no edits needed).",
|
|
102
|
+
),
|
|
103
|
+
out: Path = typer.Option(DEFAULT_ADAPTER_PATH, "--out", help="Where to write the adapter template."),
|
|
104
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing files."),
|
|
105
|
+
docs: Path = typer.Option(
|
|
106
|
+
cli_easy.DEFAULT_DEMO_DOCS, "--docs", help="demo only: the docs folder the demo pipeline searches."
|
|
107
|
+
),
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Write a starter adapter (one TODO: plug in your pipeline) and a .env.example. --framework demo needs no edits."""
|
|
110
|
+
cli_easy.run_init(framework, out, force, docs)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command()
|
|
114
|
+
@_cli_safe
|
|
115
|
+
def doctor(
|
|
116
|
+
adapter: Path = typer.Option(DEFAULT_ADAPTER_PATH, "--adapter", help="Adapter file to check."),
|
|
117
|
+
llm: Optional[str] = LLM_OPTION,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Check your setup: Python, RAGAUDIT_* settings, one LLM call, and the adapter. Prints a fix for each failure."""
|
|
120
|
+
cli_easy.run_doctor(adapter, llm)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command()
|
|
124
|
+
@_cli_safe
|
|
125
|
+
def ingest(
|
|
126
|
+
path: Path = typer.Argument(..., help="Directory containing .txt/.md documents to ingest."),
|
|
127
|
+
db: Path = DB_OPTION,
|
|
128
|
+
chunk_size: int = typer.Option(chunker.DEFAULT_CHUNK_SIZE, "--chunk-size", help="Characters per chunk."),
|
|
129
|
+
overlap: int = typer.Option(chunker.DEFAULT_OVERLAP, "--overlap", help="Character overlap between chunks."),
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Load documents from PATH, chunk them, and persist to the database."""
|
|
132
|
+
if not path.exists():
|
|
133
|
+
_error_exit(f"path not found: {path}")
|
|
134
|
+
|
|
135
|
+
documents = load_documents_from_disk(path)
|
|
136
|
+
if not documents:
|
|
137
|
+
_error_exit(f"no .txt/.md documents found under {path}")
|
|
138
|
+
|
|
139
|
+
all_chunks = []
|
|
140
|
+
with typer.progressbar(documents, label="Chunking documents") as progress:
|
|
141
|
+
for document in progress:
|
|
142
|
+
all_chunks.extend(chunker.chunk_document(document, chunk_size=chunk_size, overlap=overlap))
|
|
143
|
+
|
|
144
|
+
conn = db_module.get_connection(db)
|
|
145
|
+
try:
|
|
146
|
+
repository.save_documents(conn, documents)
|
|
147
|
+
repository.save_chunks(conn, all_chunks)
|
|
148
|
+
finally:
|
|
149
|
+
conn.close()
|
|
150
|
+
|
|
151
|
+
typer.echo(f"Ingested {len(documents)} document(s) into {len(all_chunks)} chunk(s). Saved to {db}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.command()
|
|
155
|
+
@_cli_safe
|
|
156
|
+
def generate(
|
|
157
|
+
n: Optional[int] = typer.Option(
|
|
158
|
+
None,
|
|
159
|
+
"--n",
|
|
160
|
+
help="Number of questions to generate per chunk (default 1). With --corpus: max facts per chunk (default 3).",
|
|
161
|
+
),
|
|
162
|
+
db: Path = DB_OPTION,
|
|
163
|
+
corpus: Optional[Path] = typer.Option(
|
|
164
|
+
None,
|
|
165
|
+
"--corpus",
|
|
166
|
+
help="v2: build a verified, de-worded, dev/test-split question set from this .jsonl file or directory.",
|
|
167
|
+
),
|
|
168
|
+
store: Path = STORE_OPTION,
|
|
169
|
+
llm: Optional[str] = LLM_OPTION,
|
|
170
|
+
generator_model: Optional[str] = typer.Option(None, "--generator-model", help="v2: generator model."),
|
|
171
|
+
verifier_model: Optional[str] = typer.Option(
|
|
172
|
+
None, "--verifier-model", help="v2: verifier model; must differ from the generator."
|
|
173
|
+
),
|
|
174
|
+
max_chunks: Optional[int] = typer.Option(None, "--max-chunks", help="v2: process at most this many chunks."),
|
|
175
|
+
max_llm_calls: Optional[int] = typer.Option(None, "--max-llm-calls", help="v2: stop after this many LLM calls."),
|
|
176
|
+
multi_hop: bool = typer.Option(False, "--multi-hop", help="v2: also write questions that need two chunks."),
|
|
177
|
+
multi_hop_pairs: int = typer.Option(
|
|
178
|
+
DEFAULT_MULTI_HOP_PAIRS, "--multi-hop-pairs", help="v2: max chunk pairs for multi-hop per run."
|
|
179
|
+
),
|
|
180
|
+
lexical_threshold: float = typer.Option(
|
|
181
|
+
DEFAULT_LEXICAL_THRESHOLD,
|
|
182
|
+
"--lexical-threshold",
|
|
183
|
+
help="v2: max share of question content words found in the chunk before a rewrite is rejected.",
|
|
184
|
+
),
|
|
185
|
+
retry_processed: bool = typer.Option(
|
|
186
|
+
False, "--retry-processed", help="v2: re-process chunks that previously yielded no active question."
|
|
187
|
+
),
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Generate a test set from ingested chunks and persist it."""
|
|
190
|
+
if corpus is not None:
|
|
191
|
+
cli_v2.run_generate(
|
|
192
|
+
corpus=corpus,
|
|
193
|
+
store_path=store,
|
|
194
|
+
max_facts_per_chunk=n if n is not None else DEFAULT_MAX_FACTS_PER_CHUNK,
|
|
195
|
+
llm=llm,
|
|
196
|
+
generator_model=generator_model,
|
|
197
|
+
verifier_model=verifier_model,
|
|
198
|
+
max_chunks=max_chunks,
|
|
199
|
+
max_llm_calls=max_llm_calls,
|
|
200
|
+
multi_hop=multi_hop,
|
|
201
|
+
multi_hop_pairs=multi_hop_pairs,
|
|
202
|
+
lexical_threshold=lexical_threshold,
|
|
203
|
+
retry_processed=retry_processed,
|
|
204
|
+
)
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
n = 1 if n is None else n
|
|
208
|
+
if not db.exists():
|
|
209
|
+
_error_exit(f"database not found at {db} — run `ragaudit ingest <path>` first.")
|
|
210
|
+
|
|
211
|
+
conn = db_module.get_connection(db)
|
|
212
|
+
try:
|
|
213
|
+
documents = repository.load_documents(conn)
|
|
214
|
+
chunks = repository.load_chunks(conn)
|
|
215
|
+
if not documents or not chunks:
|
|
216
|
+
_error_exit("no ingested documents found — run `ragaudit ingest <path>` first.")
|
|
217
|
+
|
|
218
|
+
client = LLMClient()
|
|
219
|
+
typer.echo(
|
|
220
|
+
f"Generating {n} question(s) per chunk for {len(chunks)} chunk(s) using {client.model} "
|
|
221
|
+
"(this may take a while)..."
|
|
222
|
+
)
|
|
223
|
+
test_set = generate_test_set(client, documents, chunks, n_per_chunk=n)
|
|
224
|
+
repository.save_test_set(conn, test_set)
|
|
225
|
+
finally:
|
|
226
|
+
conn.close()
|
|
227
|
+
|
|
228
|
+
typer.echo(
|
|
229
|
+
f"Generated {len(test_set.test_cases)} test case(s) from {len(chunks)} chunk(s). "
|
|
230
|
+
f"Saved test set '{test_set.testset_id}' to {db}"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
MATCH_BY_OPTION = typer.Option(
|
|
235
|
+
"id",
|
|
236
|
+
"--match-by",
|
|
237
|
+
help="How a retrieval hit is decided: id (the adapter returns ragaudit's chunk ids; default) or text "
|
|
238
|
+
"(a returned context overlaps the gold chunk's text; works without re-indexing your pipeline).",
|
|
239
|
+
)
|
|
240
|
+
MATCH_THRESHOLD_OPTION = typer.Option(
|
|
241
|
+
DEFAULT_MATCH_THRESHOLD, "--match-threshold", help="--match-by text: minimum overlap (0-1) to count as a hit."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@app.command()
|
|
246
|
+
@_cli_safe
|
|
247
|
+
def evaluate(
|
|
248
|
+
adapter: Path = typer.Option(..., "--adapter", help="Adapter file: `make_adapter(config)`, `adapter`, or `query`."),
|
|
249
|
+
corpus: Optional[Path] = typer.Option(
|
|
250
|
+
None, "--corpus", help="Current corpus, to detect new/changed/deleted chunks (default: last recorded)."
|
|
251
|
+
),
|
|
252
|
+
config: Optional[Path] = typer.Option(None, "--config", help="YAML pipeline config passed to the adapter."),
|
|
253
|
+
split: str = typer.Option("dev", "--split", help="dev (default) or test. Test goes through the locked path."),
|
|
254
|
+
k: int = typer.Option(DEFAULT_K, "--k", help="Score retrieval over the top k retrieved chunk ids."),
|
|
255
|
+
regression_sample: int = typer.Option(
|
|
256
|
+
DEFAULT_REGRESSION_SAMPLE,
|
|
257
|
+
"--regression-sample",
|
|
258
|
+
help="When only the corpus changed: old questions to re-run alongside all new ones.",
|
|
259
|
+
),
|
|
260
|
+
seed: int = typer.Option(0, "--seed", help="Seed for the regression sample."),
|
|
261
|
+
full: bool = typer.Option(False, "--full", help="Ignore regression sampling and cover every active question."),
|
|
262
|
+
ablation: bool = typer.Option(False, "--ablation", help="Re-ask lucky passes with no context (v1 ablation)."),
|
|
263
|
+
store: Path = STORE_OPTION,
|
|
264
|
+
llm: Optional[str] = LLM_OPTION,
|
|
265
|
+
judge_model: Optional[str] = typer.Option(None, "--judge-model", help="Correctness judge model."),
|
|
266
|
+
match_by: str = MATCH_BY_OPTION,
|
|
267
|
+
match_threshold: float = MATCH_THRESHOLD_OPTION,
|
|
268
|
+
) -> None:
|
|
269
|
+
"""v2: evaluate a pipeline on generated questions, with caching and regression sampling."""
|
|
270
|
+
cli_v2.run_evaluate(
|
|
271
|
+
adapter_path=adapter,
|
|
272
|
+
corpus=corpus,
|
|
273
|
+
config_path=config,
|
|
274
|
+
split=split,
|
|
275
|
+
k=k,
|
|
276
|
+
regression_sample=regression_sample,
|
|
277
|
+
seed=seed,
|
|
278
|
+
store_path=store,
|
|
279
|
+
llm=llm,
|
|
280
|
+
judge_model=judge_model,
|
|
281
|
+
ablation=ablation,
|
|
282
|
+
full=full,
|
|
283
|
+
match_by=match_by,
|
|
284
|
+
match_threshold=match_threshold,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@app.command()
|
|
289
|
+
@_cli_safe
|
|
290
|
+
def tune(
|
|
291
|
+
adapter: Path = typer.Option(..., "--adapter", help="Adapter file exposing `make_adapter(config)`."),
|
|
292
|
+
search_space: Path = typer.Option(..., "--search-space", help="YAML file of knobs and their allowed values."),
|
|
293
|
+
corpus: Optional[Path] = typer.Option(None, "--corpus", help="Current corpus (default: last recorded)."),
|
|
294
|
+
strategy: str = typer.Option("grid", "--strategy", help="grid, random (seeded) or llm."),
|
|
295
|
+
max_trials: int = typer.Option(DEFAULT_MAX_TRIALS, "--max-trials", help="Max configs to evaluate, baseline included."),
|
|
296
|
+
max_llm_calls: Optional[int] = typer.Option(
|
|
297
|
+
None, "--max-llm-calls", help="Cap on judge + proposer calls; room for the final test evaluation is reserved."
|
|
298
|
+
),
|
|
299
|
+
seed: int = typer.Option(0, "--seed", help="Seed for the random strategy and fallbacks."),
|
|
300
|
+
k: int = typer.Option(DEFAULT_K, "--k", help="Score retrieval over the top k retrieved chunk ids."),
|
|
301
|
+
overfit_threshold: float = typer.Option(
|
|
302
|
+
DEFAULT_OVERFIT_THRESHOLD, "--overfit-threshold", help="Warn when best dev minus best test exceeds this (points)."
|
|
303
|
+
),
|
|
304
|
+
out: Path = typer.Option(Path("best_config.yaml"), "--out", help="Where to write the best config (YAML)."),
|
|
305
|
+
store: Path = STORE_OPTION,
|
|
306
|
+
llm: Optional[str] = LLM_OPTION,
|
|
307
|
+
judge_model: Optional[str] = typer.Option(None, "--judge-model", help="Correctness judge model."),
|
|
308
|
+
proposer_model: Optional[str] = typer.Option(None, "--proposer-model", help="Model for --strategy llm."),
|
|
309
|
+
) -> None:
|
|
310
|
+
"""v2: search pipeline configs on the dev split; report baseline vs best on the locked test split."""
|
|
311
|
+
cli_v2.run_tune_command(
|
|
312
|
+
adapter_path=adapter,
|
|
313
|
+
search_space_path=search_space,
|
|
314
|
+
corpus=corpus,
|
|
315
|
+
strategy=strategy,
|
|
316
|
+
max_trials=max_trials,
|
|
317
|
+
max_llm_calls=max_llm_calls,
|
|
318
|
+
seed=seed,
|
|
319
|
+
k=k,
|
|
320
|
+
overfit_threshold=overfit_threshold,
|
|
321
|
+
out=out,
|
|
322
|
+
store_path=store,
|
|
323
|
+
llm=llm,
|
|
324
|
+
judge_model=judge_model,
|
|
325
|
+
proposer_model=proposer_model,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _text_retrieval_scorer(chunk_texts: dict[str, str], threshold: float):
|
|
330
|
+
def scorer(test_case, response):
|
|
331
|
+
gold_text = chunk_texts.get(test_case.source_chunk_id)
|
|
332
|
+
if gold_text is None:
|
|
333
|
+
raise ValueError(f"chunk {test_case.source_chunk_id} is not in the database; re-run `ragaudit ingest`.")
|
|
334
|
+
return score_retrieval_by_text(
|
|
335
|
+
test_case.source_chunk_id, gold_text, response.retrieved_chunk_ids, response.contexts, threshold
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
return scorer
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _execute_run(
|
|
342
|
+
conn,
|
|
343
|
+
test_set,
|
|
344
|
+
adapter_path: Path,
|
|
345
|
+
client,
|
|
346
|
+
*,
|
|
347
|
+
run_ablation_flag: bool,
|
|
348
|
+
match_by: str,
|
|
349
|
+
match_threshold: float,
|
|
350
|
+
) -> Run:
|
|
351
|
+
"""Run, score, ablate and save one Run. Shared by `run` and `check`."""
|
|
352
|
+
loaded_adapter = load_adapter(str(adapter_path))
|
|
353
|
+
|
|
354
|
+
def correctness_judge(question: str, expected_answer: str, candidate_answer: str):
|
|
355
|
+
return judge_correctness(client, question, expected_answer, candidate_answer)
|
|
356
|
+
|
|
357
|
+
def groundedness_judge(answer: str, contexts: list[str]):
|
|
358
|
+
return judge_groundedness(client, answer, contexts)
|
|
359
|
+
|
|
360
|
+
if match_by == "text":
|
|
361
|
+
chunk_texts = {chunk.chunk_id: chunk.text for chunk in repository.load_chunks(conn)}
|
|
362
|
+
retrieval_scorer = _text_retrieval_scorer(chunk_texts, match_threshold)
|
|
363
|
+
typer.echo(f"Matching retrieval by text (threshold {match_threshold:g}), using the contexts the adapter returns.")
|
|
364
|
+
else:
|
|
365
|
+
retrieval_scorer = score_by_id
|
|
366
|
+
|
|
367
|
+
typer.echo(f"Running {len(test_set.test_cases)} test case(s) through adapter {adapter_path}...")
|
|
368
|
+
scored_cases = run_test_set(test_set, loaded_adapter, correctness_judge, groundedness_judge, retrieval_scorer)
|
|
369
|
+
|
|
370
|
+
if match_by == "text" and scored_cases and not any(case.response.contexts for case in scored_cases):
|
|
371
|
+
typer.secho(
|
|
372
|
+
"Warning: --match-by text needs the retrieved passages, but the adapter returned no `contexts` for any "
|
|
373
|
+
"question, so every case counts as a retrieval miss. Return the retrieved texts in RAGResponse.contexts.",
|
|
374
|
+
fg=typer.colors.YELLOW,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
if run_ablation_flag:
|
|
378
|
+
lucky_pass_count = sum(1 for case in scored_cases if case.verdict == Verdict.LUCKY_PASS)
|
|
379
|
+
if lucky_pass_count:
|
|
380
|
+
typer.echo(f"Ablation: enabled — running against {lucky_pass_count} lucky-pass candidate(s)...")
|
|
381
|
+
|
|
382
|
+
def no_context_query(question: str) -> str:
|
|
383
|
+
return client.complete(NO_CONTEXT_SYSTEM_PROMPT, question)
|
|
384
|
+
|
|
385
|
+
def check_correctness(question: str, expected_answer: str, candidate_answer: str) -> bool:
|
|
386
|
+
return judge_correctness(client, question, expected_answer, candidate_answer).is_correct
|
|
387
|
+
|
|
388
|
+
scored_cases = run_ablation(scored_cases, test_set.test_cases, no_context_query, check_correctness)
|
|
389
|
+
else:
|
|
390
|
+
typer.echo("Ablation: enabled, but no lucky-pass candidates were found — nothing to ablate.")
|
|
391
|
+
else:
|
|
392
|
+
typer.echo("Ablation: disabled (--no-ablation).")
|
|
393
|
+
|
|
394
|
+
summary = compute_run_summary(scored_cases)
|
|
395
|
+
run = Run(
|
|
396
|
+
config=RunConfig(
|
|
397
|
+
run_id=str(uuid.uuid4()),
|
|
398
|
+
testset_id=test_set.testset_id,
|
|
399
|
+
adapter_path=str(adapter_path),
|
|
400
|
+
correctness_judge_model=client.model,
|
|
401
|
+
groundedness_judge_model=client.model,
|
|
402
|
+
run_ablation=run_ablation_flag,
|
|
403
|
+
started_at=datetime.now(timezone.utc),
|
|
404
|
+
),
|
|
405
|
+
finished_at=datetime.now(timezone.utc),
|
|
406
|
+
scored_cases=scored_cases,
|
|
407
|
+
summary=summary,
|
|
408
|
+
)
|
|
409
|
+
repository.save_run(conn, run)
|
|
410
|
+
return run
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@app.command(name="run")
|
|
414
|
+
@_cli_safe
|
|
415
|
+
def run_cmd(
|
|
416
|
+
adapter: Path = typer.Option(..., "--adapter", help="Path to a Python file exposing `query` or `adapter`."),
|
|
417
|
+
db: Path = DB_OPTION,
|
|
418
|
+
testset_id: Optional[str] = typer.Option(
|
|
419
|
+
None, "--testset-id", help="Test set id to run (defaults to the most recently generated)."
|
|
420
|
+
),
|
|
421
|
+
no_ablation: bool = typer.Option(False, "--no-ablation", help="Skip the retrieval-disabled ablation step."),
|
|
422
|
+
match_by: str = MATCH_BY_OPTION,
|
|
423
|
+
match_threshold: float = MATCH_THRESHOLD_OPTION,
|
|
424
|
+
) -> None:
|
|
425
|
+
"""Run the adapter over a test set, score it, ablate lucky passes, and persist the Run."""
|
|
426
|
+
try:
|
|
427
|
+
validate_match_by(match_by)
|
|
428
|
+
except ValueError as exc:
|
|
429
|
+
_error_exit(str(exc))
|
|
430
|
+
if not db.exists():
|
|
431
|
+
_error_exit(f"database not found at {db} — run `ragaudit ingest` and `ragaudit generate` first.")
|
|
432
|
+
|
|
433
|
+
conn = db_module.get_connection(db)
|
|
434
|
+
try:
|
|
435
|
+
resolved_testset_id = testset_id or repository.get_latest_testset_id(conn)
|
|
436
|
+
if resolved_testset_id is None:
|
|
437
|
+
_error_exit("no test set found — run `ragaudit generate` first.")
|
|
438
|
+
|
|
439
|
+
test_set = repository.load_test_set(conn, resolved_testset_id)
|
|
440
|
+
if test_set is None:
|
|
441
|
+
_error_exit(f"test set '{resolved_testset_id}' not found.")
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
load_adapter(str(adapter))
|
|
445
|
+
except AdapterLoadError as exc:
|
|
446
|
+
_error_exit(str(exc))
|
|
447
|
+
|
|
448
|
+
run = _execute_run(
|
|
449
|
+
conn,
|
|
450
|
+
test_set,
|
|
451
|
+
adapter,
|
|
452
|
+
LLMClient(),
|
|
453
|
+
run_ablation_flag=not no_ablation,
|
|
454
|
+
match_by=match_by,
|
|
455
|
+
match_threshold=match_threshold,
|
|
456
|
+
)
|
|
457
|
+
finally:
|
|
458
|
+
conn.close()
|
|
459
|
+
|
|
460
|
+
typer.echo(
|
|
461
|
+
f"Run '{run.config.run_id}' complete: {run.summary.total_cases} case(s), "
|
|
462
|
+
f"lucky-pass rate {run.summary.lucky_pass_rate:.1%}. Saved to {db}"
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@app.command()
|
|
467
|
+
@_cli_safe
|
|
468
|
+
def check(
|
|
469
|
+
docs: Path = typer.Option(..., "--docs", help="Folder of .txt/.md documents your pipeline answers from."),
|
|
470
|
+
adapter: Path = typer.Option(DEFAULT_ADAPTER_PATH, "--adapter", help="Adapter file (see `ragaudit init`)."),
|
|
471
|
+
questions: int = typer.Option(
|
|
472
|
+
cli_easy.DEFAULT_CHECK_QUESTIONS, "--questions", help="How many test questions to write in total."
|
|
473
|
+
),
|
|
474
|
+
match_by: str = typer.Option(
|
|
475
|
+
"text", "--match-by", help="text (default: compare returned contexts with the source passage) or id."
|
|
476
|
+
),
|
|
477
|
+
match_threshold: float = MATCH_THRESHOLD_OPTION,
|
|
478
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
479
|
+
no_open: bool = typer.Option(False, "--no-open", help="Don't open the report in the browser."),
|
|
480
|
+
max_llm_calls: Optional[int] = typer.Option(
|
|
481
|
+
None, "--max-llm-calls", help="Hard cap on LLM calls (default: the estimate's upper bound)."
|
|
482
|
+
),
|
|
483
|
+
db: Path = typer.Option(cli_easy.DEFAULT_CHECK_DB, "--db", help="Working database, recreated on every check."),
|
|
484
|
+
out: Path = typer.Option(cli_easy.DEFAULT_CHECK_REPORT, "--out", help="Where to write the HTML report."),
|
|
485
|
+
llm: Optional[str] = LLM_OPTION,
|
|
486
|
+
) -> None:
|
|
487
|
+
"""Test your RAG end to end: write questions from your docs, run your adapter, score it, open the report."""
|
|
488
|
+
cli_easy.run_check(
|
|
489
|
+
docs, adapter, questions, match_by, match_threshold, yes, no_open, max_llm_calls, db, out, llm
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@app.command()
|
|
494
|
+
@_cli_safe
|
|
495
|
+
def report(
|
|
496
|
+
out: Optional[Path] = typer.Option(
|
|
497
|
+
None, "--out", help="Output HTML file path (default report.html; generate-report.html / tune-report.html for v2 kinds)."
|
|
498
|
+
),
|
|
499
|
+
db: Path = DB_OPTION,
|
|
500
|
+
run_id: Optional[str] = typer.Option(
|
|
501
|
+
None, "--run-id", help="Run id to report on (defaults to the most recently run). For v2 kinds, the generate run or tune id."
|
|
502
|
+
),
|
|
503
|
+
kind: str = typer.Option(
|
|
504
|
+
"run", "--kind", help="run (v1 run report, the default), generate (v2 generate run) or tune (v2 tune run)."
|
|
505
|
+
),
|
|
506
|
+
store: Path = STORE_OPTION,
|
|
507
|
+
) -> None:
|
|
508
|
+
"""Render the latest (or specified) Run into a static HTML report. v2: --kind generate|tune."""
|
|
509
|
+
if kind in ("generate", "tune"):
|
|
510
|
+
output_path = cli_v2.run_report(kind, store, out or Path(f"{kind}-report.html"), run_id)
|
|
511
|
+
typer.echo(f"Report written to {output_path}")
|
|
512
|
+
return
|
|
513
|
+
if kind != "run":
|
|
514
|
+
_error_exit(f"--kind must be run, generate or tune, got {kind!r}")
|
|
515
|
+
out = out or Path("report.html")
|
|
516
|
+
if not db.exists():
|
|
517
|
+
_error_exit(f"database not found at {db} — run `ragaudit ingest`, `generate`, and `run` first.")
|
|
518
|
+
|
|
519
|
+
conn = db_module.get_connection(db)
|
|
520
|
+
try:
|
|
521
|
+
resolved_run_id = run_id or repository.get_latest_run_id(conn)
|
|
522
|
+
if resolved_run_id is None:
|
|
523
|
+
_error_exit("no run found — run `ragaudit run --adapter <path>` first.")
|
|
524
|
+
|
|
525
|
+
run = repository.load_run(conn, resolved_run_id)
|
|
526
|
+
if run is None:
|
|
527
|
+
_error_exit(f"run '{resolved_run_id}' not found.")
|
|
528
|
+
|
|
529
|
+
test_set = repository.load_test_set(conn, run.config.testset_id)
|
|
530
|
+
if test_set is None:
|
|
531
|
+
_error_exit(f"test set '{run.config.testset_id}' referenced by run '{resolved_run_id}' not found.")
|
|
532
|
+
finally:
|
|
533
|
+
conn.close()
|
|
534
|
+
|
|
535
|
+
output_path = generate_report(run, test_set, out)
|
|
536
|
+
typer.echo(f"Report written to {output_path}")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
if __name__ == "__main__":
|
|
540
|
+
app()
|