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/llm/factory.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Builds the LLM client for v2 commands, and enforces --max-llm-calls budgets.
|
|
2
|
+
|
|
3
|
+
Provider is `mock` (the offline MockLLM) or `openai` (the existing
|
|
4
|
+
OpenAI-compatible LLMClient). It comes from the `--llm` flag, else the
|
|
5
|
+
RAGAUDIT_LLM environment variable, else `openai`, which keeps v1's
|
|
6
|
+
"configure with RAGAUDIT_API_*" behaviour as the default.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
from ragaudit.llm.client import LLMClient
|
|
13
|
+
from ragaudit.llm.mock import MockLLM
|
|
14
|
+
|
|
15
|
+
PROVIDER_ENV = "RAGAUDIT_LLM"
|
|
16
|
+
PROVIDERS = ("openai", "mock")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Completer(Protocol):
|
|
20
|
+
model: str
|
|
21
|
+
|
|
22
|
+
def complete(self, system: str, user: str) -> str: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LLMBudgetExceeded(Exception):
|
|
26
|
+
"""Raised when a call would exceed --max-llm-calls. Callers stop cleanly and save progress."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_provider(provider: str | None = None) -> str:
|
|
30
|
+
resolved = (provider or os.environ.get(PROVIDER_ENV) or "openai").lower()
|
|
31
|
+
if resolved not in PROVIDERS:
|
|
32
|
+
raise ValueError(f"unknown LLM provider {resolved!r}; expected one of {', '.join(PROVIDERS)}")
|
|
33
|
+
return resolved
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def make_client(provider: str | None = None, model: str | None = None, mock_model: str = "mock-llm") -> Completer:
|
|
37
|
+
"""An LLM client for the given provider. Real clients read RAGAUDIT_API_BASE/_API_KEY (and _MODEL if no model)."""
|
|
38
|
+
if resolve_provider(provider) == "mock":
|
|
39
|
+
return MockLLM(model=model or mock_model)
|
|
40
|
+
return LLMClient(model=model)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CallBudget:
|
|
44
|
+
"""A shared counter of LLM calls with an optional cap."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, max_calls: int | None = None) -> None:
|
|
47
|
+
self.max_calls = max_calls
|
|
48
|
+
self.used = 0
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def remaining(self) -> int | None:
|
|
52
|
+
return None if self.max_calls is None else max(self.max_calls - self.used, 0)
|
|
53
|
+
|
|
54
|
+
def spend(self) -> None:
|
|
55
|
+
if self.max_calls is not None and self.used >= self.max_calls:
|
|
56
|
+
raise LLMBudgetExceeded(f"LLM call budget of {self.max_calls} reached")
|
|
57
|
+
self.used += 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class BudgetedCompleter:
|
|
61
|
+
"""Wraps a Completer so every call is counted against a shared CallBudget."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, inner: Completer, budget: CallBudget) -> None:
|
|
64
|
+
self._inner = inner
|
|
65
|
+
self.budget = budget
|
|
66
|
+
self.model = inner.model
|
|
67
|
+
|
|
68
|
+
def complete(self, system: str, user: str) -> str:
|
|
69
|
+
self.budget.spend()
|
|
70
|
+
return self._inner.complete(system, user)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Pull one JSON object out of an LLM reply, tolerating the ways real models get it slightly wrong.
|
|
2
|
+
|
|
3
|
+
Every v2 prompt asks for strict JSON, but local models often wrap it in a
|
|
4
|
+
markdown fence, add a sentence before or after it, put a raw newline inside
|
|
5
|
+
a string, or drop the final closing brace. Each of those used to throw away
|
|
6
|
+
an otherwise good answer. This tries, in order:
|
|
7
|
+
|
|
8
|
+
1. the reply as is (inside a markdown fence, if there is one);
|
|
9
|
+
2. the span from the first "{" to the last "}";
|
|
10
|
+
3. the span from the first "{" with any unclosed brackets closed.
|
|
11
|
+
|
|
12
|
+
Strings are parsed with strict=False, so raw control characters inside a
|
|
13
|
+
string are accepted (callers that need one-line text check that
|
|
14
|
+
themselves). Anything still unparseable returns None.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
_FENCE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _close_brackets(text: str) -> str | None:
|
|
24
|
+
"""Append the closers for any "{" / "[" left open, or None if the text ends inside a string."""
|
|
25
|
+
stack: list[str] = []
|
|
26
|
+
in_string = escaped = False
|
|
27
|
+
for char in text:
|
|
28
|
+
if in_string:
|
|
29
|
+
if escaped:
|
|
30
|
+
escaped = False
|
|
31
|
+
elif char == "\\":
|
|
32
|
+
escaped = True
|
|
33
|
+
elif char == '"':
|
|
34
|
+
in_string = False
|
|
35
|
+
elif char == '"':
|
|
36
|
+
in_string = True
|
|
37
|
+
elif char in "{[":
|
|
38
|
+
stack.append("}" if char == "{" else "]")
|
|
39
|
+
elif char in "}]":
|
|
40
|
+
if not stack or stack.pop() != char:
|
|
41
|
+
return None
|
|
42
|
+
if in_string:
|
|
43
|
+
return None
|
|
44
|
+
return text + "".join(reversed(stack))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _candidates(text: str) -> list[str]:
|
|
48
|
+
candidates = [text]
|
|
49
|
+
start, end = text.find("{"), text.rfind("}")
|
|
50
|
+
if start != -1 and end > start:
|
|
51
|
+
candidates.append(text[start : end + 1])
|
|
52
|
+
if start != -1:
|
|
53
|
+
closed = _close_brackets(text[start:].rstrip())
|
|
54
|
+
if closed is not None:
|
|
55
|
+
candidates.append(closed)
|
|
56
|
+
return candidates
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_json_object(raw: str) -> dict | None:
|
|
60
|
+
"""The first JSON object found in raw, or None."""
|
|
61
|
+
text = raw.strip()
|
|
62
|
+
fenced = _FENCE.search(text)
|
|
63
|
+
if fenced:
|
|
64
|
+
text = fenced.group(1)
|
|
65
|
+
for candidate in _candidates(text):
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(candidate, strict=False)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
continue
|
|
70
|
+
if isinstance(data, dict):
|
|
71
|
+
return data
|
|
72
|
+
return None
|