ragaudit-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. ragaudit/__init__.py +1 -0
  2. ragaudit/adapters/__init__.py +0 -0
  3. ragaudit/adapters/base.py +109 -0
  4. ragaudit/adapters/callable_adapter.py +15 -0
  5. ragaudit/cli.py +540 -0
  6. ragaudit/cli_easy.py +458 -0
  7. ragaudit/cli_v2.py +507 -0
  8. ragaudit/env.py +46 -0
  9. ragaudit/generate/__init__.py +0 -0
  10. ragaudit/generate/pipeline.py +486 -0
  11. ragaudit/generate/prompts.py +116 -0
  12. ragaudit/generate/testset_generator.py +146 -0
  13. ragaudit/generate/text.py +75 -0
  14. ragaudit/ingest/__init__.py +0 -0
  15. ragaudit/ingest/chunker.py +53 -0
  16. ragaudit/ingest/corpus.py +120 -0
  17. ragaudit/ingest/loader.py +49 -0
  18. ragaudit/ingest/versioning.py +92 -0
  19. ragaudit/llm/__init__.py +0 -0
  20. ragaudit/llm/client.py +86 -0
  21. ragaudit/llm/factory.py +70 -0
  22. ragaudit/llm/json_output.py +72 -0
  23. ragaudit/llm/mock.py +552 -0
  24. ragaudit/models/__init__.py +0 -0
  25. ragaudit/models/adapter.py +39 -0
  26. ragaudit/models/document.py +28 -0
  27. ragaudit/models/evaluation.py +70 -0
  28. ragaudit/models/question.py +152 -0
  29. ragaudit/models/run.py +44 -0
  30. ragaudit/models/scoring.py +82 -0
  31. ragaudit/models/testset.py +30 -0
  32. ragaudit/models/tune.py +56 -0
  33. ragaudit/report/__init__.py +0 -0
  34. ragaudit/report/generator.py +155 -0
  35. ragaudit/report/plain_summary.py +122 -0
  36. ragaudit/report/templates/_v2_tooltip.jinja +43 -0
  37. ragaudit/report/templates/generate_report.html.jinja +175 -0
  38. ragaudit/report/templates/report.html.jinja +252 -0
  39. ragaudit/report/templates/style.css +1095 -0
  40. ragaudit/report/templates/tune_report.html.jinja +158 -0
  41. ragaudit/report/templates/v2.css +286 -0
  42. ragaudit/report/v2.py +380 -0
  43. ragaudit/runner/__init__.py +0 -0
  44. ragaudit/runner/ablation.py +46 -0
  45. ragaudit/runner/evaluate.py +325 -0
  46. ragaudit/runner/executor.py +98 -0
  47. ragaudit/runner/locked.py +37 -0
  48. ragaudit/runner/summary.py +45 -0
  49. ragaudit/scaffold.py +252 -0
  50. ragaudit/scoring/__init__.py +0 -0
  51. ragaudit/scoring/correctness.py +94 -0
  52. ragaudit/scoring/groundedness.py +86 -0
  53. ragaudit/scoring/retrieval.py +42 -0
  54. ragaudit/scoring/text_match.py +51 -0
  55. ragaudit/scoring/verdict.py +14 -0
  56. ragaudit/storage/__init__.py +0 -0
  57. ragaudit/storage/db.py +81 -0
  58. ragaudit/storage/lock.py +36 -0
  59. ragaudit/storage/repository.py +216 -0
  60. ragaudit/storage/store.py +536 -0
  61. ragaudit/tune/__init__.py +7 -0
  62. ragaudit/tune/devset.py +16 -0
  63. ragaudit/tune/loop.py +208 -0
  64. ragaudit/tune/search_space.py +140 -0
  65. ragaudit/tune/strategies.py +197 -0
  66. ragaudit/yaml_io.py +199 -0
  67. ragaudit_cli-0.3.0.dist-info/METADATA +429 -0
  68. ragaudit_cli-0.3.0.dist-info/RECORD +71 -0
  69. ragaudit_cli-0.3.0.dist-info/WHEEL +4 -0
  70. ragaudit_cli-0.3.0.dist-info/entry_points.txt +2 -0
  71. ragaudit_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
