cli-modelarium 0.1.3__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.
- cli_modelarium/__init__.py +6 -0
- cli_modelarium/__main__.py +8 -0
- cli_modelarium/assertions.py +596 -0
- cli_modelarium/banner.py +96 -0
- cli_modelarium/batch.py +425 -0
- cli_modelarium/cli.py +2577 -0
- cli_modelarium/exceptions.py +88 -0
- cli_modelarium/hallucination.py +384 -0
- cli_modelarium/io_safety.py +112 -0
- cli_modelarium/judging.py +469 -0
- cli_modelarium/models_registry.py +138 -0
- cli_modelarium/output_formatters.py +1108 -0
- cli_modelarium/pricing.py +199 -0
- cli_modelarium/providers/__init__.py +7 -0
- cli_modelarium/providers/_utils.py +26 -0
- cli_modelarium/providers/anthropic_provider.py +148 -0
- cli_modelarium/providers/base.py +87 -0
- cli_modelarium/providers/deepseek_provider.py +15 -0
- cli_modelarium/providers/google_provider.py +135 -0
- cli_modelarium/providers/groq_provider.py +15 -0
- cli_modelarium/providers/local_provider.py +94 -0
- cli_modelarium/providers/mistral_provider.py +172 -0
- cli_modelarium/providers/openai_provider.py +163 -0
- cli_modelarium/providers/openrouter_provider.py +33 -0
- cli_modelarium/providers/xai_provider.py +15 -0
- cli_modelarium/run_statistics.py +1202 -0
- cli_modelarium/security.py +202 -0
- cli_modelarium/streaming.py +416 -0
- cli_modelarium-0.1.3.dist-info/METADATA +764 -0
- cli_modelarium-0.1.3.dist-info/RECORD +34 -0
- cli_modelarium-0.1.3.dist-info/WHEEL +4 -0
- cli_modelarium-0.1.3.dist-info/entry_points.txt +2 -0
- cli_modelarium-0.1.3.dist-info/licenses/LICENSE +201 -0
- cli_modelarium-0.1.3.dist-info/licenses/NOTICE +102 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Custom exception classes for Cli Modelarium."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ModelariumError(Exception):
|
|
7
|
+
"""Base class for all Cli Modelarium exceptions."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProviderError(ModelariumError):
|
|
11
|
+
"""Raised when a provider API call fails."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, provider: str | None = None) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.provider = provider
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RateLimitError(ProviderError):
|
|
19
|
+
"""Raised when a provider returns HTTP 429."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
message: str,
|
|
24
|
+
provider: str | None = None,
|
|
25
|
+
retry_after: float | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(message, provider=provider)
|
|
28
|
+
self.retry_after = retry_after
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProviderOverloadedError(ProviderError):
|
|
32
|
+
"""Raised when Anthropic returns HTTP 529 overloaded_error (distinct from 429)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AuthenticationError(ProviderError):
|
|
36
|
+
"""Raised when API authentication fails (HTTP 401/403)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ConfigurationError(ModelariumError):
|
|
40
|
+
"""Raised when configuration is invalid or missing."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class KeyNotConfiguredError(ConfigurationError):
|
|
44
|
+
"""Raised when an API key is required but not configured."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, provider: str) -> None:
|
|
47
|
+
self.provider = provider
|
|
48
|
+
super().__init__(
|
|
49
|
+
f"No API key configured for {provider}.\n"
|
|
50
|
+
f" Run: cli-modelarium keys set {provider}\n"
|
|
51
|
+
f" Or set environment variable: {provider.upper()}_API_KEY"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class InvalidKeyFormatError(ConfigurationError):
|
|
56
|
+
"""Raised when an API key fails format validation."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class UnknownModelError(ConfigurationError):
|
|
60
|
+
"""Raised when a requested model is not in the registry."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UnknownProviderError(ConfigurationError):
|
|
64
|
+
"""Raised when a requested provider is not recognized."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CostLimitExceededError(ModelariumError):
|
|
68
|
+
"""Raised when estimated or actual cost exceeds the user's --max-cost ceiling."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BatchValidationError(ModelariumError):
|
|
72
|
+
"""Raised when a batch file fails validation (format, content)."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class BatchSizeError(BatchValidationError):
|
|
76
|
+
"""Raised when batch dimensions exceed safety limits without --force-large."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class OutputFormatError(ModelariumError):
|
|
80
|
+
"""Raised when the output format cannot be inferred or is unsupported."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AssertionConfigError(BatchValidationError):
|
|
84
|
+
"""Raised when an assertion config is malformed (unknown type, missing value)."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class LocalURLError(ConfigurationError):
|
|
88
|
+
"""Raised when a local provider URL fails the localhost-only safety check."""
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Hallucination detection preset.
|
|
2
|
+
|
|
3
|
+
A specialized judge configuration that:
|
|
4
|
+
|
|
5
|
+
* Replaces the default `JUDGE_PROMPT_TEMPLATE` with a hallucination-
|
|
6
|
+
focused prompt that asks for a Low/Medium/High risk classification
|
|
7
|
+
alongside the 1-10 score.
|
|
8
|
+
* Optionally substitutes a `{reference_check}` block with either
|
|
9
|
+
reference facts (when --expected-facts is provided) or a "use your
|
|
10
|
+
own knowledge" instruction.
|
|
11
|
+
* Uses `parse_hallucination_response` instead of `parse_judge_response`
|
|
12
|
+
so the JudgeScore picks up the `risk_level` field.
|
|
13
|
+
* Worst-case aggregates risk_level across panel judges (any High wins).
|
|
14
|
+
|
|
15
|
+
This module deliberately reuses the Phase 8 judging machinery rather than
|
|
16
|
+
forking it - the only judging-side additions are the optional `risk_level`
|
|
17
|
+
field on `JudgeScore` and `aggregated_risk_level` on `JudgeResult`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
from cli_modelarium.exceptions import BatchValidationError
|
|
26
|
+
from cli_modelarium.io_safety import safe_input_path, split_escaped_csv
|
|
27
|
+
from cli_modelarium.judging import (
|
|
28
|
+
JudgeResult,
|
|
29
|
+
_extract_first_json_object,
|
|
30
|
+
_strip_code_fence,
|
|
31
|
+
parse_judge_response,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# ===== prompt templates =====
|
|
35
|
+
|
|
36
|
+
# The full evaluation rubric. `{reference_check}` is substituted with either
|
|
37
|
+
# HALLUCINATION_WITH_FACTS (when facts are provided) or
|
|
38
|
+
# HALLUCINATION_WITHOUT_FACTS. The literal `{"score": ...}` braces in the
|
|
39
|
+
# response-format line do NOT need escaping because we use `.replace()`,
|
|
40
|
+
# not `.format()`.
|
|
41
|
+
HALLUCINATION_CRITERIA_BASE = (
|
|
42
|
+
"""\
|
|
43
|
+
Evaluate the response for hallucination risk. Check:
|
|
44
|
+
1. Factual accuracy of claimed facts (dates, names, statistics)
|
|
45
|
+
2. Whether the response invents specific details (fake citations, quotes, sources)
|
|
46
|
+
3. Whether the response contradicts commonly accepted facts
|
|
47
|
+
4. Whether the response hedges appropriately on uncertain claims
|
|
48
|
+
5. {reference_check}
|
|
49
|
+
|
|
50
|
+
Rate hallucination risk on 1-10:
|
|
51
|
+
- 10 = All claims accurate and well-hedged
|
|
52
|
+
- 7-9 = Mostly accurate with minor unhedged claims
|
|
53
|
+
- 4-6 = Some incorrect or unverifiable claims
|
|
54
|
+
- 1-3 = Multiple incorrect claims or fabricated details
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
"Respond ONLY with JSON: "
|
|
58
|
+
'{"score": <1-10>, "risk_level": "Low" | "Medium" | "High", '
|
|
59
|
+
'"reasoning": "<one sentence>"}'
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
HALLUCINATION_WITH_FACTS = """\
|
|
63
|
+
Verify whether the response is consistent with these known facts:
|
|
64
|
+
{facts}
|
|
65
|
+
Flag any contradictions or omissions of these facts."""
|
|
66
|
+
|
|
67
|
+
HALLUCINATION_WITHOUT_FACTS = """\
|
|
68
|
+
Evaluate against the judge model's own factual knowledge.
|
|
69
|
+
Lower scores for any claims that cannot be verified."""
|
|
70
|
+
|
|
71
|
+
# Wrapper template that build_judge_prompt fills with {criteria}/{prompt}/{response}.
|
|
72
|
+
# Mirrors JUDGE_PROMPT_TEMPLATE's shape but drops the trailing "Respond with..."
|
|
73
|
+
# instructions because HALLUCINATION_CRITERIA_BASE already includes them.
|
|
74
|
+
HALLUCINATION_TEMPLATE = """\
|
|
75
|
+
{criteria}
|
|
76
|
+
|
|
77
|
+
Original prompt: {prompt}
|
|
78
|
+
Response to evaluate: {response}"""
|
|
79
|
+
|
|
80
|
+
# Size limit for --expected-facts-file. Matches SYSTEM_PROMPT_MAX_BYTES.
|
|
81
|
+
EXPECTED_FACTS_MAX_BYTES = 1_000_000
|
|
82
|
+
|
|
83
|
+
# The three permitted risk_level classifications.
|
|
84
|
+
RISK_LOW = "Low"
|
|
85
|
+
RISK_MEDIUM = "Medium"
|
|
86
|
+
RISK_HIGH = "High"
|
|
87
|
+
_RISK_LEVELS = {RISK_LOW, RISK_MEDIUM, RISK_HIGH}
|
|
88
|
+
|
|
89
|
+
# Extension to the ToS panel, appended when --check-hallucination is active.
|
|
90
|
+
HALLUCINATION_TOS_EXTENSION = (
|
|
91
|
+
"Hallucination detection uses LLM-as-judge methodology with reference "
|
|
92
|
+
"facts when provided. Accuracy varies by model and topic. Treat results "
|
|
93
|
+
"as guidance, not ground truth."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ===== facts handling =====
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def parse_facts_csv(value: str) -> list[str]:
|
|
101
|
+
"""Split a comma-separated facts string. Same `\\,` escape as elsewhere.
|
|
102
|
+
|
|
103
|
+
Empty string returns []. Whitespace stripped, empty pieces dropped.
|
|
104
|
+
"""
|
|
105
|
+
return split_escaped_csv(value)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def load_expected_facts(file_path: str) -> list[str]:
|
|
109
|
+
"""Load expected facts from a .txt (one per line) or .json (array) file.
|
|
110
|
+
|
|
111
|
+
Returns [] for an empty file. Rejects duplicate facts (case-insensitive)
|
|
112
|
+
with BatchValidationError. Unknown extension also raises.
|
|
113
|
+
"""
|
|
114
|
+
path = safe_input_path(file_path, max_size_bytes=EXPECTED_FACTS_MAX_BYTES)
|
|
115
|
+
suffix = path.suffix.lower()
|
|
116
|
+
if suffix == ".txt":
|
|
117
|
+
facts = _load_facts_txt(path)
|
|
118
|
+
elif suffix == ".json":
|
|
119
|
+
facts = _load_facts_json(path)
|
|
120
|
+
else:
|
|
121
|
+
raise BatchValidationError(
|
|
122
|
+
f"Cannot detect expected-facts file format from extension {suffix!r}.\n"
|
|
123
|
+
f" Supported: .txt (one fact per line) or .json (array of strings).\n"
|
|
124
|
+
f" At: {path}"
|
|
125
|
+
)
|
|
126
|
+
_reject_duplicates(facts, path=str(path))
|
|
127
|
+
return facts
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _load_facts_txt(path) -> list[str]:
|
|
131
|
+
"""Parse a .txt facts file. Comments (`#`) and blank lines ignored."""
|
|
132
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
133
|
+
out: list[str] = []
|
|
134
|
+
for line in text.splitlines():
|
|
135
|
+
stripped = line.strip()
|
|
136
|
+
if not stripped or stripped.startswith("#"):
|
|
137
|
+
continue
|
|
138
|
+
out.append(stripped)
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _load_facts_json(path) -> list[str]:
|
|
143
|
+
"""Parse a .json facts file. Must be a top-level array of strings."""
|
|
144
|
+
raw = path.read_text(encoding="utf-8-sig")
|
|
145
|
+
data = json.loads(raw)
|
|
146
|
+
if not isinstance(data, list):
|
|
147
|
+
raise BatchValidationError(
|
|
148
|
+
f"Expected-facts JSON file must be an array at top level, "
|
|
149
|
+
f"got {type(data).__name__!r}. At: {path}"
|
|
150
|
+
)
|
|
151
|
+
out: list[str] = []
|
|
152
|
+
for i, item in enumerate(data):
|
|
153
|
+
if not isinstance(item, str):
|
|
154
|
+
raise BatchValidationError(
|
|
155
|
+
f"Expected-facts JSON element #{i} must be a string "
|
|
156
|
+
f"(got {type(item).__name__!r}). At: {path}"
|
|
157
|
+
)
|
|
158
|
+
stripped = item.strip()
|
|
159
|
+
if stripped:
|
|
160
|
+
out.append(stripped)
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _reject_duplicates(facts: list[str], *, path: str) -> None:
|
|
165
|
+
"""Raise if `facts` contains any case-insensitive duplicate."""
|
|
166
|
+
seen: dict[str, str] = {}
|
|
167
|
+
for fact in facts:
|
|
168
|
+
key = fact.lower()
|
|
169
|
+
if key in seen:
|
|
170
|
+
raise BatchValidationError(
|
|
171
|
+
f"Duplicate expected fact (case-insensitive): "
|
|
172
|
+
f"{seen[key]!r} and {fact!r}. At: {path}"
|
|
173
|
+
)
|
|
174
|
+
seen[key] = fact
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ===== criteria builder =====
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def build_hallucination_criteria(facts: list[str] | None) -> list[str]:
|
|
181
|
+
"""Build the single-item criteria list passed to run_judging.
|
|
182
|
+
|
|
183
|
+
The returned list has exactly one element: the filled
|
|
184
|
+
HALLUCINATION_CRITERIA_BASE with `{reference_check}` substituted. When
|
|
185
|
+
`facts` is provided, the WITH_FACTS template is used with facts
|
|
186
|
+
rendered as a bullet list. When `facts` is None or empty, the
|
|
187
|
+
WITHOUT_FACTS template is used.
|
|
188
|
+
"""
|
|
189
|
+
if facts:
|
|
190
|
+
bullets = "\n".join(f" - {f}" for f in facts)
|
|
191
|
+
reference_check = HALLUCINATION_WITH_FACTS.replace("{facts}", bullets)
|
|
192
|
+
else:
|
|
193
|
+
reference_check = HALLUCINATION_WITHOUT_FACTS
|
|
194
|
+
filled = HALLUCINATION_CRITERIA_BASE.replace("{reference_check}", reference_check)
|
|
195
|
+
return [filled]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ===== response parser =====
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def parse_hallucination_response(text: str) -> dict:
|
|
202
|
+
"""Parse a hallucination-judge response into score/risk_level/reasoning.
|
|
203
|
+
|
|
204
|
+
Wraps `parse_judge_response` for score+reasoning+parse_error, then
|
|
205
|
+
extracts `risk_level` from the same JSON. The risk_level field:
|
|
206
|
+
|
|
207
|
+
* MUST be one of "Low" / "Medium" / "High" (case-insensitive,
|
|
208
|
+
normalized to title case).
|
|
209
|
+
* Invalid values (e.g. "Critical") become parse_error.
|
|
210
|
+
* Missing risk_level falls back to derivation from score
|
|
211
|
+
(1-3 -> High, 4-6 -> Medium, 7-10 -> Low). If score is also
|
|
212
|
+
missing, risk_level stays None.
|
|
213
|
+
"""
|
|
214
|
+
base = parse_judge_response(text)
|
|
215
|
+
|
|
216
|
+
risk_level: str | None = None
|
|
217
|
+
risk_error: str | None = None
|
|
218
|
+
|
|
219
|
+
parsed = _try_parse_object(text)
|
|
220
|
+
if parsed is not None and "risk_level" in parsed:
|
|
221
|
+
raw_rl = parsed["risk_level"]
|
|
222
|
+
if isinstance(raw_rl, str):
|
|
223
|
+
normalized = raw_rl.strip().title()
|
|
224
|
+
if normalized in _RISK_LEVELS:
|
|
225
|
+
risk_level = normalized
|
|
226
|
+
else:
|
|
227
|
+
risk_error = f"invalid risk_level {raw_rl!r}; must be one of {sorted(_RISK_LEVELS)}"
|
|
228
|
+
else:
|
|
229
|
+
risk_error = f"risk_level must be a string, got {type(raw_rl).__name__}"
|
|
230
|
+
else:
|
|
231
|
+
# Not in the JSON - derive from score per spec.
|
|
232
|
+
risk_level = risk_level_from_score(base["score"])
|
|
233
|
+
|
|
234
|
+
parse_error = base["parse_error"] or risk_error
|
|
235
|
+
return {
|
|
236
|
+
"score": base["score"],
|
|
237
|
+
"reasoning": base["reasoning"],
|
|
238
|
+
"risk_level": risk_level,
|
|
239
|
+
"parse_error": parse_error,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _try_parse_object(text: str) -> dict | None:
|
|
244
|
+
"""Run the same code-fence + extract-first-json strategies as parse_judge_response."""
|
|
245
|
+
text = text.strip()
|
|
246
|
+
if not text:
|
|
247
|
+
return None
|
|
248
|
+
candidate = _strip_code_fence(text)
|
|
249
|
+
try:
|
|
250
|
+
parsed = json.loads(candidate)
|
|
251
|
+
except json.JSONDecodeError:
|
|
252
|
+
parsed = _extract_first_json_object(text)
|
|
253
|
+
if isinstance(parsed, dict):
|
|
254
|
+
return parsed
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def risk_level_from_score(score: int | None) -> str | None:
|
|
259
|
+
"""Map a 1-10 score to a risk_level. Deterministic per spec.
|
|
260
|
+
|
|
261
|
+
1-3 = High, 4-6 = Medium, 7-10 = Low. Out-of-range scores or None
|
|
262
|
+
return None - we don't guess.
|
|
263
|
+
"""
|
|
264
|
+
if score is None:
|
|
265
|
+
return None
|
|
266
|
+
if not isinstance(score, int):
|
|
267
|
+
return None
|
|
268
|
+
if score < 1 or score > 10:
|
|
269
|
+
return None
|
|
270
|
+
if score <= 3:
|
|
271
|
+
return RISK_HIGH
|
|
272
|
+
if score <= 6:
|
|
273
|
+
return RISK_MEDIUM
|
|
274
|
+
return RISK_LOW
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ===== aggregation =====
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def aggregate_risk_levels(judges: list) -> str | None:
|
|
281
|
+
"""Worst-wins aggregation across a panel's per-judge risk_levels.
|
|
282
|
+
|
|
283
|
+
Any High -> "High". No High but any Medium -> "Medium". All Low -> "Low".
|
|
284
|
+
Empty (no judges with a risk_level) -> None.
|
|
285
|
+
"""
|
|
286
|
+
levels = [j.risk_level for j in judges if getattr(j, "risk_level", None)]
|
|
287
|
+
if not levels:
|
|
288
|
+
return None
|
|
289
|
+
if RISK_HIGH in levels:
|
|
290
|
+
return RISK_HIGH
|
|
291
|
+
if RISK_MEDIUM in levels:
|
|
292
|
+
return RISK_MEDIUM
|
|
293
|
+
return RISK_LOW
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def annotate_risk_levels(judge_results: list[JudgeResult]) -> None:
|
|
297
|
+
"""Populate `aggregated_risk_level` on each result in place.
|
|
298
|
+
|
|
299
|
+
Called by cli.py after run_judging() returns; mutates the
|
|
300
|
+
JudgeResults so downstream formatters see the aggregated value.
|
|
301
|
+
"""
|
|
302
|
+
for jr in judge_results:
|
|
303
|
+
jr.aggregated_risk_level = aggregate_risk_levels(jr.judges)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ===== config dataclass =====
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@dataclass
|
|
310
|
+
class HallucinationConfig:
|
|
311
|
+
"""Resolved configuration for a hallucination run."""
|
|
312
|
+
|
|
313
|
+
criteria: list[str]
|
|
314
|
+
template: str
|
|
315
|
+
# facts is None when no facts were supplied (uses WITHOUT_FACTS template).
|
|
316
|
+
# Empty list is normalized to None at resolve time.
|
|
317
|
+
facts: list[str] | None = None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def resolve_hallucination_config(
|
|
321
|
+
*,
|
|
322
|
+
check_hallucination: bool,
|
|
323
|
+
expected_facts: str | None,
|
|
324
|
+
expected_facts_file: str | None,
|
|
325
|
+
hallucination_template: str | None,
|
|
326
|
+
judge_models_present: bool,
|
|
327
|
+
) -> HallucinationConfig | None:
|
|
328
|
+
"""Validate the hallucination flag combination and return a config or None.
|
|
329
|
+
|
|
330
|
+
Returns None when --check-hallucination is NOT set (the standard
|
|
331
|
+
judging path applies). Raises ModelariumError for invalid combinations
|
|
332
|
+
so cli.py can surface them as click.UsageError / exit 2.
|
|
333
|
+
"""
|
|
334
|
+
from cli_modelarium.io_safety import load_system_prompt
|
|
335
|
+
|
|
336
|
+
if not check_hallucination:
|
|
337
|
+
if expected_facts or expected_facts_file or hallucination_template:
|
|
338
|
+
raise BatchValidationError(
|
|
339
|
+
"--expected-facts, --expected-facts-file, and --hallucination-template "
|
|
340
|
+
"require --check-hallucination."
|
|
341
|
+
)
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
if not judge_models_present:
|
|
345
|
+
raise BatchValidationError(
|
|
346
|
+
"--check-hallucination requires --judge or --judges - "
|
|
347
|
+
"hallucination scoring is implemented as a judge configuration."
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if expected_facts and expected_facts_file:
|
|
351
|
+
raise BatchValidationError(
|
|
352
|
+
"--expected-facts and --expected-facts-file are mutually exclusive."
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if hallucination_template and (expected_facts or expected_facts_file):
|
|
356
|
+
raise BatchValidationError(
|
|
357
|
+
"--hallucination-template is mutually exclusive with "
|
|
358
|
+
"--expected-facts / --expected-facts-file - custom templates "
|
|
359
|
+
"are responsible for their own fact-checking structure."
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
facts: list[str] | None = None
|
|
363
|
+
if expected_facts_file:
|
|
364
|
+
facts = load_expected_facts(expected_facts_file)
|
|
365
|
+
elif expected_facts:
|
|
366
|
+
facts = parse_facts_csv(expected_facts)
|
|
367
|
+
# Empty CSV -> [] -> normalize to None so we use WITHOUT_FACTS.
|
|
368
|
+
if facts is not None and len(facts) == 0:
|
|
369
|
+
facts = None
|
|
370
|
+
|
|
371
|
+
if hallucination_template:
|
|
372
|
+
# User-supplied evaluation rubric. Replaces the BASE constant entirely.
|
|
373
|
+
# The custom text is treated as a single criterion; the standard
|
|
374
|
+
# HALLUCINATION_TEMPLATE wrapper still provides {prompt}/{response} slots.
|
|
375
|
+
custom_text = load_system_prompt(hallucination_template)
|
|
376
|
+
criteria = [custom_text]
|
|
377
|
+
else:
|
|
378
|
+
criteria = build_hallucination_criteria(facts)
|
|
379
|
+
|
|
380
|
+
return HallucinationConfig(
|
|
381
|
+
criteria=criteria,
|
|
382
|
+
template=HALLUCINATION_TEMPLATE,
|
|
383
|
+
facts=facts,
|
|
384
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Path safety helpers for user-provided input files.
|
|
2
|
+
|
|
3
|
+
Used by:
|
|
4
|
+
* --system-prompt-file (Phase 6)
|
|
5
|
+
* batch input files (Phase 7)
|
|
6
|
+
|
|
7
|
+
Security model
|
|
8
|
+
==============
|
|
9
|
+
|
|
10
|
+
This module does NOT block path traversal (`../foo`, `/etc/passwd`, etc.).
|
|
11
|
+
The user explicitly typed the path on their CLI; we trust their intent.
|
|
12
|
+
What we DO protect against:
|
|
13
|
+
|
|
14
|
+
* Reading directories, special devices, or sockets by accident.
|
|
15
|
+
* Loading multi-gigabyte files that would OOM the process.
|
|
16
|
+
* Silently consuming garbage from a non-UTF-8 file - we read with
|
|
17
|
+
`utf-8-sig` so BOMs are tolerated but mismatched encodings surface as
|
|
18
|
+
a clear UnicodeDecodeError rather than producing mojibake downstream.
|
|
19
|
+
|
|
20
|
+
If you ever want to restrict paths (e.g. a "must be inside repo root" check
|
|
21
|
+
for batch mode CI workflows), add it on top - don't bake it in here, because
|
|
22
|
+
this module is shared by every command that loads a user file.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
# Maximum size for a system-prompt file. 1 MB allows for very long prompts
|
|
30
|
+
# (a typical system prompt is well under 10 KB) while preventing OOM.
|
|
31
|
+
SYSTEM_PROMPT_MAX_BYTES = 1_000_000 # 1 MB
|
|
32
|
+
|
|
33
|
+
# Phase 7 batch files can be larger - bumped to 10 MB.
|
|
34
|
+
BATCH_INPUT_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def safe_input_path(user_path: str, *, max_size_bytes: int) -> Path:
|
|
38
|
+
"""Resolve a user-provided path and check it's safe to read.
|
|
39
|
+
|
|
40
|
+
Returns the resolved `Path` on success.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
FileNotFoundError: the path does not exist.
|
|
44
|
+
ValueError: the path is not a regular file, or the file exceeds
|
|
45
|
+
`max_size_bytes`.
|
|
46
|
+
"""
|
|
47
|
+
path = Path(user_path).expanduser().resolve()
|
|
48
|
+
if not path.exists():
|
|
49
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
raise ValueError(f"Path is not a regular file: {path}")
|
|
52
|
+
size = path.stat().st_size
|
|
53
|
+
if size > max_size_bytes:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"File too large: {size / 1_048_576:.2f} MB "
|
|
56
|
+
f"(max {max_size_bytes / 1_048_576:.2f} MB at {path})"
|
|
57
|
+
)
|
|
58
|
+
return path
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def split_escaped_csv(value: str) -> list[str]:
|
|
62
|
+
"""Split a comma-separated string with `\\,` as a literal-comma escape.
|
|
63
|
+
|
|
64
|
+
Whitespace around each piece is stripped; empty pieces (e.g. trailing
|
|
65
|
+
comma) are dropped. Any other backslash is kept verbatim.
|
|
66
|
+
|
|
67
|
+
Used by --system-prompts, --judge-criteria, --expected-facts. Lives in
|
|
68
|
+
io_safety so the various callers don't have to depend on cli.py.
|
|
69
|
+
|
|
70
|
+
Example: `split_escaped_csv(r"a,b,c\\,d")` -> `["a", "b", "c,d"]`
|
|
71
|
+
"""
|
|
72
|
+
out: list[str] = []
|
|
73
|
+
buf: list[str] = []
|
|
74
|
+
i = 0
|
|
75
|
+
while i < len(value):
|
|
76
|
+
c = value[i]
|
|
77
|
+
if c == "\\" and i + 1 < len(value) and value[i + 1] == ",":
|
|
78
|
+
buf.append(",")
|
|
79
|
+
i += 2
|
|
80
|
+
continue
|
|
81
|
+
if c == ",":
|
|
82
|
+
piece = "".join(buf).strip()
|
|
83
|
+
if piece:
|
|
84
|
+
out.append(piece)
|
|
85
|
+
buf = []
|
|
86
|
+
i += 1
|
|
87
|
+
continue
|
|
88
|
+
buf.append(c)
|
|
89
|
+
i += 1
|
|
90
|
+
last = "".join(buf).strip()
|
|
91
|
+
if last:
|
|
92
|
+
out.append(last)
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_system_prompt(file_path: str) -> str:
|
|
97
|
+
"""Load a system prompt from disk.
|
|
98
|
+
|
|
99
|
+
Returns the file contents with leading/trailing whitespace stripped.
|
|
100
|
+
Empty files yield an empty string.
|
|
101
|
+
|
|
102
|
+
Limits:
|
|
103
|
+
* Size: 1 MB (see `SYSTEM_PROMPT_MAX_BYTES`).
|
|
104
|
+
* Encoding: UTF-8. A leading BOM is tolerated (`utf-8-sig`).
|
|
105
|
+
|
|
106
|
+
Security: the file path is trusted - the user typed it. We do not
|
|
107
|
+
block path traversal. We DO prevent the foot-guns (size + encoding).
|
|
108
|
+
See the module docstring for the full rationale.
|
|
109
|
+
"""
|
|
110
|
+
path = safe_input_path(file_path, max_size_bytes=SYSTEM_PROMPT_MAX_BYTES)
|
|
111
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
112
|
+
return text.strip()
|