aieval-py 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.
aieval/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ """aieval: Deterministic-first runtime validation and reliability for LLM outputs and agent actions."""
2
+
3
+ from aieval.checks import (
4
+ AllowedFieldsCheck,
5
+ AllowedValuesCheck,
6
+ AsyncCheck,
7
+ Check,
8
+ CheckResult,
9
+ LengthBoundsCheck,
10
+ MultiToolCallCheck,
11
+ NumericRangeCheck,
12
+ RegexMatchCheck,
13
+ RequiredFieldsCheck,
14
+ SchemaCheck,
15
+ ToolCallCheck,
16
+ ValidJsonCheck,
17
+ allowed_fields,
18
+ allowed_values,
19
+ length_bounds,
20
+ numeric_range,
21
+ regex_match,
22
+ required_fields,
23
+ schema_check,
24
+ tool_call_check,
25
+ tool_call_one_of,
26
+ valid_json,
27
+ )
28
+ from aieval.domain import (
29
+ EvaluationContext,
30
+ EvaluationError,
31
+ EvaluationResult,
32
+ EvaluationSummary,
33
+ Failure,
34
+ FailureCode,
35
+ Severity,
36
+ )
37
+ from aieval.runner import (
38
+ EvaluationConfigError,
39
+ aevaluate,
40
+ evaluate,
41
+ )
42
+ from aieval.semantic import (
43
+ AsyncSemanticJudge,
44
+ SemanticJudge,
45
+ )
46
+
47
+ __version__ = "0.1.0"
48
+
49
+ __all__ = [
50
+ "AllowedFieldsCheck",
51
+ "AllowedValuesCheck",
52
+ "AsyncCheck",
53
+ "AsyncSemanticJudge",
54
+ "Check",
55
+ "CheckResult",
56
+ "EvaluationConfigError",
57
+ "EvaluationContext",
58
+ "EvaluationError",
59
+ "EvaluationResult",
60
+ "EvaluationSummary",
61
+ "Failure",
62
+ "FailureCode",
63
+ "LengthBoundsCheck",
64
+ "MultiToolCallCheck",
65
+ "NumericRangeCheck",
66
+ "RegexMatchCheck",
67
+ "RequiredFieldsCheck",
68
+ "SchemaCheck",
69
+ "SemanticJudge",
70
+ "Severity",
71
+ "ToolCallCheck",
72
+ "ValidJsonCheck",
73
+ "__version__",
74
+ "aevaluate",
75
+ "allowed_fields",
76
+ "allowed_values",
77
+ "evaluate",
78
+ "length_bounds",
79
+ "numeric_range",
80
+ "regex_match",
81
+ "required_fields",
82
+ "schema_check",
83
+ "tool_call_check",
84
+ "tool_call_one_of",
85
+ "valid_json",
86
+ ]
@@ -0,0 +1,51 @@
1
+ from aieval.checks.base import AsyncCheck, Check, CheckResult
2
+ from aieval.checks.constraints import (
3
+ AllowedValuesCheck,
4
+ LengthBoundsCheck,
5
+ NumericRangeCheck,
6
+ RegexMatchCheck,
7
+ allowed_values,
8
+ length_bounds,
9
+ numeric_range,
10
+ regex_match,
11
+ )
12
+ from aieval.checks.fields import (
13
+ AllowedFieldsCheck,
14
+ RequiredFieldsCheck,
15
+ allowed_fields,
16
+ required_fields,
17
+ )
18
+ from aieval.checks.json_check import ValidJsonCheck, valid_json
19
+ from aieval.checks.schema import SchemaCheck, schema_check
20
+ from aieval.checks.tool_call import (
21
+ MultiToolCallCheck,
22
+ ToolCallCheck,
23
+ tool_call_check,
24
+ tool_call_one_of,
25
+ )
26
+
27
+ __all__ = [
28
+ "AllowedFieldsCheck",
29
+ "AllowedValuesCheck",
30
+ "AsyncCheck",
31
+ "Check",
32
+ "CheckResult",
33
+ "LengthBoundsCheck",
34
+ "MultiToolCallCheck",
35
+ "NumericRangeCheck",
36
+ "RegexMatchCheck",
37
+ "RequiredFieldsCheck",
38
+ "SchemaCheck",
39
+ "ToolCallCheck",
40
+ "ValidJsonCheck",
41
+ "allowed_fields",
42
+ "allowed_values",
43
+ "length_bounds",
44
+ "numeric_range",
45
+ "regex_match",
46
+ "required_fields",
47
+ "schema_check",
48
+ "tool_call_check",
49
+ "tool_call_one_of",
50
+ "valid_json",
51
+ ]
aieval/checks/base.py ADDED
@@ -0,0 +1,55 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass, field
3
+ from typing import Any, Protocol, runtime_checkable
4
+
5
+ from aieval.domain.context import EvaluationContext
6
+ from aieval.domain.failure import Failure
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class CheckResult:
11
+ """The outcome of an individual check."""
12
+
13
+ passed: bool
14
+ failures: tuple[Failure, ...] = field(default_factory=tuple)
15
+ name: str = "check"
16
+
17
+ @classmethod
18
+ def ok(cls, name: str = "check") -> "CheckResult":
19
+ return cls(passed=True, failures=(), name=name)
20
+
21
+ @classmethod
22
+ def failed(
23
+ cls,
24
+ failures: Sequence[Failure] | Failure,
25
+ name: str = "check",
26
+ ) -> "CheckResult":
27
+ if isinstance(failures, Failure):
28
+ return cls(passed=False, failures=(failures,), name=name)
29
+ return cls(passed=False, failures=tuple(failures), name=name)
30
+
31
+
32
+ @runtime_checkable
33
+ class Check(Protocol):
34
+ """Protocol for synchronous validation checks."""
35
+
36
+ name: str
37
+
38
+ def __call__(
39
+ self,
40
+ output: Any,
41
+ context: EvaluationContext | None = None,
42
+ ) -> CheckResult | Sequence[Failure] | None: ...
43
+
44
+
45
+ @runtime_checkable
46
+ class AsyncCheck(Protocol):
47
+ """Protocol for asynchronous validation checks."""
48
+
49
+ name: str
50
+
51
+ async def __call__(
52
+ self,
53
+ output: Any,
54
+ context: EvaluationContext | None = None,
55
+ ) -> CheckResult | Sequence[Failure] | None: ...
@@ -0,0 +1,243 @@
1
+ import re
2
+ from collections.abc import Collection
3
+ from typing import Any
4
+
5
+ from aieval.checks.base import CheckResult
6
+ from aieval.domain.context import EvaluationContext
7
+ from aieval.domain.failure import Failure, FailureCode, Severity
8
+
9
+
10
+ class RegexMatchCheck:
11
+ """Checks that a string matches a given regular expression."""
12
+
13
+ def __init__(
14
+ self,
15
+ pattern: str | re.Pattern[str],
16
+ *,
17
+ severity: Severity = Severity.ERROR,
18
+ name: str | None = None,
19
+ ) -> None:
20
+ if isinstance(pattern, str):
21
+ self.pattern = re.compile(pattern)
22
+ self.pattern_str = pattern
23
+ else:
24
+ self.pattern = pattern
25
+ self.pattern_str = pattern.pattern
26
+ self.severity = severity
27
+ self.name = name or f"regex_match({self.pattern_str})"
28
+
29
+ def __call__(
30
+ self,
31
+ output: Any,
32
+ context: EvaluationContext | None = None,
33
+ ) -> CheckResult:
34
+ if not isinstance(output, str):
35
+ return CheckResult.failed(
36
+ Failure(
37
+ code=FailureCode.REGEX_MISMATCH,
38
+ message=f"Expected string output for regex match, got {type(output).__name__}",
39
+ severity=self.severity,
40
+ ),
41
+ name=self.name,
42
+ )
43
+
44
+ if not self.pattern.search(output):
45
+ return CheckResult.failed(
46
+ Failure(
47
+ code=FailureCode.REGEX_MISMATCH,
48
+ message=f"Output does not match required pattern: '{self.pattern_str}'",
49
+ severity=self.severity,
50
+ metadata={"pattern": self.pattern_str, "sample": output[:100]},
51
+ ),
52
+ name=self.name,
53
+ )
54
+ return CheckResult.ok(name=self.name)
55
+
56
+
57
+ class AllowedValuesCheck:
58
+ """Checks that the output value belongs to a predefined set of allowed values."""
59
+
60
+ def __init__(
61
+ self,
62
+ allowed: Collection[Any],
63
+ *,
64
+ severity: Severity = Severity.ERROR,
65
+ name: str | None = None,
66
+ ) -> None:
67
+ self.allowed = set(allowed)
68
+ self.severity = severity
69
+ self.name = name or f"allowed_values({list(allowed)})"
70
+
71
+ def __call__(
72
+ self,
73
+ output: Any,
74
+ context: EvaluationContext | None = None,
75
+ ) -> CheckResult:
76
+ try:
77
+ is_member = output in self.allowed
78
+ except TypeError:
79
+ is_member = False
80
+
81
+ if not is_member:
82
+ return CheckResult.failed(
83
+ Failure(
84
+ code=FailureCode.ENUM_MISMATCH,
85
+ message=f"Value '{output}' is not one of the allowed values: {sorted(str(x) for x in self.allowed)}",
86
+ severity=self.severity,
87
+ metadata={"value": str(output), "allowed": list(self.allowed)},
88
+ ),
89
+ name=self.name,
90
+ )
91
+ return CheckResult.ok(name=self.name)
92
+
93
+
94
+ class NumericRangeCheck:
95
+ """Checks that a numeric value falls within [min_val, max_val]."""
96
+
97
+ def __init__(
98
+ self,
99
+ *,
100
+ min_val: float | None = None,
101
+ max_val: float | None = None,
102
+ severity: Severity = Severity.ERROR,
103
+ name: str | None = None,
104
+ ) -> None:
105
+ self.min_val = min_val
106
+ self.max_val = max_val
107
+ self.severity = severity
108
+ self.name = name or f"numeric_range(min={min_val}, max={max_val})"
109
+
110
+ def __call__(
111
+ self,
112
+ output: Any,
113
+ context: EvaluationContext | None = None,
114
+ ) -> CheckResult:
115
+ if not isinstance(output, (int, float)) or isinstance(output, bool):
116
+ return CheckResult.failed(
117
+ Failure(
118
+ code=FailureCode.VALUE_CONSTRAINT_VIOLATION,
119
+ message=f"Expected numeric output, got {type(output).__name__}",
120
+ severity=self.severity,
121
+ ),
122
+ name=self.name,
123
+ )
124
+
125
+ if self.min_val is not None and output < self.min_val:
126
+ return CheckResult.failed(
127
+ Failure(
128
+ code=FailureCode.VALUE_CONSTRAINT_VIOLATION,
129
+ message=f"Value {output} is less than minimum allowed {self.min_val}",
130
+ severity=self.severity,
131
+ metadata={"value": output, "min": self.min_val},
132
+ ),
133
+ name=self.name,
134
+ )
135
+
136
+ if self.max_val is not None and output > self.max_val:
137
+ return CheckResult.failed(
138
+ Failure(
139
+ code=FailureCode.VALUE_CONSTRAINT_VIOLATION,
140
+ message=f"Value {output} is greater than maximum allowed {self.max_val}",
141
+ severity=self.severity,
142
+ metadata={"value": output, "max": self.max_val},
143
+ ),
144
+ name=self.name,
145
+ )
146
+
147
+ return CheckResult.ok(name=self.name)
148
+
149
+
150
+ class LengthBoundsCheck:
151
+ """Checks that the length of a string, list, or sequence falls within [min_len, max_len]."""
152
+
153
+ def __init__(
154
+ self,
155
+ *,
156
+ min_len: int | None = None,
157
+ max_len: int | None = None,
158
+ severity: Severity = Severity.ERROR,
159
+ name: str | None = None,
160
+ ) -> None:
161
+ self.min_len = min_len
162
+ self.max_len = max_len
163
+ self.severity = severity
164
+ self.name = name or f"length_bounds(min={min_len}, max={max_len})"
165
+
166
+ def __call__(
167
+ self,
168
+ output: Any,
169
+ context: EvaluationContext | None = None,
170
+ ) -> CheckResult:
171
+ try:
172
+ length = len(output)
173
+ except TypeError:
174
+ return CheckResult.failed(
175
+ Failure(
176
+ code=FailureCode.LENGTH_OUT_OF_BOUNDS,
177
+ message=f"Output of type {type(output).__name__} does not have a length",
178
+ severity=self.severity,
179
+ ),
180
+ name=self.name,
181
+ )
182
+
183
+ if self.min_len is not None and length < self.min_len:
184
+ return CheckResult.failed(
185
+ Failure(
186
+ code=FailureCode.LENGTH_OUT_OF_BOUNDS,
187
+ message=f"Length {length} is less than minimum required {self.min_len}",
188
+ severity=self.severity,
189
+ metadata={"length": length, "min_len": self.min_len},
190
+ ),
191
+ name=self.name,
192
+ )
193
+
194
+ if self.max_len is not None and length > self.max_len:
195
+ return CheckResult.failed(
196
+ Failure(
197
+ code=FailureCode.LENGTH_OUT_OF_BOUNDS,
198
+ message=f"Length {length} is greater than maximum allowed {self.max_len}",
199
+ severity=self.severity,
200
+ metadata={"length": length, "max_len": self.max_len},
201
+ ),
202
+ name=self.name,
203
+ )
204
+
205
+ return CheckResult.ok(name=self.name)
206
+
207
+
208
+ def regex_match(
209
+ pattern: str | re.Pattern[str],
210
+ *,
211
+ severity: Severity = Severity.ERROR,
212
+ name: str | None = None,
213
+ ) -> RegexMatchCheck:
214
+ return RegexMatchCheck(pattern, severity=severity, name=name)
215
+
216
+
217
+ def allowed_values(
218
+ allowed: Collection[Any],
219
+ *,
220
+ severity: Severity = Severity.ERROR,
221
+ name: str | None = None,
222
+ ) -> AllowedValuesCheck:
223
+ return AllowedValuesCheck(allowed, severity=severity, name=name)
224
+
225
+
226
+ def numeric_range(
227
+ *,
228
+ min_val: float | None = None,
229
+ max_val: float | None = None,
230
+ severity: Severity = Severity.ERROR,
231
+ name: str | None = None,
232
+ ) -> NumericRangeCheck:
233
+ return NumericRangeCheck(min_val=min_val, max_val=max_val, severity=severity, name=name)
234
+
235
+
236
+ def length_bounds(
237
+ *,
238
+ min_len: int | None = None,
239
+ max_len: int | None = None,
240
+ severity: Severity = Severity.ERROR,
241
+ name: str | None = None,
242
+ ) -> LengthBoundsCheck:
243
+ return LengthBoundsCheck(min_len=min_len, max_len=max_len, severity=severity, name=name)
@@ -0,0 +1,127 @@
1
+ import json
2
+ from collections.abc import Sequence
3
+ from typing import Any
4
+
5
+ from aieval.checks.base import CheckResult
6
+ from aieval.domain.context import EvaluationContext
7
+ from aieval.domain.failure import Failure, FailureCode, Severity
8
+
9
+
10
+ def _normalize_dict(output: Any) -> tuple[dict[str, Any] | None, Failure | None]:
11
+ if isinstance(output, dict):
12
+ return output, None
13
+ if isinstance(output, str):
14
+ try:
15
+ parsed = json.loads(output)
16
+ if isinstance(parsed, dict):
17
+ return parsed, None
18
+ return None, Failure(
19
+ code=FailureCode.INVALID_JSON,
20
+ message=f"Expected JSON object, parsed {type(parsed).__name__}",
21
+ severity=Severity.ERROR,
22
+ )
23
+ except json.JSONDecodeError as err:
24
+ return None, Failure(
25
+ code=FailureCode.INVALID_JSON,
26
+ message=f"Output is not valid JSON: {err.msg}",
27
+ severity=Severity.ERROR,
28
+ )
29
+ return None, Failure(
30
+ code=FailureCode.INVALID_JSON,
31
+ message=f"Output of type {type(output).__name__} is not a dictionary or JSON object",
32
+ severity=Severity.ERROR,
33
+ )
34
+
35
+
36
+ class RequiredFieldsCheck:
37
+ """Checks that all specified fields are present in the output dictionary."""
38
+
39
+ def __init__(
40
+ self,
41
+ fields: Sequence[str],
42
+ *,
43
+ severity: Severity = Severity.ERROR,
44
+ ) -> None:
45
+ self.fields = tuple(fields)
46
+ self.severity = severity
47
+ self.name = f"required_fields({list(self.fields)})"
48
+
49
+ def __call__(
50
+ self,
51
+ output: Any,
52
+ context: EvaluationContext | None = None,
53
+ ) -> CheckResult:
54
+ data, err = _normalize_dict(output)
55
+ if err is not None:
56
+ return CheckResult.failed(err, name=self.name)
57
+ assert data is not None
58
+
59
+ missing = [f for f in self.fields if f not in data]
60
+ if missing:
61
+ failures = [
62
+ Failure(
63
+ code=FailureCode.MISSING_REQUIRED_FIELD,
64
+ message=f"Missing required field: '{field}'",
65
+ severity=self.severity,
66
+ path=field,
67
+ )
68
+ for field in missing
69
+ ]
70
+ return CheckResult.failed(failures, name=self.name)
71
+ return CheckResult.ok(name=self.name)
72
+
73
+
74
+ class AllowedFieldsCheck:
75
+ """Checks that no unexpected fields are present beyond the allowed list."""
76
+
77
+ def __init__(
78
+ self,
79
+ fields: Sequence[str],
80
+ *,
81
+ severity: Severity = Severity.ERROR,
82
+ ) -> None:
83
+ self.allowed = set(fields)
84
+ self.severity = severity
85
+ self.name = f"allowed_fields({list(fields)})"
86
+
87
+ def __call__(
88
+ self,
89
+ output: Any,
90
+ context: EvaluationContext | None = None,
91
+ ) -> CheckResult:
92
+ data, err = _normalize_dict(output)
93
+ if err is not None:
94
+ return CheckResult.failed(err, name=self.name)
95
+ assert data is not None
96
+
97
+ unexpected = [f for f in data if f not in self.allowed]
98
+ if unexpected:
99
+ failures = [
100
+ Failure(
101
+ code=FailureCode.UNEXPECTED_FIELD,
102
+ message=f"Unexpected field present: '{field}'",
103
+ severity=self.severity,
104
+ path=field,
105
+ )
106
+ for field in unexpected
107
+ ]
108
+ return CheckResult.failed(failures, name=self.name)
109
+ return CheckResult.ok(name=self.name)
110
+
111
+
112
+ def required_fields(
113
+ fields: Sequence[str],
114
+ *,
115
+ severity: Severity = Severity.ERROR,
116
+ ) -> RequiredFieldsCheck:
117
+ """Returns a check verifying the presence of specified fields."""
118
+ return RequiredFieldsCheck(fields, severity=severity)
119
+
120
+
121
+ def allowed_fields(
122
+ fields: Sequence[str],
123
+ *,
124
+ severity: Severity = Severity.ERROR,
125
+ ) -> AllowedFieldsCheck:
126
+ """Returns a check verifying that only allowed fields are present."""
127
+ return AllowedFieldsCheck(fields, severity=severity)
@@ -0,0 +1,59 @@
1
+ import json
2
+ from typing import Any
3
+
4
+ from aieval.checks.base import CheckResult
5
+ from aieval.domain.context import EvaluationContext
6
+ from aieval.domain.failure import Failure, FailureCode, Severity
7
+
8
+
9
+ class ValidJsonCheck:
10
+ """Checks that output is valid JSON (if string) or a valid JSON-serializable structure."""
11
+
12
+ name: str = "valid_json"
13
+
14
+ def __call__(
15
+ self,
16
+ output: Any,
17
+ context: EvaluationContext | None = None,
18
+ ) -> CheckResult:
19
+ if isinstance(output, (dict, list)):
20
+ return CheckResult.ok(name=self.name)
21
+
22
+ if isinstance(output, str):
23
+ trimmed = output.strip()
24
+ if not trimmed:
25
+ return CheckResult.failed(
26
+ Failure(
27
+ code=FailureCode.INVALID_JSON,
28
+ message="Output is an empty string, expected valid JSON.",
29
+ severity=Severity.ERROR,
30
+ ),
31
+ name=self.name,
32
+ )
33
+ try:
34
+ json.loads(trimmed)
35
+ return CheckResult.ok(name=self.name)
36
+ except json.JSONDecodeError as err:
37
+ return CheckResult.failed(
38
+ Failure(
39
+ code=FailureCode.INVALID_JSON,
40
+ message=f"Output is not valid JSON: {err.msg} at line {err.lineno} col {err.colno}",
41
+ severity=Severity.ERROR,
42
+ metadata={"error": str(err), "pos": err.pos},
43
+ ),
44
+ name=self.name,
45
+ )
46
+
47
+ return CheckResult.failed(
48
+ Failure(
49
+ code=FailureCode.INVALID_JSON,
50
+ message=f"Output of type {type(output).__name__} is not a valid JSON string or object.",
51
+ severity=Severity.ERROR,
52
+ ),
53
+ name=self.name,
54
+ )
55
+
56
+
57
+ def valid_json() -> ValidJsonCheck:
58
+ """Returns a check ensuring the output is valid JSON."""
59
+ return ValidJsonCheck()
@@ -0,0 +1,86 @@
1
+ import json
2
+ from typing import Any
3
+
4
+ from pydantic import BaseModel, TypeAdapter, ValidationError
5
+
6
+ from aieval.checks.base import CheckResult
7
+ from aieval.domain.context import EvaluationContext
8
+ from aieval.domain.failure import Failure, FailureCode, Severity
9
+
10
+
11
+ class SchemaCheck:
12
+ """Validates output against a Pydantic v2 BaseModel or TypeAdapter."""
13
+
14
+ def __init__(
15
+ self,
16
+ schema: type[BaseModel] | TypeAdapter[Any],
17
+ *,
18
+ severity: Severity = Severity.ERROR,
19
+ name: str | None = None,
20
+ ) -> None:
21
+ self.schema = schema
22
+ self.severity = severity
23
+ if name is not None:
24
+ self.name = name
25
+ elif isinstance(schema, type) and issubclass(schema, BaseModel):
26
+ self.name = f"schema_check({schema.__name__})"
27
+ else:
28
+ self.name = "schema_check"
29
+
30
+ def __call__(
31
+ self,
32
+ output: Any,
33
+ context: EvaluationContext | None = None,
34
+ ) -> CheckResult:
35
+ data = output
36
+
37
+ # Auto-parse JSON string if input is string
38
+ if isinstance(output, str):
39
+ trimmed = output.strip()
40
+ try:
41
+ data = json.loads(trimmed)
42
+ except json.JSONDecodeError as err:
43
+ return CheckResult.failed(
44
+ Failure(
45
+ code=FailureCode.INVALID_JSON,
46
+ message=f"Failed to parse JSON string for schema validation: {err.msg}",
47
+ severity=self.severity,
48
+ metadata={"raw": output},
49
+ ),
50
+ name=self.name,
51
+ )
52
+
53
+ try:
54
+ if isinstance(self.schema, type) and issubclass(self.schema, BaseModel):
55
+ self.schema.model_validate(data)
56
+ elif isinstance(self.schema, TypeAdapter):
57
+ self.schema.validate_python(data)
58
+ return CheckResult.ok(name=self.name)
59
+ except ValidationError as err:
60
+ failures: list[Failure] = []
61
+ for e in err.errors():
62
+ loc = e.get("loc", ())
63
+ path_str = ".".join(str(item) for item in loc) if loc else None
64
+ failures.append(
65
+ Failure(
66
+ code=FailureCode.SCHEMA_VALIDATION_ERROR,
67
+ message=e.get("msg", "Schema validation error"),
68
+ severity=self.severity,
69
+ path=path_str,
70
+ metadata={
71
+ "type": e.get("type"),
72
+ "input": e.get("input"),
73
+ },
74
+ )
75
+ )
76
+ return CheckResult.failed(failures, name=self.name)
77
+
78
+
79
+ def schema_check(
80
+ schema: type[BaseModel] | TypeAdapter[Any],
81
+ *,
82
+ severity: Severity = Severity.ERROR,
83
+ name: str | None = None,
84
+ ) -> SchemaCheck:
85
+ """Returns a check validating output against a Pydantic v2 BaseModel or TypeAdapter."""
86
+ return SchemaCheck(schema, severity=severity, name=name)