ragaudit/cli_easy.py ADDED
@@ -0,0 +1,458 @@
1
+ """Bodies of the first-run commands: `init`, `doctor` and `check`.
2
+
3
+ Registered in cli.py like the v2 commands in cli_v2.py.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ from contextlib import contextmanager
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ import typer
13
+ from pydantic import ValidationError
14
+
15
+ from ragaudit.adapters.base import AdapterLoadError, load_adapter
16
+ from ragaudit.llm.client import LLMClient, LLMClientError
17
+ from ragaudit.llm.factory import PROVIDER_ENV, resolve_provider
18
+ from ragaudit.models.adapter import RAGResponse
19
+ from ragaudit.scaffold import DEFAULT_DEMO_DOCS, DEMO_DOCS_ENV, ENV_EXAMPLE_NAME, FRAMEWORKS, write_starter_files
20
+
21
+
22
+ def _fail(message: str) -> None:
23
+ typer.secho(f"Error: {message}", fg=typer.colors.RED, err=True)
24
+ raise typer.Exit(code=1)
25
+
26
+
27
+ @contextmanager
28
+ def _adapter_env(llm: str | None, demo_docs: Path | None = None):
29
+ """Expose --llm (and check's --docs) to adapters that call ragaudit's LLM, like the demo one.
30
+
31
+ Sets RAGAUDIT_LLM only when --llm was passed, and restores the
32
+ environment afterwards.
33
+ """
34
+ updates = {}
35
+ if llm:
36
+ updates[PROVIDER_ENV] = llm
37
+ if demo_docs is not None:
38
+ updates[DEMO_DOCS_ENV] = str(demo_docs.resolve())
39
+ saved = {name: os.environ.get(name) for name in updates}
40
+ os.environ.update(updates)
41
+ try:
42
+ yield
43
+ finally:
44
+ for name, value in saved.items():
45
+ if value is None:
46
+ os.environ.pop(name, None)
47
+ else:
48
+ os.environ[name] = value
49
+
50
+
51
+ def adapter_llm_calls(adapter) -> int:
52
+ """LLM calls the adapter itself makes per question through ragaudit's LLM (the demo adapter: 1)."""
53
+ fn = getattr(adapter, "_fn", None) # a query() function wrapped by CallableAdapter
54
+ flagged = getattr(adapter, "USES_RAGAUDIT_LLM", False) or getattr(fn, "__globals__", {}).get("USES_RAGAUDIT_LLM", False)
55
+ return 1 if flagged else 0
56
+
57
+
58
+ # --- init --------------------------------------------------------------------
59
+
60
+
61
+ def run_init(framework: str, out: Path, force: bool, docs: Path = DEFAULT_DEMO_DOCS) -> list[Path]:
62
+ if framework not in FRAMEWORKS:
63
+ _fail(f"--framework must be one of {', '.join(FRAMEWORKS)}, got {framework!r}")
64
+ try:
65
+ written = write_starter_files(framework, out, force=force, docs=docs)
66
+ except FileExistsError as exc:
67
+ _fail(str(exc))
68
+ env_example = out.parent / ENV_EXAMPLE_NAME
69
+ env_file = out.parent / ".env"
70
+ if framework == "demo":
71
+ typer.echo(f"Wrote {out} (a ready-to-run demo pipeline: keyword search over {docs}) and {env_example}.")
72
+ if not docs.is_dir():
73
+ typer.echo(f" Note: {docs} does not exist yet; put a few .txt or .md files in it.")
74
+ typer.echo("Next:")
75
+ typer.echo(f" 1. cp {env_example} {env_file} and fill in your API settings (OpenAI-compatible or local Ollama).")
76
+ typer.echo(f" 2. ragaudit check --docs {docs} --adapter {out}")
77
+ typer.echo(" (add --llm mock to try it offline with a fake LLM first)")
78
+ return written
79
+ typer.echo(f"Wrote {out} ({framework} adapter template) and {env_example}.")
80
+ typer.echo("Next:")
81
+ typer.echo(f" 1. cp {env_example} {env_file} and fill in your API settings (OpenAI-compatible or local Ollama).")
82
+ typer.echo(f" 2. Open {out} and connect your pipeline at the TODO.")
83
+ typer.echo(f" 3. ragaudit doctor --adapter {out}")
84
+ typer.echo(f" 4. ragaudit check --docs ./your-docs --adapter {out}")
85
+ return written
86
+
87
+
88
+ # --- doctor ------------------------------------------------------------------
89
+
90
+
91
+ MIN_PYTHON = (3, 11)
92
+ API_ENV_VARS = ("RAGAUDIT_API_BASE", "RAGAUDIT_API_KEY", "RAGAUDIT_MODEL")
93
+ DOCTOR_QUESTION = "What is this document about?"
94
+ ENV_FIX = "add it to .env (`cp .env.example .env`, from `ragaudit init`) or export it in your shell."
95
+
96
+
97
+ @dataclass
98
+ class Check:
99
+ name: str
100
+ ok: bool
101
+ fix: str | None = None
102
+
103
+
104
+ def check_python(version: tuple = tuple(sys.version_info[:3])) -> Check:
105
+ shown = ".".join(str(part) for part in version)
106
+ if tuple(version[:2]) >= MIN_PYTHON:
107
+ return Check(f"Python {shown} (3.11+ required)", True)
108
+ return Check(
109
+ f"Python {shown} is too old (3.11+ required)",
110
+ False,
111
+ "install Python 3.11 or newer, then `python3.11 -m pip install ragaudit-cli` in a fresh virtualenv.",
112
+ )
113
+
114
+
115
+ def check_env_vars(mock: bool) -> list[Check]:
116
+ checks = []
117
+ examples = {
118
+ "RAGAUDIT_API_BASE": "RAGAUDIT_API_BASE=https://api.openai.com/v1 (or http://localhost:11434/v1 for Ollama)",
119
+ "RAGAUDIT_API_KEY": "RAGAUDIT_API_KEY=sk-... (or RAGAUDIT_API_KEY=ollama for Ollama)",
120
+ "RAGAUDIT_MODEL": "RAGAUDIT_MODEL=gpt-4o-mini (or an Ollama model such as llama3.1:8b)",
121
+ }
122
+ for name in API_ENV_VARS:
123
+ if os.environ.get(name):
124
+ checks.append(Check(f"{name} is set", True))
125
+ elif mock:
126
+ checks.append(Check(f"{name} is not set (not needed with the mock LLM)", True))
127
+ else:
128
+ checks.append(Check(f"{name} is not set", False, f"set {examples[name]}: {ENV_FIX}"))
129
+ return checks
130
+
131
+
132
+ def _is_local_ollama(api_base: str) -> bool:
133
+ return ":11434" in api_base
134
+
135
+
136
+ def _api_fix(error: str, api_base: str) -> str:
137
+ ollama = _is_local_ollama(api_base)
138
+ if "status 401" in error or "status 403" in error:
139
+ if ollama:
140
+ return "the server refused the key. For Ollama set RAGAUDIT_API_KEY=ollama."
141
+ return "the server refused RAGAUDIT_API_KEY: check the key is valid and has access to RAGAUDIT_MODEL."
142
+ if "status 404" in error:
143
+ if ollama:
144
+ return (
145
+ "model or URL not found. Run `ollama list` to see your models (`ollama pull llama3.1:8b` to get one), "
146
+ "set RAGAUDIT_MODEL to one of them, and make sure RAGAUDIT_API_BASE ends in /v1."
147
+ )
148
+ return "model or URL not found: check RAGAUDIT_MODEL is a model your provider serves and RAGAUDIT_API_BASE ends in /v1."
149
+ if "status 429" in error:
150
+ return "rate limited or out of quota: check your provider's billing and limits, then retry."
151
+ if "status" in error:
152
+ return "the API returned an error: check RAGAUDIT_API_BASE points at an OpenAI-compatible /v1 endpoint."
153
+ if ollama:
154
+ return "could not reach Ollama: start it with `ollama serve` (or open the Ollama app), then retry."
155
+ return "could not reach RAGAUDIT_API_BASE: check the URL (it should end in /v1) and your network."
156
+
157
+
158
+ def check_api(mock: bool, env_ok: bool, client_factory=None) -> Check:
159
+ name = "Test call to the LLM API"
160
+ if mock:
161
+ return Check(f"{name}: skipped, mock LLM selected ({PROVIDER_ENV}=mock); its scores are not meaningful", True)
162
+ if not env_ok:
163
+ return Check(f"{name}: not attempted", False, "set the RAGAUDIT_* variables above first.")
164
+ api_base = os.environ.get("RAGAUDIT_API_BASE", "")
165
+ if not api_base.startswith(("http://", "https://")):
166
+ return Check(f"{name}: RAGAUDIT_API_BASE is not a URL", False, "it must start with http:// or https:// and end in /v1.")
167
+ try:
168
+ client = (client_factory or LLMClient)(max_retries=1, timeout=30.0)
169
+ reply = client.complete("You are a health check. Reply with the single word OK.", "Are you there?")
170
+ except LLMClientError as exc:
171
+ return Check(f"{name} failed", False, _api_fix(str(exc), api_base))
172
+ except Exception as exc: # malformed responses, bad URLs
173
+ return Check(f"{name} failed ({type(exc).__name__})", False, _api_fix(str(exc), api_base))
174
+ if not str(reply).strip():
175
+ return Check(f"{name}: the model replied with nothing", False, "check RAGAUDIT_MODEL is a chat model.")
176
+ return Check(f"{name} answered", True)
177
+
178
+
179
+ def check_adapter_import(adapter_path: Path):
180
+ name = f"Adapter {adapter_path} imports"
181
+ if not adapter_path.is_file():
182
+ return Check(f"Adapter {adapter_path} not found", False, "run `ragaudit init`, or pass --adapter PATH."), None
183
+ try:
184
+ adapter = load_adapter(str(adapter_path))
185
+ except AdapterLoadError as exc:
186
+ cause = exc.__cause__
187
+ detail = f"{type(cause).__name__}: {cause}" if cause is not None else str(exc)
188
+ if isinstance(cause, ModuleNotFoundError):
189
+ fix = f"install the missing package ({detail}) in this environment, e.g. `pip install {cause.name}`."
190
+ elif cause is not None:
191
+ fix = f"fix the error raised while importing it ({detail})."
192
+ else:
193
+ fix = "define `query(question) -> RAGResponse` (or an `adapter` object). `ragaudit init` writes a template."
194
+ return Check(f"Adapter {adapter_path} does not import", False, fix), None
195
+ return Check(name, True), adapter
196
+
197
+
198
+ def check_adapter_response(adapter, adapter_path: Path, question: str = DOCTOR_QUESTION) -> list[Check]:
199
+ name = "Adapter returns a valid RAGResponse"
200
+ if adapter is None:
201
+ return [Check(f"{name}: not attempted", False, "fix the adapter import above first.")]
202
+ try:
203
+ response = adapter.query(question)
204
+ except NotImplementedError:
205
+ return [Check(f"{name}: the template is not filled in", False, f"connect your pipeline at the TODO in {adapter_path}.")]
206
+ except Exception as exc:
207
+ return [Check(f"{name}: query() raised {type(exc).__name__}: {exc}", False, f"fix query() in {adapter_path}; it must not raise on a normal question.")]
208
+ if isinstance(response, dict):
209
+ try:
210
+ response = RAGResponse.model_validate(response)
211
+ except ValidationError:
212
+ pass
213
+ if not isinstance(response, RAGResponse):
214
+ return [Check(
215
+ f"{name}: query() returned {type(response).__name__}",
216
+ False,
217
+ "return RAGResponse(answer=..., contexts=[...]) (from ragaudit.models.adapter import RAGResponse).",
218
+ )]
219
+ if not response.answer.strip():
220
+ return [Check(f"{name}: the answer is empty", False, f"return your pipeline's answer text in `answer` in {adapter_path}.")]
221
+ checks = [Check(f"{name} on a sample question", True)]
222
+ if response.contexts:
223
+ checks.append(Check(f"Adapter returns retrieved passages ({len(response.contexts)} in `contexts`)", True))
224
+ else:
225
+ checks.append(Check(
226
+ "Adapter returns no retrieved passages (`contexts` is empty)",
227
+ False,
228
+ "return the text of each retrieved passage in `contexts`; `ragaudit check` needs them to score "
229
+ "retrieval (or use --match-by id if you return ragaudit chunk ids).",
230
+ ))
231
+ return checks
232
+
233
+
234
+ def run_doctor(adapter_path: Path, llm: str | None = None) -> list[Check]:
235
+ try:
236
+ mock = resolve_provider(llm) == "mock"
237
+ except ValueError as exc:
238
+ _fail(str(exc))
239
+ typer.echo("ragaudit doctor")
240
+ if Path(".env").is_file():
241
+ typer.echo(" (read settings from ./.env; variables exported in your shell take priority)")
242
+ checks = [check_python()]
243
+ env_checks = check_env_vars(mock)
244
+ checks += env_checks
245
+ checks.append(check_api(mock, all(c.ok for c in env_checks)))
246
+ with _adapter_env(llm):
247
+ import_check, adapter = check_adapter_import(adapter_path)
248
+ checks.append(import_check)
249
+ checks += check_adapter_response(adapter, adapter_path)
250
+
251
+ for check in checks:
252
+ if check.ok:
253
+ typer.secho(f" PASS {check.name}", fg=typer.colors.GREEN)
254
+ else:
255
+ typer.secho(f" FAIL {check.name}", fg=typer.colors.RED)
256
+ typer.echo(f" Fix: {check.fix}")
257
+ failed = sum(1 for check in checks if not check.ok)
258
+ if failed:
259
+ typer.secho(f"{failed} check(s) failed. Fix them and run `ragaudit doctor` again.", fg=typer.colors.RED)
260
+ raise typer.Exit(code=1)
261
+ typer.secho(
262
+ f"All checks passed. Next: ragaudit check --docs ./your-docs --adapter {adapter_path}", fg=typer.colors.GREEN
263
+ )
264
+ return checks
265
+
266
+
267
+ # --- check -------------------------------------------------------------------
268
+
269
+ DEFAULT_CHECK_QUESTIONS = 20
270
+ MAX_CHECK_QUESTIONS = 500
271
+ DEFAULT_CHECK_DB = Path(".ragaudit") / "check.db"
272
+ DEFAULT_CHECK_REPORT = Path("ragaudit-report.html")
273
+ JUDGE_CALLS_PER_QUESTION = 2 # correctness + groundedness
274
+ ABLATION_CALLS_PER_QUESTION = 2 # no-context answer + its correctness judgment, for lucky passes only
275
+
276
+
277
+ @dataclass
278
+ class CheckPlan:
279
+ chunks: list
280
+ """The chunks questions will be written from."""
281
+ per_chunk: list[int]
282
+ """Questions to ask of each chunk, in the same order."""
283
+
284
+ @property
285
+ def questions(self) -> int:
286
+ return sum(self.per_chunk)
287
+
288
+ @property
289
+ def generate_calls(self) -> int:
290
+ return len(self.chunks)
291
+
292
+ @property
293
+ def expected_calls(self) -> int:
294
+ return self.generate_calls + JUDGE_CALLS_PER_QUESTION * self.questions
295
+
296
+ @property
297
+ def max_calls(self) -> int:
298
+ return self.expected_calls + ABLATION_CALLS_PER_QUESTION * self.questions
299
+
300
+
301
+ def plan_check(chunks: list, questions: int) -> CheckPlan:
302
+ """Spread `questions` evenly over the corpus: one each from evenly spaced chunks, or more per chunk when there are few."""
303
+ if questions <= len(chunks):
304
+ selected = [chunks[(i * len(chunks)) // questions] for i in range(questions)]
305
+ return CheckPlan(selected, [1] * questions)
306
+ base, extra = divmod(questions, len(chunks))
307
+ return CheckPlan(list(chunks), [base + 1 if i < extra else base for i in range(len(chunks))])
308
+
309
+
310
+ def print_summary_box(lines: list[str]) -> None:
311
+ typer.echo("")
312
+ typer.secho("ragaudit check: summary", bold=True)
313
+ for line in lines:
314
+ typer.echo(f" {line}")
315
+
316
+
317
+ def run_check(
318
+ docs: Path,
319
+ adapter_path: Path,
320
+ questions: int,
321
+ match_by: str,
322
+ match_threshold: float,
323
+ yes: bool,
324
+ no_open: bool,
325
+ max_llm_calls: int | None,
326
+ db: Path,
327
+ out: Path,
328
+ llm: str | None,
329
+ ) -> list[str]:
330
+ """ingest -> generate -> run (with ablation) -> report, in one command."""
331
+ with _adapter_env(llm, docs if docs.is_dir() else None):
332
+ return _run_check(
333
+ docs, adapter_path, questions, match_by, match_threshold, yes, no_open, max_llm_calls, db, out, llm
334
+ )
335
+
336
+
337
+ def _run_check(
338
+ docs: Path,
339
+ adapter_path: Path,
340
+ questions: int,
341
+ match_by: str,
342
+ match_threshold: float,
343
+ yes: bool,
344
+ no_open: bool,
345
+ max_llm_calls: int | None,
346
+ db: Path,
347
+ out: Path,
348
+ llm: str | None,
349
+ ) -> list[str]:
350
+ import webbrowser
351
+
352
+ from ragaudit.cli import _execute_run
353
+ from ragaudit.generate.testset_generator import generate_test_cases_for_chunk, generate_test_set
354
+ from ragaudit.ingest import chunker
355
+ from ragaudit.ingest.loader import load_documents
356
+ from ragaudit.llm.factory import BudgetedCompleter, CallBudget, LLMBudgetExceeded, make_client
357
+ from ragaudit.report.generator import generate_report
358
+ from ragaudit.report.plain_summary import build_plain_summary
359
+ from ragaudit.scoring.text_match import validate_match_by
360
+ from ragaudit.storage import db as db_module
361
+ from ragaudit.storage import repository
362
+
363
+ try:
364
+ validate_match_by(match_by)
365
+ provider = resolve_provider(llm)
366
+ except ValueError as exc:
367
+ _fail(str(exc))
368
+ if not 1 <= questions <= MAX_CHECK_QUESTIONS:
369
+ _fail(f"--questions must be between 1 and {MAX_CHECK_QUESTIONS}, got {questions}")
370
+ if max_llm_calls is not None and max_llm_calls < 1:
371
+ _fail("--max-llm-calls must be at least 1")
372
+ if not docs.exists():
373
+ _fail(f"docs folder not found: {docs}")
374
+ documents = load_documents(docs)
375
+ if not documents:
376
+ _fail(f"no .txt or .md files found under {docs}")
377
+ chunks = [chunk for document in documents for chunk in chunker.chunk_document(document)]
378
+ if not chunks:
379
+ _fail(f"the files under {docs} are empty")
380
+ try:
381
+ adapter = load_adapter(str(adapter_path))
382
+ except AdapterLoadError as exc:
383
+ _fail(f"{exc} Run `ragaudit doctor --adapter {adapter_path}` for the exact fix.")
384
+ # One sample question before any LLM spend, so a broken adapter costs nothing.
385
+ problems = [c for c in check_adapter_response(adapter, adapter_path) if not c.ok]
386
+ if match_by == "id": # empty contexts only block text matching
387
+ problems = [c for c in problems if "`contexts`" not in c.name]
388
+ if problems:
389
+ _fail(f"{problems[0].name}. Fix: {problems[0].fix}")
390
+
391
+ plan = plan_check(chunks, questions)
392
+ cap = max_llm_calls if max_llm_calls is not None else plan.max_calls
393
+ typer.echo(f"Docs: {len(documents)} file(s), {len(chunks)} chunk(s). Questions: {questions}, from {plan.generate_calls} chunk(s).")
394
+ typer.echo(
395
+ f"Estimated LLM calls: about {plan.expected_calls} ({plan.generate_calls} to write questions, "
396
+ f"{JUDGE_CALLS_PER_QUESTION * questions} to judge answers), up to {plan.max_calls} if every answer needs "
397
+ f"the lucky-pass check. Budget cap: {cap} call(s) (--max-llm-calls)."
398
+ )
399
+ own_calls = adapter_llm_calls(adapter)
400
+ if own_calls:
401
+ typer.echo(
402
+ f"The demo adapter answers through the same LLM: {own_calls * questions} more call(s), outside the cap "
403
+ f"(1 was made just now to test it)."
404
+ )
405
+ else:
406
+ typer.echo(f"Your adapter will be called {questions} more time(s); its own LLM calls are not counted here.")
407
+ if provider == "mock":
408
+ typer.echo("LLM: mock (offline, free; its scores say nothing about a real pipeline).")
409
+ if not yes and not typer.confirm("Continue?", default=True):
410
+ if own_calls:
411
+ typer.echo("Cancelled. Only the adapter's one test call was sent to the LLM.")
412
+ else:
413
+ typer.echo("Cancelled. Nothing was sent to the LLM.")
414
+ raise typer.Exit(code=0)
415
+
416
+ budget = CallBudget(cap)
417
+ client = BudgetedCompleter(make_client(provider), budget)
418
+
419
+ db.parent.mkdir(parents=True, exist_ok=True)
420
+ if db.exists():
421
+ db.unlink() # each check starts clean; the HTML report is the record
422
+ conn = db_module.get_connection(db)
423
+ phase = "writing questions"
424
+ try:
425
+ repository.save_documents(conn, documents)
426
+ repository.save_chunks(conn, chunks)
427
+ typer.echo(f"Writing {questions} question(s) from your docs...")
428
+ test_set = generate_test_set(client, documents, [], n_per_chunk=1)
429
+ cases = []
430
+ for chunk, n in zip(plan.chunks, plan.per_chunk):
431
+ cases.extend(generate_test_cases_for_chunk(client, chunk, n=n)[:n])
432
+ test_set = test_set.model_copy(update={"test_cases": cases})
433
+ if not cases:
434
+ _fail("the LLM did not return any usable questions; check RAGAUDIT_MODEL with `ragaudit doctor`.")
435
+ if len(cases) < questions:
436
+ typer.echo(f"Got {len(cases)} of {questions} question(s); some passages gave fewer than asked.")
437
+ repository.save_test_set(conn, test_set)
438
+ phase = "scoring answers"
439
+ run = _execute_run(
440
+ conn, test_set, adapter_path, client,
441
+ run_ablation_flag=True, match_by=match_by, match_threshold=match_threshold,
442
+ )
443
+ except LLMBudgetExceeded:
444
+ _fail(
445
+ f"stopped while {phase}: the budget of {cap} LLM call(s) was used up. "
446
+ f"Raise --max-llm-calls or lower --questions (now {questions})."
447
+ )
448
+ finally:
449
+ conn.close()
450
+
451
+ report_path = generate_report(run, test_set, out)
452
+ typer.echo(f"Used {budget.used} LLM call(s). Report: {report_path}")
453
+ if not no_open:
454
+ webbrowser.open(report_path.resolve().as_uri())
455
+
456
+ lines = build_plain_summary(run.summary, run.scored_cases).lines()
457
+ print_summary_box(lines)
458
+ return lines