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 ADDED
@@ -0,0 +1 @@
1
+
ragsentry/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from ragsentry.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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
+