regulated-eval 1.0.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.
- regulated_eval/__init__.py +95 -0
- regulated_eval/ab/__init__.py +268 -0
- regulated_eval/adapters/__init__.py +73 -0
- regulated_eval/adapters/base.py +126 -0
- regulated_eval/adapters/gigachat.py +226 -0
- regulated_eval/adapters/openai_compat.py +198 -0
- regulated_eval/adapters/retry.py +123 -0
- regulated_eval/adapters/yandexgpt.py +178 -0
- regulated_eval/cli.py +752 -0
- regulated_eval/config.py +112 -0
- regulated_eval/datasets/__init__.py +1 -0
- regulated_eval/datasets/loader.py +145 -0
- regulated_eval/datasets/manifest.py +164 -0
- regulated_eval/datasets/versioning.py +163 -0
- regulated_eval/exceptions.py +39 -0
- regulated_eval/human_loop/__init__.py +13 -0
- regulated_eval/human_loop/annotation_store.py +314 -0
- regulated_eval/judges/__init__.py +24 -0
- regulated_eval/judges/calibration.py +214 -0
- regulated_eval/judges/conflicts.py +200 -0
- regulated_eval/judges/judge.py +268 -0
- regulated_eval/metrics/__init__.py +1 -0
- regulated_eval/metrics/aggregator.py +145 -0
- regulated_eval/observability/__init__.py +66 -0
- regulated_eval/observability/logs.py +321 -0
- regulated_eval/observability/metrics.py +253 -0
- regulated_eval/observability/otel_setup.py +223 -0
- regulated_eval/observability/redaction.py +220 -0
- regulated_eval/pipeline.py +501 -0
- regulated_eval/py.typed +2 -0
- regulated_eval/reports/__init__.py +20 -0
- regulated_eval/reports/html.py +200 -0
- regulated_eval/reports/markdown.py +140 -0
- regulated_eval/schemas.py +263 -0
- regulated_eval/security/__init__.py +50 -0
- regulated_eval/security/audit.py +201 -0
- regulated_eval/security/injection_filter.py +186 -0
- regulated_eval/security/pii_redactor.py +212 -0
- regulated_eval/security/provider_allowlist.py +208 -0
- regulated_eval/security/secrets.py +110 -0
- regulated_eval-1.0.0.dist-info/METADATA +272 -0
- regulated_eval-1.0.0.dist-info/RECORD +45 -0
- regulated_eval-1.0.0.dist-info/WHEEL +4 -0
- regulated_eval-1.0.0.dist-info/entry_points.txt +2 -0
- regulated_eval-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Regulated LLM Eval Framework — evaluation for LLMs in regulated domains.
|
|
2
|
+
|
|
3
|
+
Library API (FR-014):
|
|
4
|
+
- run_eval: Run full evaluation pipeline
|
|
5
|
+
- calibrate_judge: Calibrate judge against expert annotations
|
|
6
|
+
- generate_report: Generate Markdown/HTML report
|
|
7
|
+
- ab_test: Run A/B test
|
|
8
|
+
- resolve_conflicts: Resolve judge-human conflicts
|
|
9
|
+
- replay_run: Replay previous run
|
|
10
|
+
- load_dataset: Load and validate dataset
|
|
11
|
+
- verify_annotations: Verify annotation chain
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = "1.0.0"
|
|
15
|
+
|
|
16
|
+
from regulated_eval.adapters import (
|
|
17
|
+
BaseAdapter,
|
|
18
|
+
GigaChatAdapter,
|
|
19
|
+
LLMRequest,
|
|
20
|
+
LLMResponse,
|
|
21
|
+
OpenAICompatibleAdapter,
|
|
22
|
+
YandexGPTAdapter,
|
|
23
|
+
create_adapter,
|
|
24
|
+
)
|
|
25
|
+
from regulated_eval.config import Config
|
|
26
|
+
from regulated_eval.datasets.loader import load_golden_set
|
|
27
|
+
from regulated_eval.datasets.manifest import create_manifest, load_manifest_file, save_manifest_file
|
|
28
|
+
from regulated_eval.human_loop.annotation_store import AnnotationStore
|
|
29
|
+
from regulated_eval.judges import CalibrationEngine, ConflictResolver
|
|
30
|
+
from regulated_eval.metrics.aggregator import MetricsAggregator
|
|
31
|
+
from regulated_eval.observability import (
|
|
32
|
+
init_otel,
|
|
33
|
+
record_eval_run_duration,
|
|
34
|
+
record_hallucination_rate,
|
|
35
|
+
record_llm_request_duration,
|
|
36
|
+
shutdown_otel,
|
|
37
|
+
)
|
|
38
|
+
from regulated_eval.pipeline import EvalOrchestrator, ReplayOrchestrator
|
|
39
|
+
from regulated_eval.reports import generate_html_report, generate_markdown_report
|
|
40
|
+
from regulated_eval.schemas import (
|
|
41
|
+
Annotation,
|
|
42
|
+
CalibrationResult,
|
|
43
|
+
GoldenRecord,
|
|
44
|
+
GoldenSetSummary,
|
|
45
|
+
JudgeResult,
|
|
46
|
+
MetricsBlock,
|
|
47
|
+
RunManifest,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
# Version
|
|
52
|
+
"__version__",
|
|
53
|
+
# Config
|
|
54
|
+
"Config",
|
|
55
|
+
# Adapters
|
|
56
|
+
"BaseAdapter",
|
|
57
|
+
"LLMRequest",
|
|
58
|
+
"LLMResponse",
|
|
59
|
+
"YandexGPTAdapter",
|
|
60
|
+
"GigaChatAdapter",
|
|
61
|
+
"OpenAICompatibleAdapter",
|
|
62
|
+
"create_adapter",
|
|
63
|
+
# Pipeline
|
|
64
|
+
"EvalOrchestrator",
|
|
65
|
+
"ReplayOrchestrator",
|
|
66
|
+
# Datasets
|
|
67
|
+
"load_golden_set",
|
|
68
|
+
"create_manifest",
|
|
69
|
+
"save_manifest_file",
|
|
70
|
+
"load_manifest_file",
|
|
71
|
+
# Judges
|
|
72
|
+
"CalibrationEngine",
|
|
73
|
+
"ConflictResolver",
|
|
74
|
+
# Metrics
|
|
75
|
+
"MetricsAggregator",
|
|
76
|
+
# Reports
|
|
77
|
+
"generate_markdown_report",
|
|
78
|
+
"generate_html_report",
|
|
79
|
+
# Security
|
|
80
|
+
"AnnotationStore",
|
|
81
|
+
# Observability
|
|
82
|
+
"init_otel",
|
|
83
|
+
"shutdown_otel",
|
|
84
|
+
"record_eval_run_duration",
|
|
85
|
+
"record_llm_request_duration",
|
|
86
|
+
"record_hallucination_rate",
|
|
87
|
+
# Schemas
|
|
88
|
+
"GoldenRecord",
|
|
89
|
+
"JudgeResult",
|
|
90
|
+
"RunManifest",
|
|
91
|
+
"Annotation",
|
|
92
|
+
"MetricsBlock",
|
|
93
|
+
"CalibrationResult",
|
|
94
|
+
"GoldenSetSummary",
|
|
95
|
+
]
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""A/B testing with statistical significance.
|
|
2
|
+
|
|
3
|
+
Covers FR-008, TM-04.
|
|
4
|
+
|
|
5
|
+
Provides:
|
|
6
|
+
- Bootstrap confidence intervals
|
|
7
|
+
- Wilcoxon signed-rank test
|
|
8
|
+
- Cohen's d effect size
|
|
9
|
+
- Result interpretation
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from scipy import stats
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ABResult:
|
|
25
|
+
"""Result of A/B test."""
|
|
26
|
+
|
|
27
|
+
prompt_a: str = ""
|
|
28
|
+
prompt_b: str = ""
|
|
29
|
+
model_a: str = ""
|
|
30
|
+
model_b: str = ""
|
|
31
|
+
metric_a: float = 0.0
|
|
32
|
+
metric_b: float = 0.0
|
|
33
|
+
delta: float = 0.0
|
|
34
|
+
p_value: float = 1.0
|
|
35
|
+
ci_low: float = 0.0
|
|
36
|
+
ci_high: float = 0.0
|
|
37
|
+
cohens_d: float = 0.0
|
|
38
|
+
n_a: int = 0
|
|
39
|
+
n_b: int = 0
|
|
40
|
+
significant: bool = False
|
|
41
|
+
winner: str = "" # "a", "b", or "none"
|
|
42
|
+
interpretation: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ABTest:
|
|
46
|
+
"""A/B test with statistical significance testing.
|
|
47
|
+
|
|
48
|
+
Uses:
|
|
49
|
+
- Bootstrap CI (1000 iterations) for metric differences
|
|
50
|
+
- Wilcoxon signed-rank test for p-value
|
|
51
|
+
- Cohen's d for effect size
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
n_bootstrap: int = 1000,
|
|
57
|
+
alpha: float = 0.05,
|
|
58
|
+
random_seed: int = 42,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Initialize A/B test.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
n_bootstrap: Number of bootstrap iterations.
|
|
64
|
+
alpha: Significance level (default 0.05).
|
|
65
|
+
random_seed: Random seed for reproducibility.
|
|
66
|
+
"""
|
|
67
|
+
self._n_bootstrap = n_bootstrap
|
|
68
|
+
self._alpha = alpha
|
|
69
|
+
self._rng = np.random.RandomState(random_seed)
|
|
70
|
+
|
|
71
|
+
def run(
|
|
72
|
+
self,
|
|
73
|
+
scores_a: list[float],
|
|
74
|
+
scores_b: list[float],
|
|
75
|
+
prompt_a: str = "",
|
|
76
|
+
prompt_b: str = "",
|
|
77
|
+
model_a: str = "",
|
|
78
|
+
model_b: str = "",
|
|
79
|
+
higher_is_better: bool = True,
|
|
80
|
+
) -> ABResult:
|
|
81
|
+
"""Run A/B test between two sets of scores.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
scores_a: Scores for variant A.
|
|
85
|
+
scores_b: Scores for variant B.
|
|
86
|
+
prompt_a: Prompt A identifier.
|
|
87
|
+
prompt_b: Prompt B identifier.
|
|
88
|
+
model_a: Model A name.
|
|
89
|
+
model_b: Model B name.
|
|
90
|
+
higher_is_better: Whether higher scores are better.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
ABResult with statistical analysis.
|
|
94
|
+
"""
|
|
95
|
+
result = ABResult(
|
|
96
|
+
prompt_a=prompt_a,
|
|
97
|
+
prompt_b=prompt_b,
|
|
98
|
+
model_a=model_a,
|
|
99
|
+
model_b=model_b,
|
|
100
|
+
n_a=len(scores_a),
|
|
101
|
+
n_b=len(scores_b),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if not scores_a or not scores_b:
|
|
105
|
+
result.interpretation = "Insufficient data for A/B test"
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
# Compute mean metrics
|
|
109
|
+
result.metric_a = float(np.mean(scores_a))
|
|
110
|
+
result.metric_b = float(np.mean(scores_b))
|
|
111
|
+
result.delta = result.metric_b - result.metric_a
|
|
112
|
+
|
|
113
|
+
# Bootstrap CI for delta
|
|
114
|
+
ci_low, ci_high = self._bootstrap_ci(scores_a, scores_b, higher_is_better)
|
|
115
|
+
result.ci_low = ci_low
|
|
116
|
+
result.ci_high = ci_high
|
|
117
|
+
|
|
118
|
+
# Wilcoxon signed-rank test
|
|
119
|
+
# Pad shorter list with mean values for paired test
|
|
120
|
+
min_len = min(len(scores_a), len(scores_b))
|
|
121
|
+
if min_len >= 2:
|
|
122
|
+
a_sample = scores_a[:min_len]
|
|
123
|
+
b_sample = scores_b[:min_len]
|
|
124
|
+
_, result.p_value = stats.wilcoxon(a_sample, b_sample)
|
|
125
|
+
else:
|
|
126
|
+
result.p_value = 1.0
|
|
127
|
+
|
|
128
|
+
# Cohen's d effect size
|
|
129
|
+
result.cohens_d = self._cohens_d(scores_a, scores_b)
|
|
130
|
+
|
|
131
|
+
# Determine significance
|
|
132
|
+
result.significant = result.p_value < self._alpha
|
|
133
|
+
|
|
134
|
+
# Determine winner
|
|
135
|
+
if result.significant:
|
|
136
|
+
if higher_is_better:
|
|
137
|
+
result.winner = "b" if result.delta > 0 else "a"
|
|
138
|
+
else:
|
|
139
|
+
result.winner = "b" if result.delta < 0 else "a"
|
|
140
|
+
else:
|
|
141
|
+
result.winner = "none"
|
|
142
|
+
|
|
143
|
+
# Generate interpretation
|
|
144
|
+
result.interpretation = self._interpret_result(result, higher_is_better)
|
|
145
|
+
|
|
146
|
+
logger.info(
|
|
147
|
+
"A/B test: delta=%.4f, p=%.4f, d=%.4f, significant=%s, winner=%s",
|
|
148
|
+
result.delta,
|
|
149
|
+
result.p_value,
|
|
150
|
+
result.cohens_d,
|
|
151
|
+
result.significant,
|
|
152
|
+
result.winner,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
def _bootstrap_ci(
|
|
158
|
+
self,
|
|
159
|
+
scores_a: list[float],
|
|
160
|
+
scores_b: list[float],
|
|
161
|
+
higher_is_better: bool,
|
|
162
|
+
) -> tuple[float, float]:
|
|
163
|
+
"""Compute bootstrap confidence interval for delta.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
scores_a: Scores for variant A.
|
|
167
|
+
scores_b: Scores for variant B.
|
|
168
|
+
higher_is_better: Whether higher scores are better.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Tuple of (ci_low, ci_high).
|
|
172
|
+
"""
|
|
173
|
+
deltas = []
|
|
174
|
+
min_len = min(len(scores_a), len(scores_b))
|
|
175
|
+
|
|
176
|
+
for _ in range(self._n_bootstrap):
|
|
177
|
+
indices = self._rng.randint(0, min_len, size=min_len)
|
|
178
|
+
a_sample = [scores_a[i] for i in indices]
|
|
179
|
+
b_sample = [scores_b[i] for i in indices]
|
|
180
|
+
|
|
181
|
+
delta_a = np.mean(a_sample)
|
|
182
|
+
delta_b = np.mean(b_sample)
|
|
183
|
+
deltas.append(delta_b - delta_a)
|
|
184
|
+
|
|
185
|
+
alpha = self._alpha
|
|
186
|
+
ci_low = np.percentile(deltas, 100 * alpha / 2)
|
|
187
|
+
ci_high = np.percentile(deltas, 100 * (1 - alpha / 2))
|
|
188
|
+
|
|
189
|
+
return float(ci_low), float(ci_high)
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _cohens_d(scores_a: list[float], scores_b: list[float]) -> float:
|
|
193
|
+
"""Compute Cohen's d effect size.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
scores_a: Scores for variant A.
|
|
197
|
+
scores_b: Scores for variant B.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Cohen's d value.
|
|
201
|
+
"""
|
|
202
|
+
n_a = len(scores_a)
|
|
203
|
+
n_b = len(scores_b)
|
|
204
|
+
|
|
205
|
+
if n_a < 2 or n_b < 2:
|
|
206
|
+
return 0.0
|
|
207
|
+
|
|
208
|
+
mean_a = np.mean(scores_a)
|
|
209
|
+
mean_b = np.mean(scores_b)
|
|
210
|
+
std_a = np.std(scores_a, ddof=1)
|
|
211
|
+
std_b = np.std(scores_b, ddof=1)
|
|
212
|
+
|
|
213
|
+
# Pooled standard deviation
|
|
214
|
+
pooled_std = np.sqrt(((n_a - 1) * std_a**2 + (n_b - 1) * std_b**2) / (n_a + n_b - 2))
|
|
215
|
+
|
|
216
|
+
if pooled_std == 0:
|
|
217
|
+
return 0.0
|
|
218
|
+
|
|
219
|
+
return float((mean_b - mean_a) / pooled_std)
|
|
220
|
+
|
|
221
|
+
def _interpret_result(
|
|
222
|
+
self,
|
|
223
|
+
result: ABResult,
|
|
224
|
+
higher_is_better: bool,
|
|
225
|
+
) -> str:
|
|
226
|
+
"""Interpret A/B test results.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
result: ABResult to interpret.
|
|
230
|
+
higher_is_better: Whether higher scores are better.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Interpretation string.
|
|
234
|
+
"""
|
|
235
|
+
if not result.significant:
|
|
236
|
+
return (
|
|
237
|
+
f"No statistically significant difference (p={result.p_value:.4f} >= 0.05). "
|
|
238
|
+
f"Delta: {result.delta:+.4f} [{result.ci_low:.4f}, {result.ci_high:.4f}]. "
|
|
239
|
+
f"Consider increasing dataset size."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
winner_name = "A" if result.winner == "a" else "B" if result.winner == "b" else "none"
|
|
243
|
+
|
|
244
|
+
effect_size = abs(result.cohens_d)
|
|
245
|
+
if effect_size < 0.2:
|
|
246
|
+
effect_label = "negligible"
|
|
247
|
+
elif effect_size < 0.5:
|
|
248
|
+
effect_label = "small"
|
|
249
|
+
elif effect_size < 0.8:
|
|
250
|
+
effect_label = "medium"
|
|
251
|
+
else:
|
|
252
|
+
effect_label = "large"
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
f"Statistically significant difference found (p={result.p_value:.4f} < 0.05). "
|
|
256
|
+
f"Winner: {winner_name}. Delta: {result.delta:+.4f}. "
|
|
257
|
+
f"Effect size: {effect_label} (d={result.cohens_d:+.4f})."
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def n_bootstrap(self) -> int:
|
|
262
|
+
"""Number of bootstrap iterations."""
|
|
263
|
+
return self._n_bootstrap
|
|
264
|
+
|
|
265
|
+
@n_bootstrap.setter
|
|
266
|
+
def n_bootstrap(self, value: int) -> None:
|
|
267
|
+
"""Set number of bootstrap iterations."""
|
|
268
|
+
self._n_bootstrap = value
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Adapters package
|
|
2
|
+
|
|
3
|
+
"""LLM provider adapters for Regulated LLM Eval Framework.
|
|
4
|
+
|
|
5
|
+
Covers FR-003, NFR-008.
|
|
6
|
+
|
|
7
|
+
Adapters:
|
|
8
|
+
- YandexGPT: Russian LLM (not foreign)
|
|
9
|
+
- GigaChat: Russian LLM (not foreign)
|
|
10
|
+
- OpenAICompatible: OpenAI-compatible API (foreign, requires opt-in)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from regulated_eval.adapters.base import BaseAdapter, LLMRequest, LLMResponse
|
|
16
|
+
from regulated_eval.adapters.gigachat import GigaChatAdapter
|
|
17
|
+
from regulated_eval.adapters.openai_compat import OpenAICompatibleAdapter
|
|
18
|
+
from regulated_eval.adapters.retry import RateLimitError, RateLimitExhausted, check_rate_limit
|
|
19
|
+
from regulated_eval.adapters.yandexgpt import YandexGPTAdapter
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"BaseAdapter",
|
|
23
|
+
"LLMRequest",
|
|
24
|
+
"LLMResponse",
|
|
25
|
+
"YandexGPTAdapter",
|
|
26
|
+
"GigaChatAdapter",
|
|
27
|
+
"OpenAICompatibleAdapter",
|
|
28
|
+
"RateLimitError",
|
|
29
|
+
"RateLimitExhausted",
|
|
30
|
+
"check_rate_limit",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_adapter(
|
|
35
|
+
provider: str,
|
|
36
|
+
model: str = "",
|
|
37
|
+
config=None,
|
|
38
|
+
base_url: str | None = None,
|
|
39
|
+
) -> BaseAdapter:
|
|
40
|
+
"""Create an adapter by provider name.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
provider: Provider name (yandexgpt, gigachat, openai_compat).
|
|
44
|
+
model: Model name (uses default if empty).
|
|
45
|
+
config: Optional Config object.
|
|
46
|
+
base_url: Optional base URL for openai_compat.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Configured BaseAdapter instance.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If provider is not supported.
|
|
53
|
+
"""
|
|
54
|
+
from regulated_eval.config import Config # noqa: PLC0414
|
|
55
|
+
|
|
56
|
+
cfg = config or Config()
|
|
57
|
+
|
|
58
|
+
if provider == "yandexgpt":
|
|
59
|
+
model_name = model or "yandexgpt-lite"
|
|
60
|
+
return YandexGPTAdapter(model=model_name, config=cfg)
|
|
61
|
+
|
|
62
|
+
elif provider == "gigachat":
|
|
63
|
+
model_name = model or "gigachat"
|
|
64
|
+
return GigaChatAdapter(model=model_name, config=cfg)
|
|
65
|
+
|
|
66
|
+
elif provider == "openai_compat":
|
|
67
|
+
model_name = model or "gpt-4o-mini"
|
|
68
|
+
url = base_url or cfg.openai_base_url
|
|
69
|
+
return OpenAICompatibleAdapter(model=model_name, base_url=url, config=cfg)
|
|
70
|
+
|
|
71
|
+
else:
|
|
72
|
+
supported = ", ".join(["yandexgpt", "gigachat", "openai_compat"])
|
|
73
|
+
raise ValueError(f"Unsupported provider: {provider}. Supported: {supported}")
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Base adapter protocol for LLM providers.
|
|
2
|
+
|
|
3
|
+
Covers FR-003, NFR-008.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import time
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from regulated_eval.observability import get_tracer, record_llm_request_duration
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class LLMRequest:
|
|
18
|
+
"""Request to send to an LLM provider."""
|
|
19
|
+
|
|
20
|
+
prompt: str
|
|
21
|
+
model: str = ""
|
|
22
|
+
temperature: float = 0.0
|
|
23
|
+
max_tokens: int = 1024
|
|
24
|
+
stop_sequences: list[str] = field(default_factory=list)
|
|
25
|
+
metadata: dict = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class LLMResponse:
|
|
30
|
+
"""Response from an LLM provider."""
|
|
31
|
+
|
|
32
|
+
text: str
|
|
33
|
+
model: str = ""
|
|
34
|
+
tokens_input: int = 0
|
|
35
|
+
tokens_output: int = 0
|
|
36
|
+
finish_reason: str = ""
|
|
37
|
+
latency_ms: float = 0.0
|
|
38
|
+
error: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BaseAdapter(ABC):
|
|
42
|
+
"""Abstract base class for LLM provider adapters.
|
|
43
|
+
|
|
44
|
+
All adapters must implement:
|
|
45
|
+
- generate(): Send a prompt and return a response
|
|
46
|
+
- cost(): Estimate cost for a request
|
|
47
|
+
- provider(): Return the provider name
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
async def generate(self, request: LLMRequest) -> LLMResponse:
|
|
52
|
+
"""Send a prompt to the LLM and return the response.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
request: LLM request with prompt, model, etc.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
LLMResponse with text, tokens, latency, etc.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
RateLimitExhausted: If all retry attempts are exhausted.
|
|
62
|
+
MissingCredentials: If API key is not set.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def generate_sync(self, request: LLMRequest) -> LLMResponse:
|
|
66
|
+
"""Synchronous wrapper for generate().
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
request: LLM request with prompt, model, etc.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
LLMResponse with text, tokens, latency, etc.
|
|
73
|
+
"""
|
|
74
|
+
start_time = time.monotonic()
|
|
75
|
+
tracer = get_tracer("regulated-eval")
|
|
76
|
+
|
|
77
|
+
with tracer.start_as_current_span("adapter.call") as span:
|
|
78
|
+
span.set_attribute("llm.provider", self.provider())
|
|
79
|
+
span.set_attribute("llm.model", self.model_name())
|
|
80
|
+
span.set_attribute("llm.role", request.metadata.get("role", "generator"))
|
|
81
|
+
|
|
82
|
+
response = asyncio.run(self.generate(request))
|
|
83
|
+
|
|
84
|
+
# Record span attributes
|
|
85
|
+
span.set_attribute("llm.tokens_input", response.tokens_input)
|
|
86
|
+
span.set_attribute("llm.tokens_output", response.tokens_output)
|
|
87
|
+
span.set_attribute("llm.has_error", response.error is not None)
|
|
88
|
+
|
|
89
|
+
duration = time.monotonic() - start_time
|
|
90
|
+
response.latency_ms = duration * 1000
|
|
91
|
+
span.set_attribute("llm.duration_ms", response.latency_ms)
|
|
92
|
+
|
|
93
|
+
# Record metric
|
|
94
|
+
record_llm_request_duration(
|
|
95
|
+
{},
|
|
96
|
+
duration,
|
|
97
|
+
provider=self.provider(),
|
|
98
|
+
model=self.model_name(),
|
|
99
|
+
role=request.metadata.get("role", "generator"),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return response
|
|
103
|
+
|
|
104
|
+
@abstractmethod
|
|
105
|
+
def provider(self) -> str:
|
|
106
|
+
"""Return the provider name (e.g. 'yandexgpt', 'gigachat')."""
|
|
107
|
+
|
|
108
|
+
@abstractmethod
|
|
109
|
+
def model_name(self) -> str:
|
|
110
|
+
"""Return the model name being used."""
|
|
111
|
+
|
|
112
|
+
def estimate_cost(self, tokens_input: int, tokens_output: int) -> float | None:
|
|
113
|
+
"""Estimate cost in RUB for the given token counts.
|
|
114
|
+
|
|
115
|
+
Override in subclasses with provider-specific pricing.
|
|
116
|
+
Returns None if pricing is not configured.
|
|
117
|
+
"""
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def is_foreign(self) -> bool:
|
|
122
|
+
"""Whether this adapter connects to a foreign (non-Russian) provider.
|
|
123
|
+
|
|
124
|
+
Used for 152-FZ compliance (NFR-SEC-001).
|
|
125
|
+
"""
|
|
126
|
+
return False
|