ragsentry 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.
- ragsentry/__init__.py +1 -0
- ragsentry/__main__.py +4 -0
- ragsentry/adapters/__init__.py +1 -0
- ragsentry/adapters/base.py +32 -0
- ragsentry/adapters/http.py +106 -0
- ragsentry/adapters/python_callable.py +74 -0
- ragsentry/adapters/shell.py +84 -0
- ragsentry/ci.py +150 -0
- ragsentry/cli.py +392 -0
- ragsentry/diff.py +211 -0
- ragsentry/evalset/__init__.py +1 -0
- ragsentry/evalset/loader.py +51 -0
- ragsentry/evalset/schema.py +13 -0
- ragsentry/metrics/__init__.py +1 -0
- ragsentry/metrics/judge_config.py +182 -0
- ragsentry/metrics/ragas_backend.py +181 -0
- ragsentry/report/__init__.py +1 -0
- ragsentry/report/console.py +73 -0
- ragsentry/report/pr_comment.py +89 -0
- ragsentry/runner.py +56 -0
- ragsentry/storage/__init__.py +13 -0
- ragsentry/storage/base.py +44 -0
- ragsentry/storage/local_file.py +56 -0
- ragsentry/storage/postgres.py +119 -0
- ragsentry-0.1.0.dist-info/METADATA +128 -0
- ragsentry-0.1.0.dist-info/RECORD +30 -0
- ragsentry-0.1.0.dist-info/WHEEL +5 -0
- ragsentry-0.1.0.dist-info/entry_points.txt +2 -0
- ragsentry-0.1.0.dist-info/licenses/LICENSE +21 -0
- ragsentry-0.1.0.dist-info/top_level.txt +1 -0
ragsentry/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
ragsentry/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class AdapterResponse:
|
|
10
|
+
"""Standardized response structure returned by any RagSentry adapter."""
|
|
11
|
+
|
|
12
|
+
answer: str
|
|
13
|
+
contexts: list[str]
|
|
14
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def to_dict(self) -> dict[str, Any]:
|
|
18
|
+
return{
|
|
19
|
+
"answer": self.answer,
|
|
20
|
+
"contexts": self.contexts,
|
|
21
|
+
"metadata": self.metadata,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@runtime_checkable
|
|
27
|
+
class Adapter(Protocol):
|
|
28
|
+
"""Protocol defining the adapter contract for external RAG systems."""
|
|
29
|
+
|
|
30
|
+
def query(self, question: str) -> AdapterResponse:
|
|
31
|
+
"""Query the target RAG system with a question and return an AdapterResponse."""
|
|
32
|
+
...
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
from ragsentry.adapters.base import Adapter, AdapterResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def extract_path(data: Any, path: str) -> Any:
|
|
11
|
+
"""Extract a nested value using dotted syntax like 'data.answer' or 'results.0.text'."""
|
|
12
|
+
if not path:
|
|
13
|
+
return data
|
|
14
|
+
|
|
15
|
+
current = data
|
|
16
|
+
for part in path.split("."):
|
|
17
|
+
if isinstance(current, dict):
|
|
18
|
+
current = current.get(part)
|
|
19
|
+
|
|
20
|
+
elif isinstance(current, (list, tuple)) and part.isdigit():
|
|
21
|
+
idx = int(part)
|
|
22
|
+
current = current[idx] if 0<= idx < len(current) else None
|
|
23
|
+
|
|
24
|
+
else:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
if current is None:
|
|
28
|
+
break
|
|
29
|
+
return current
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HttpAdapter(Adapter):
|
|
35
|
+
"""Adapter that queries an external RAG service over HTTP."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
url: str,
|
|
40
|
+
method: str = "POST",
|
|
41
|
+
headers: dict[str, str] | None = None,
|
|
42
|
+
question_key: str = "question",
|
|
43
|
+
answer_path: str = "answer",
|
|
44
|
+
contexts_path: str = "contexts",
|
|
45
|
+
metadata_path: str | None = "metadata",
|
|
46
|
+
timeout: float =30.0
|
|
47
|
+
) -> None:
|
|
48
|
+
self.url = url
|
|
49
|
+
self.method = method.upper()
|
|
50
|
+
self.headers = headers or {"Content-Type": "application/json"}
|
|
51
|
+
self.question_key = question_key
|
|
52
|
+
self.answer_path = answer_path
|
|
53
|
+
self.contexts_path = contexts_path
|
|
54
|
+
self.metadata_path = metadata_path
|
|
55
|
+
self.timeout = timeout
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def query(self, question: str) -> AdapterResponse:
|
|
59
|
+
payload = {self.question_key: question}
|
|
60
|
+
|
|
61
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
62
|
+
if self.method == "POST":
|
|
63
|
+
response = client.post(self.url ,json=payload, headers=self.headers)
|
|
64
|
+
elif self.method == "GET":
|
|
65
|
+
response = client.get(self.url ,params=payload, headers=self.headers)
|
|
66
|
+
|
|
67
|
+
else:
|
|
68
|
+
raise ValueError(f"Unsupported HTTP method: {self.method}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
response.raise_for_status()
|
|
72
|
+
data = response.json()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
#extract the answer
|
|
76
|
+
raw_answer = extract_path(data, self.answer_path)
|
|
77
|
+
if raw_answer is None:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"Failed to extract answer using path '{self.answer_path}' from response: {data}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
#extract the contexts
|
|
83
|
+
raw_contexts = extract_path(data, self.contexts_path)
|
|
84
|
+
if raw_contexts is None:
|
|
85
|
+
raw_contexts = []
|
|
86
|
+
elif not isinstance(raw_contexts, list):
|
|
87
|
+
raw_contexts = [raw_contexts]
|
|
88
|
+
|
|
89
|
+
contexts = [
|
|
90
|
+
c if isinstance(c, str) else str(c.get("text", c)) if isinstance(c, dict) else str(c)
|
|
91
|
+
for c in raw_contexts
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
#extract metadata
|
|
95
|
+
metadata = {}
|
|
96
|
+
if self.metadata_path:
|
|
97
|
+
raw_meta = extract_path(data, self.metadata_path)
|
|
98
|
+
if isinstance(raw_meta, dict):
|
|
99
|
+
metadata = raw_meta
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
return AdapterResponse(
|
|
103
|
+
answer=str(raw_answer),
|
|
104
|
+
contexts=contexts,
|
|
105
|
+
metadata=metadata
|
|
106
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ragsentry.adapters.base import Adapter, AdapterResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PythonCallableAdapter(Adapter):
|
|
12
|
+
"""Adapter that wraps an in-process Python callable."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def __init__(self, target: Callable[[str], dict[str, Any] | AdapterResponse] | str) -> None:
|
|
16
|
+
|
|
17
|
+
if isinstance(target, str):
|
|
18
|
+
self._callable = self._load_callable(target)
|
|
19
|
+
elif callable(target):
|
|
20
|
+
self._callable = target
|
|
21
|
+
|
|
22
|
+
else:
|
|
23
|
+
raise TypeError(f"Target must be a callable or an import string ('module:func'), got {type(target)}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def _load_callable(import_str: str) -> Callable[[str], Any]:
|
|
29
|
+
"""Resolves an import string of format 'module.submodule:function_name'."""
|
|
30
|
+
if ":" not in import_str:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"Invalid import string '{import_str}'. Expected format: 'package.module:function_name'"
|
|
33
|
+
)
|
|
34
|
+
cwd = os.getcwd()
|
|
35
|
+
if cwd not in sys.path:
|
|
36
|
+
sys.path.insert(0, cwd)
|
|
37
|
+
|
|
38
|
+
module_path, func_name = import_str.split(":", 1)
|
|
39
|
+
module = importlib.import_module(module_path)
|
|
40
|
+
target = getattr(module, func_name)
|
|
41
|
+
if not callable(target):
|
|
42
|
+
raise TypeError(f"Resolved object '{import_str}' is not callable.")
|
|
43
|
+
|
|
44
|
+
return target
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def query(self, question: str) -> AdapterResponse:
|
|
48
|
+
|
|
49
|
+
raw = self._callable(question)
|
|
50
|
+
|
|
51
|
+
if isinstance(raw, AdapterResponse):
|
|
52
|
+
return raw
|
|
53
|
+
|
|
54
|
+
if isinstance(raw, dict):
|
|
55
|
+
if "answer" not in raw or "contexts" not in raw:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"Callable returned a dict missing 'answer' or 'contexts': keys={list(raw.keys())}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
contexts = raw["contexts"]
|
|
61
|
+
if not isinstance(contexts, list):
|
|
62
|
+
raise TypeError(f"'contexts' must be a list of strings, got {type(contexts)}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
return AdapterResponse(
|
|
66
|
+
answer=str(raw["answer"]),
|
|
67
|
+
contexts=[str(c) for c in contexts],
|
|
68
|
+
metadata=raw.get("metadata", {}),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
raise TypeError(
|
|
73
|
+
f"Expected callable to return dict or AdapterResponse, got {type(raw)}"
|
|
74
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shlex
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
from ragsentry.adapters.base import Adapter, AdapterResponse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ShellAdapter(Adapter):
|
|
13
|
+
"""Adapter that executes an external shell command or script."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
command: str | list [str],
|
|
18
|
+
pass_as: str = "stdin", #stdin or arg
|
|
19
|
+
timeout: float = 30.0,
|
|
20
|
+
cwd: str | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
|
|
23
|
+
if isinstance(command, str):
|
|
24
|
+
self.command = shlex.split(command, posix=False)
|
|
25
|
+
|
|
26
|
+
else:
|
|
27
|
+
self.command = list(command)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
self.pass_as = pass_as.lower()
|
|
31
|
+
self.timeout = timeout
|
|
32
|
+
self.cwd = cwd
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if self.pass_as not in ("stdin", "arg"):
|
|
36
|
+
raise ValueError(f"pass_as must be 'stdin' or 'arg', got '{pass_as}'")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def query(self, question: str) -> AdapterResponse:
|
|
40
|
+
cmd = list(self.command)
|
|
41
|
+
input_data = None
|
|
42
|
+
|
|
43
|
+
if self.pass_as == "arg":
|
|
44
|
+
cmd.append(question)
|
|
45
|
+
|
|
46
|
+
else:
|
|
47
|
+
input_data = json.dumps({"question": question})
|
|
48
|
+
|
|
49
|
+
proc = subprocess.run(
|
|
50
|
+
cmd,
|
|
51
|
+
input=input_data,
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
timeout=self.timeout,
|
|
55
|
+
cwd=self.cwd,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if proc.returncode != 0:
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
f"Shell command failed with exit code {proc.returncode}.\nStderr: {proc.stderr}"
|
|
61
|
+
)
|
|
62
|
+
stdout_clean = proc.stdout.strip()
|
|
63
|
+
if not stdout_clean:
|
|
64
|
+
raise ValueError(f"Shell command produced no stdout output.\nStderr: {proc.stderr}")
|
|
65
|
+
try:
|
|
66
|
+
data: dict[str, Any] = json.loads(stdout_clean)
|
|
67
|
+
except json.JSONDecodeError as exc:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Shell adapter expected JSON output on stdout, got: {stdout_clean}"
|
|
70
|
+
) from exc
|
|
71
|
+
if "answer" not in data or "contexts" not in data:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Shell command output missing 'answer' or 'contexts'. Keys found: {list(data.keys())}"
|
|
74
|
+
)
|
|
75
|
+
contexts = data["contexts"]
|
|
76
|
+
if not isinstance(contexts, list):
|
|
77
|
+
raise TypeError(f"'contexts' in shell output must be a list, got {type(contexts)}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
return AdapterResponse(
|
|
81
|
+
answer=str(data["answer"]),
|
|
82
|
+
contexts=[str(c) for c in contexts],
|
|
83
|
+
metadata=data.get("metadata", {}),
|
|
84
|
+
)
|
ragsentry/ci.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
from ragsentry.diff import RunDiff, compare_runs
|
|
10
|
+
from ragsentry.storage.base import RunResult
|
|
11
|
+
from ragsentry.storage import get_run_store
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class CIGateResult:
|
|
17
|
+
"""Outcome of a CI quality gate check."""
|
|
18
|
+
passed: bool
|
|
19
|
+
violations: list[str] = field(default_factory=list)
|
|
20
|
+
candidate_run: RunResult | None = None
|
|
21
|
+
baseline_run: RunResult | None = None
|
|
22
|
+
diff: RunDiff | None = None
|
|
23
|
+
thresholds: dict[str, float] = field(default_factory=dict)
|
|
24
|
+
max_regression: float | None = None
|
|
25
|
+
max_regressed_questions: int = 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def exit_code(self) -> int:
|
|
30
|
+
return 0 if self.passed else 1
|
|
31
|
+
|
|
32
|
+
def parse_thresholds(threshold_inputs: list[str] | dict[str, float] | None) -> dict[str, float]:
|
|
33
|
+
"""Parse threshold inputs from list of 'metric=val' strings or dict."""
|
|
34
|
+
if not threshold_inputs:
|
|
35
|
+
return {}
|
|
36
|
+
|
|
37
|
+
if isinstance(threshold_inputs, dict):
|
|
38
|
+
return {k: float(v) for k, v in threshold_inputs.items()}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
result: dict[str, float] = {}
|
|
42
|
+
for item in threshold_inputs:
|
|
43
|
+
item = item.strip()
|
|
44
|
+
if not item:
|
|
45
|
+
continue
|
|
46
|
+
if "=" in item:
|
|
47
|
+
k, v = item.split("=", 1)
|
|
48
|
+
result[k.strip()] = float(v.strip())
|
|
49
|
+
|
|
50
|
+
elif ":" in item:
|
|
51
|
+
k, v = item.split(":", 1)
|
|
52
|
+
result[k.strip()] = float(v.strip())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def evaluate_ci_gate(
|
|
60
|
+
candidate: RunResult | str | Path,
|
|
61
|
+
baseline: RunResult | str | Path | None = None,
|
|
62
|
+
thresholds: dict[str, float] | list[str] | None = None,
|
|
63
|
+
max_regression: float | None = None,
|
|
64
|
+
max_regressed_questions: int = 0,
|
|
65
|
+
tolerance: float = 0.001,
|
|
66
|
+
storage: str = "local",
|
|
67
|
+
) -> CIGateResult:
|
|
68
|
+
"""
|
|
69
|
+
Check if a candidate run satisfies CI quality criteria:
|
|
70
|
+
1. Absolute thresholds (e.g. faithfulness >= 0.8)
|
|
71
|
+
2. Regression checks against baseline (e.g. max drop <= 0.05, max regressed questions <= 0)
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
store = get_run_store(storage=storage)
|
|
75
|
+
violations: list[str] = []
|
|
76
|
+
|
|
77
|
+
if isinstance(candidate, (str, Path)):
|
|
78
|
+
cand_run = store.load(str(candidate))
|
|
79
|
+
|
|
80
|
+
else:
|
|
81
|
+
cand_run = candidate
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
base_run: RunResult | None = None
|
|
85
|
+
diff: RunDiff | None = None
|
|
86
|
+
|
|
87
|
+
if baseline:
|
|
88
|
+
if isinstance(baseline, (str, Path)):
|
|
89
|
+
base_run = store.load(str(baseline))
|
|
90
|
+
|
|
91
|
+
else:
|
|
92
|
+
base_run = baseline
|
|
93
|
+
|
|
94
|
+
diff = compare_runs(base_run, cand_run, tolerance=tolerance)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
parsed_thresh = parse_thresholds(thresholds)
|
|
99
|
+
avg_scores = cand_run.summary.get("average_scores", {})
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# 1. Absolute Threshold Checks
|
|
103
|
+
for metric, min_val in parsed_thresh.items():
|
|
104
|
+
actual_val = avg_scores.get(metric)
|
|
105
|
+
if actual_val is None:
|
|
106
|
+
violations.append(
|
|
107
|
+
f"Missing metric '{metric}': expected >= {min_val:.4f}, but metric was not scored."
|
|
108
|
+
)
|
|
109
|
+
elif actual_val < min_val:
|
|
110
|
+
violations.append(
|
|
111
|
+
f"Threshold breach on '{metric}': score {actual_val:.4f} < required minimum {min_val:.4f}."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# 2. Baseline Regression Checks
|
|
115
|
+
if diff:
|
|
116
|
+
counts = diff.summary.get("counts", {})
|
|
117
|
+
regressed_q_count = counts.get("regressed", 0)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if regressed_q_count > max_regressed_questions:
|
|
121
|
+
violations.append(
|
|
122
|
+
f"Regression breach: {regressed_q_count} questions regressed (max allowed: {max_regressed_questions})."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if max_regression is not None:
|
|
128
|
+
avg_deltas = diff.summary.get("average_deltas", {})
|
|
129
|
+
for m_name, d_info in avg_deltas.items():
|
|
130
|
+
delta = d_info.get("delta")
|
|
131
|
+
if delta is not None and delta < -max_regression:
|
|
132
|
+
violations.append(
|
|
133
|
+
f"Regression breach on '{m_name}': average score dropped by {abs(delta):.4f} "
|
|
134
|
+
f"(max allowed drop: {max_regression:.4f})."
|
|
135
|
+
)
|
|
136
|
+
passed = len(violations) == 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
return CIGateResult(
|
|
140
|
+
passed=passed,
|
|
141
|
+
violations=violations,
|
|
142
|
+
candidate_run=cand_run,
|
|
143
|
+
baseline_run=base_run,
|
|
144
|
+
diff=diff,
|
|
145
|
+
thresholds=parsed_thresh,
|
|
146
|
+
max_regression=max_regression,
|
|
147
|
+
max_regressed_questions=max_regressed_questions,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|