aicert 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.
- aicert/__init__.py +3 -0
- aicert/__main__.py +6 -0
- aicert/artifacts.py +104 -0
- aicert/cli.py +1423 -0
- aicert/config.py +193 -0
- aicert/doctor.py +366 -0
- aicert/hashing.py +28 -0
- aicert/metrics.py +305 -0
- aicert/providers/__init__.py +13 -0
- aicert/providers/anthropic.py +182 -0
- aicert/providers/base.py +36 -0
- aicert/providers/openai.py +153 -0
- aicert/providers/openai_compatible.py +152 -0
- aicert/runner.py +620 -0
- aicert/templating.py +83 -0
- aicert/validation.py +322 -0
- aicert-0.1.0.dist-info/METADATA +306 -0
- aicert-0.1.0.dist-info/RECORD +22 -0
- aicert-0.1.0.dist-info/WHEEL +5 -0
- aicert-0.1.0.dist-info/entry_points.txt +2 -0
- aicert-0.1.0.dist-info/licenses/LICENSE +21 -0
- aicert-0.1.0.dist-info/top_level.txt +1 -0
aicert/validation.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Validation utilities for aicert."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict, List, Optional, Union
|
|
7
|
+
|
|
8
|
+
from jsonschema import ValidationError, validate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ValidationResult:
|
|
13
|
+
"""Result of validating LLM output."""
|
|
14
|
+
ok_json: bool = False
|
|
15
|
+
ok_schema: bool = False
|
|
16
|
+
extra_keys: List[str] = field(default_factory=list)
|
|
17
|
+
error: Optional[str] = None
|
|
18
|
+
parsed: Optional[Any] = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract_json_from_block(text: str) -> Optional[str]:
|
|
22
|
+
"""Extract JSON from a ```json ... ``` block."""
|
|
23
|
+
# Match ```json at start of line, followed by any content, ending with ```
|
|
24
|
+
pattern = r'```json\s*\n(.*?)\n```'
|
|
25
|
+
match = re.search(pattern, text, re.DOTALL)
|
|
26
|
+
if match:
|
|
27
|
+
return match.group(1).strip()
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def extract_json_basic(text: str) -> Optional[str]:
|
|
32
|
+
"""Basic extraction of first JSON object/array from text."""
|
|
33
|
+
# Find the first { or [ that starts a JSON object/array
|
|
34
|
+
text = text.strip()
|
|
35
|
+
|
|
36
|
+
# Find first { or [ at the start or after whitespace
|
|
37
|
+
start_idx = -1
|
|
38
|
+
for i, char in enumerate(text):
|
|
39
|
+
if char == '{':
|
|
40
|
+
start_idx = i
|
|
41
|
+
break
|
|
42
|
+
elif char == '[':
|
|
43
|
+
start_idx = i
|
|
44
|
+
break
|
|
45
|
+
|
|
46
|
+
if start_idx == -1:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
# Try to parse from this position
|
|
50
|
+
# We'll try to find the matching closing bracket
|
|
51
|
+
end_idx = -1
|
|
52
|
+
stack = []
|
|
53
|
+
for i in range(start_idx, len(text)):
|
|
54
|
+
char = text[i]
|
|
55
|
+
if char == '{' or char == '[':
|
|
56
|
+
stack.append(char)
|
|
57
|
+
elif char == '}' or char == ']':
|
|
58
|
+
if not stack:
|
|
59
|
+
continue
|
|
60
|
+
opening = stack.pop()
|
|
61
|
+
if (opening == '{' and char == '}') or (opening == '[' and char == ']'):
|
|
62
|
+
if not stack:
|
|
63
|
+
end_idx = i + 1
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
if end_idx > 0:
|
|
67
|
+
return text[start_idx:end_idx]
|
|
68
|
+
|
|
69
|
+
# Fallback: try to find any closing bracket at the end
|
|
70
|
+
# This is a simple heuristic
|
|
71
|
+
if start_idx == 0:
|
|
72
|
+
# Try to find the last } or ]
|
|
73
|
+
for i in range(len(text) - 1, start_idx - 1, -1):
|
|
74
|
+
if text[i] in '}]':
|
|
75
|
+
return text[start_idx:i + 1]
|
|
76
|
+
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_output(text: str, extract_json: bool) -> tuple[bool, Any | None, str | None]:
|
|
81
|
+
"""
|
|
82
|
+
Parse output text to extract JSON.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
text: The raw output text from the LLM
|
|
86
|
+
extract_json: Whether to try extracting JSON from blocks or text
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Tuple of (ok_json: bool, parsed: Any|None, error: str|None)
|
|
90
|
+
"""
|
|
91
|
+
if extract_json:
|
|
92
|
+
# Try to extract from ```json block first
|
|
93
|
+
json_text = extract_json_from_block(text)
|
|
94
|
+
if json_text is None:
|
|
95
|
+
# Fall back to basic extraction
|
|
96
|
+
json_text = extract_json_basic(text)
|
|
97
|
+
|
|
98
|
+
if json_text is None:
|
|
99
|
+
return False, None, "No JSON found in output"
|
|
100
|
+
else:
|
|
101
|
+
# Use entire text as JSON
|
|
102
|
+
json_text = text
|
|
103
|
+
|
|
104
|
+
if json_text is None:
|
|
105
|
+
return False, None, "No JSON found in output"
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
parsed = json.loads(json_text)
|
|
109
|
+
return True, parsed, None
|
|
110
|
+
except json.JSONDecodeError as e:
|
|
111
|
+
return False, None, f"Invalid JSON: {str(e)}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def find_extra_keys(obj: Any, schema: Dict[str, Any], parent_path: str = "") -> List[str]:
|
|
115
|
+
"""
|
|
116
|
+
Find extra keys in an object that are not in the schema.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
obj: The parsed JSON object
|
|
120
|
+
schema: The JSON schema
|
|
121
|
+
parent_path: Path for error reporting
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
List of extra key names
|
|
125
|
+
"""
|
|
126
|
+
extra_keys: List[str] = []
|
|
127
|
+
|
|
128
|
+
if not isinstance(obj, dict) or not isinstance(schema, dict):
|
|
129
|
+
return extra_keys
|
|
130
|
+
|
|
131
|
+
# Check if this object has additionalProperties: false
|
|
132
|
+
additional_properties = schema.get("additionalProperties", None)
|
|
133
|
+
is_strict = additional_properties is False
|
|
134
|
+
|
|
135
|
+
# Get defined properties
|
|
136
|
+
properties = schema.get("properties", {})
|
|
137
|
+
required_fields = schema.get("required", [])
|
|
138
|
+
|
|
139
|
+
for key, value in obj.items():
|
|
140
|
+
key_path = f"{parent_path}.{key}" if parent_path else key
|
|
141
|
+
|
|
142
|
+
if key in properties:
|
|
143
|
+
# Key is defined, check nested schema
|
|
144
|
+
prop_schema = properties[key]
|
|
145
|
+
if isinstance(prop_schema, dict):
|
|
146
|
+
# Recursively check nested objects
|
|
147
|
+
nested_extra = find_extra_keys(value, prop_schema, key_path)
|
|
148
|
+
extra_keys.extend(nested_extra)
|
|
149
|
+
elif is_strict:
|
|
150
|
+
# Key not defined and additionalProperties is false
|
|
151
|
+
extra_keys.append(key_path)
|
|
152
|
+
|
|
153
|
+
return extra_keys
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def remove_additional_properties(schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
157
|
+
"""Remove additionalProperties from schema and nested schemas."""
|
|
158
|
+
if not isinstance(schema, dict):
|
|
159
|
+
return schema
|
|
160
|
+
|
|
161
|
+
result = {}
|
|
162
|
+
for key, value in schema.items():
|
|
163
|
+
if key == "additionalProperties":
|
|
164
|
+
continue
|
|
165
|
+
elif key == "properties" and isinstance(value, dict):
|
|
166
|
+
result[key] = {k: remove_additional_properties(v) for k, v in value.items()}
|
|
167
|
+
elif isinstance(value, dict):
|
|
168
|
+
result[key] = remove_additional_properties(value)
|
|
169
|
+
else:
|
|
170
|
+
result[key] = value
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def validate_schema(parsed: Any, schema: Dict[str, Any], allow_extra_keys: bool = True) -> tuple[bool, str | None, List[str]]:
|
|
175
|
+
"""
|
|
176
|
+
Validate parsed JSON against a schema.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
parsed: The parsed JSON object
|
|
180
|
+
schema: The JSON schema
|
|
181
|
+
allow_extra_keys: If False, enforce that no extra keys exist
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Tuple of (ok_schema: bool, error: str|None, extra_keys: List[str])
|
|
185
|
+
"""
|
|
186
|
+
extra_keys: List[str] = []
|
|
187
|
+
|
|
188
|
+
if not isinstance(schema, dict):
|
|
189
|
+
return True, None, []
|
|
190
|
+
|
|
191
|
+
# Check for strict mode - extra key enforcement
|
|
192
|
+
if not allow_extra_keys:
|
|
193
|
+
extra_keys = find_extra_keys(parsed, schema)
|
|
194
|
+
if extra_keys:
|
|
195
|
+
return False, f"Extra keys not allowed: {', '.join(extra_keys)}", extra_keys
|
|
196
|
+
|
|
197
|
+
# If allow_extra_keys=True, remove additionalProperties restriction
|
|
198
|
+
# before validation to allow extra keys
|
|
199
|
+
validation_schema = schema if allow_extra_keys else schema
|
|
200
|
+
if allow_extra_keys and schema.get("additionalProperties") is False:
|
|
201
|
+
validation_schema = remove_additional_properties(schema)
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
validate(instance=parsed, schema=validation_schema)
|
|
205
|
+
return True, None, extra_keys
|
|
206
|
+
except ValidationError as e:
|
|
207
|
+
return False, f"Schema validation error: {e.message}", extra_keys
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def validate_output(
|
|
211
|
+
text: str,
|
|
212
|
+
schema: Dict[str, Any],
|
|
213
|
+
extract_json: bool,
|
|
214
|
+
allow_extra_keys: bool = True
|
|
215
|
+
) -> ValidationResult:
|
|
216
|
+
"""
|
|
217
|
+
Validate LLM output against a schema.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
text: The raw output text from the LLM
|
|
221
|
+
schema: The JSON schema to validate against
|
|
222
|
+
extract_json: Whether to try extracting JSON from blocks
|
|
223
|
+
allow_extra_keys: If False, enforce that no extra keys exist
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
ValidationResult with ok_json, ok_schema, extra_keys, and error
|
|
227
|
+
"""
|
|
228
|
+
# Step 1: Parse JSON from output
|
|
229
|
+
ok_json, parsed, error = parse_output(text, extract_json)
|
|
230
|
+
|
|
231
|
+
if not ok_json:
|
|
232
|
+
return ValidationResult(
|
|
233
|
+
ok_json=False,
|
|
234
|
+
ok_schema=False,
|
|
235
|
+
error=error
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Step 2: Validate against schema
|
|
239
|
+
ok_schema, schema_error, extra_keys = validate_schema(parsed, schema, allow_extra_keys)
|
|
240
|
+
|
|
241
|
+
return ValidationResult(
|
|
242
|
+
ok_json=True,
|
|
243
|
+
ok_schema=ok_schema,
|
|
244
|
+
extra_keys=extra_keys,
|
|
245
|
+
error=schema_error,
|
|
246
|
+
parsed=parsed
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# Keep old functions for backward compatibility
|
|
251
|
+
|
|
252
|
+
class SchemaLoadError(Exception):
|
|
253
|
+
"""Error raised when schema loading fails."""
|
|
254
|
+
|
|
255
|
+
def __init__(self, message: str, schema_path: str = None, hint: str = None):
|
|
256
|
+
self.message = message
|
|
257
|
+
self.schema_path = schema_path
|
|
258
|
+
self.hint = hint
|
|
259
|
+
super().__init__(self._format_message())
|
|
260
|
+
|
|
261
|
+
def _format_message(self) -> str:
|
|
262
|
+
"""Format the error message with context."""
|
|
263
|
+
parts = []
|
|
264
|
+
if self.schema_path:
|
|
265
|
+
parts.append(f"[bold red]Schema file: {self.schema_path}[/bold red]")
|
|
266
|
+
parts.append(f"[bold red]Error:[/bold red] {self.message}")
|
|
267
|
+
if self.hint:
|
|
268
|
+
parts.append(f"[bold yellow]Hint:[/bold yellow] {self.hint}")
|
|
269
|
+
return "\n".join(parts)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def load_json_schema(schema_path: str) -> Dict[str, Any]:
|
|
273
|
+
"""Load a JSON schema from a file with better error messages."""
|
|
274
|
+
import yaml
|
|
275
|
+
from pathlib import Path
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
with open(schema_path, "r") as f:
|
|
279
|
+
schema = yaml.safe_load(f)
|
|
280
|
+
except FileNotFoundError:
|
|
281
|
+
raise SchemaLoadError(
|
|
282
|
+
message=f"Schema file not found: {schema_path}",
|
|
283
|
+
schema_path=schema_path,
|
|
284
|
+
hint="Make sure the path is correct and the file exists."
|
|
285
|
+
)
|
|
286
|
+
except yaml.YAMLError as e:
|
|
287
|
+
raise SchemaLoadError(
|
|
288
|
+
message=f"Invalid YAML in schema file: {e}",
|
|
289
|
+
schema_path=schema_path,
|
|
290
|
+
hint="Check for syntax errors like incorrect indentation."
|
|
291
|
+
)
|
|
292
|
+
except Exception as e:
|
|
293
|
+
raise SchemaLoadError(
|
|
294
|
+
message=f"Failed to load schema: {e}",
|
|
295
|
+
schema_path=schema_path,
|
|
296
|
+
hint="Make sure the file is valid JSON or YAML."
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Validate that it's a dict (could be JSON or YAML parsed)
|
|
300
|
+
if not isinstance(schema, dict):
|
|
301
|
+
raise SchemaLoadError(
|
|
302
|
+
message=f"Schema must be a dictionary/object, got {type(schema).__name__}",
|
|
303
|
+
schema_path=schema_path,
|
|
304
|
+
hint="The schema file should contain a JSON object with '$schema', 'type', 'properties', etc."
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
return schema
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def validate_json(data: Any, schema: Dict[str, Any]) -> bool:
|
|
311
|
+
"""Validate JSON data against a schema."""
|
|
312
|
+
validate(instance=data, schema=schema)
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def validate_json_file(data_path: str, schema_path: str) -> bool:
|
|
317
|
+
"""Validate a JSON file against a schema."""
|
|
318
|
+
import yaml
|
|
319
|
+
with open(data_path, "r") as f:
|
|
320
|
+
data = yaml.safe_load(f)
|
|
321
|
+
schema = load_json_schema(schema_path)
|
|
322
|
+
return validate_json(data, schema)
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aicert
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CI for LLM JSON outputs - Validate, test, and measure LLM outputs
|
|
5
|
+
Author: aicert contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: llm,cli,validation,testing,json
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: typer>=0.21.0
|
|
19
|
+
Requires-Dist: pydantic>=2.5.0
|
|
20
|
+
Requires-Dist: pyyaml>=6.0.1
|
|
21
|
+
Requires-Dist: jsonschema>=4.20.0
|
|
22
|
+
Requires-Dist: httpx>=0.25.0
|
|
23
|
+
Requires-Dist: rich>=14.0.0
|
|
24
|
+
Requires-Dist: cryptography>=41.0.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
28
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# aicert
|
|
33
|
+
|
|
34
|
+
**Reliability tooling for structured LLM outputs.**
|
|
35
|
+
|
|
36
|
+
Measure, validate, and enforce JSON stability in CI.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Why aicert?
|
|
41
|
+
|
|
42
|
+
LLMs are probabilistic systems.
|
|
43
|
+
|
|
44
|
+
If your application depends on structured JSON generated by an LLM, that output becomes an implicit contract within your system. Model updates, prompt changes, or configuration tweaks can cause that contract to drift — often without immediate visibility.
|
|
45
|
+
|
|
46
|
+
Traditional tests typically validate a single run. They do not measure variability across repeated executions.
|
|
47
|
+
|
|
48
|
+
Aicert treats LLM output like any other production dependency:
|
|
49
|
+
|
|
50
|
+
* Validate against a JSON Schema
|
|
51
|
+
* Measure stability across repeated runs
|
|
52
|
+
* Track latency and variability
|
|
53
|
+
* Enforce reliability thresholds in CI
|
|
54
|
+
|
|
55
|
+
Instead of assuming structured outputs remain stable, you can verify that they do.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## What It Enables
|
|
60
|
+
|
|
61
|
+
Aicert helps you:
|
|
62
|
+
|
|
63
|
+
* Detect schema breakage before deployment
|
|
64
|
+
* Quantify output variability across runs
|
|
65
|
+
* Catch regressions caused by model or prompt changes
|
|
66
|
+
* Enforce reliability standards automatically in CI
|
|
67
|
+
* Treat prompts and structured outputs as testable infrastructure
|
|
68
|
+
|
|
69
|
+
It converts non-deterministic behavior into measurable signals.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Who It’s For
|
|
74
|
+
|
|
75
|
+
Aicert is designed for teams shipping structured LLM outputs into production systems:
|
|
76
|
+
|
|
77
|
+
* Backend APIs powered by LLMs
|
|
78
|
+
* Extraction and classification pipelines
|
|
79
|
+
* Decision-support systems
|
|
80
|
+
* Any workflow where JSON output drives downstream logic
|
|
81
|
+
|
|
82
|
+
If structured LLM output is part of your system contract, Aicert provides guardrails.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Installation
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install aicert
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
For development:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install -e .
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## What It Measures
|
|
101
|
+
|
|
102
|
+
* **Compliance** — % of outputs matching JSON Schema
|
|
103
|
+
* **Stability** — % of identical outputs across repeated runs
|
|
104
|
+
* **Latency** — P50 / P95 response times
|
|
105
|
+
* **Similarity** — Structural or semantic similarity
|
|
106
|
+
* **CI Gating** — Threshold-based pass/fail enforcement
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Basic Workflow
|
|
111
|
+
|
|
112
|
+
### 1. Create `aicert.yaml`
|
|
113
|
+
|
|
114
|
+
```yaml
|
|
115
|
+
project: my-project
|
|
116
|
+
|
|
117
|
+
providers:
|
|
118
|
+
- id: openai-gpt4
|
|
119
|
+
provider: openai
|
|
120
|
+
model: gpt-4
|
|
121
|
+
temperature: 0.1
|
|
122
|
+
|
|
123
|
+
prompt_file: prompt.txt
|
|
124
|
+
cases_file: cases.jsonl
|
|
125
|
+
schema_file: schema.json
|
|
126
|
+
|
|
127
|
+
runs: 50
|
|
128
|
+
concurrency: 10
|
|
129
|
+
timeout_s: 30
|
|
130
|
+
|
|
131
|
+
validation:
|
|
132
|
+
extract_json: true
|
|
133
|
+
allow_extra_keys: false
|
|
134
|
+
|
|
135
|
+
thresholds:
|
|
136
|
+
min_stability: 85
|
|
137
|
+
min_compliance: 95
|
|
138
|
+
p95_latency_ms: 5000
|
|
139
|
+
|
|
140
|
+
ci:
|
|
141
|
+
runs: 10
|
|
142
|
+
save_on_fail: true
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
### 2. Run Stability Tests
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
aicert stability aicert.yaml
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
### 3. Enforce in CI
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
aicert ci aicert.yaml
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Non-zero exit codes indicate threshold failures.
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Commands
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
aicert init
|
|
169
|
+
aicert doctor aicert.yaml
|
|
170
|
+
aicert run aicert.yaml
|
|
171
|
+
aicert stability aicert.yaml
|
|
172
|
+
aicert compare aicert.yaml
|
|
173
|
+
aicert ci aicert.yaml
|
|
174
|
+
aicert diff <run_a> <run_b>
|
|
175
|
+
aicert report <run_dir>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
# Aicert Pro
|
|
181
|
+
|
|
182
|
+
Aicert Pro turns measurement into enforcement.
|
|
183
|
+
|
|
184
|
+
The core package tells you how your LLM behaves.
|
|
185
|
+
Pro defines what behavior is acceptable — and blocks regressions automatically.
|
|
186
|
+
|
|
187
|
+
When structured outputs drive production systems, “informational metrics” are not enough.
|
|
188
|
+
You need a reliability boundary.
|
|
189
|
+
|
|
190
|
+
Aicert Pro adds:
|
|
191
|
+
|
|
192
|
+
* Baseline locking — capture a known-good state
|
|
193
|
+
* Regression enforcement — fail CI when stability or compliance degrades
|
|
194
|
+
* Schema and prompt drift detection — detect structural changes immediately
|
|
195
|
+
* Cost regression limits — prevent silent cost creep
|
|
196
|
+
* Policy-backed CI gating — enforce standards across commits
|
|
197
|
+
|
|
198
|
+
Pro is designed for teams that treat LLM output as production infrastructure.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## What Changes With Pro?
|
|
203
|
+
|
|
204
|
+
Without Pro:
|
|
205
|
+
|
|
206
|
+
You measure stability.
|
|
207
|
+
You review results manually.
|
|
208
|
+
Regressions can slip through if thresholds are ignored.
|
|
209
|
+
|
|
210
|
+
With Pro:
|
|
211
|
+
|
|
212
|
+
Known-good behavior is locked.
|
|
213
|
+
Deviations are compared automatically.
|
|
214
|
+
CI fails when output contracts drift.
|
|
215
|
+
Reliability becomes enforceable policy.
|
|
216
|
+
Pro moves you from observation to control.
|
|
217
|
+
|
|
218
|
+
## Installing Aicert Pro
|
|
219
|
+
|
|
220
|
+
After purchasing a subscription:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
pip install aicert-pro
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Set your license key:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
export AICERT_LICENSE="your_signed_license_key"
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Verify:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
aicert-pro license verify
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Use baseline enforcement:
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
aicert-pro baseline save aicert.yaml
|
|
242
|
+
aicert-pro baseline check aicert.yaml
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## Licensing
|
|
248
|
+
|
|
249
|
+
Aicert Pro uses signed offline license keys.
|
|
250
|
+
|
|
251
|
+
* No SaaS dependency
|
|
252
|
+
* No account login
|
|
253
|
+
* CI-friendly
|
|
254
|
+
* Time-limited per subscription
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Pricing
|
|
259
|
+
|
|
260
|
+
* **$29 / month**
|
|
261
|
+
* **$290 / year (2 months free)**
|
|
262
|
+
|
|
263
|
+
Purchase:
|
|
264
|
+
[https://mfifth.github.io/aicert/](https://mfifth.github.io/aicert/)
|
|
265
|
+
|
|
266
|
+
Questions:
|
|
267
|
+
[mfifth@gmail.com](mailto:mfifth@gmail.com)
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## GitHub Actions Example
|
|
272
|
+
|
|
273
|
+
```yaml
|
|
274
|
+
- run: pip install aicert
|
|
275
|
+
- run: aicert ci aicert.yaml
|
|
276
|
+
env:
|
|
277
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Pro enforcement in CI:
|
|
281
|
+
|
|
282
|
+
```yaml
|
|
283
|
+
- run: pip install aicert-pro
|
|
284
|
+
- run: aicert-pro baseline check aicert.yaml
|
|
285
|
+
env:
|
|
286
|
+
AICERT_LICENSE: ${{ secrets.AICERT_LICENSE }}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## Exit Codes
|
|
292
|
+
|
|
293
|
+
| Code | Meaning |
|
|
294
|
+
| ---- | ------------------------ |
|
|
295
|
+
| 0 | Success |
|
|
296
|
+
| 2 | Threshold failure |
|
|
297
|
+
| 3 | Config/schema error |
|
|
298
|
+
| 4 | Provider/auth error |
|
|
299
|
+
| 5 | License error (Pro only) |
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## License
|
|
304
|
+
|
|
305
|
+
Core package (`aicert`) is MIT licensed.
|
|
306
|
+
Aicert Pro is commercial software.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
aicert/__init__.py,sha256=Wr15C3CQxuxepOgzREU4WQl9TkSRSNTMHTslmrV73_Y,63
|
|
2
|
+
aicert/__main__.py,sha256=4P50AkHMqohCf03NwoGtALlQFF0w0KZAu-braeWXxLY,116
|
|
3
|
+
aicert/artifacts.py,sha256=lbSkeIYR3XUPLTQBI8VqY6O5iMBSJGpdpaYmA8NC88Q,3031
|
|
4
|
+
aicert/cli.py,sha256=7nL9CCyb_PoAvdAS7kF5Mi0GbwK8_EtTqll9Wvq2iPY,56603
|
|
5
|
+
aicert/config.py,sha256=EeBHZcH_aMKvAgm7aojJ-1DccfkHte5bQpGYLbMZihk,7188
|
|
6
|
+
aicert/doctor.py,sha256=Ig1SwOrXbDV_pEFE2UpjzNnbyVfSLeWuGhjCv1QxoZU,12569
|
|
7
|
+
aicert/hashing.py,sha256=uqQmg2qp7Lfb3N3EYqpfw6bBDvletks8ZoHHKmJHcUU,705
|
|
8
|
+
aicert/metrics.py,sha256=sh-yMk7qrH_3QC_IZEr-PASnaYi43sHql3ueCNym8JA,11152
|
|
9
|
+
aicert/runner.py,sha256=l3BHDpypQKTiHm2c3B_ClUO_P2kNEOCz0ncU3eVIDFI,20722
|
|
10
|
+
aicert/templating.py,sha256=EXqjw69VjHKTNATp-_sXycs6HABraMZnq3bRrKiA0lY,2472
|
|
11
|
+
aicert/validation.py,sha256=m2Qx7vyNxUiajzFXW2UDjAWl7qqihbvaPLifviV1fiY,10371
|
|
12
|
+
aicert/providers/__init__.py,sha256=z4JPvhJkVsz7gW8gf4b4lUgvMg9cK6rj78p7WhA1v68,374
|
|
13
|
+
aicert/providers/anthropic.py,sha256=FUlZZ6LP_o9GP5Qbk0sVXEiBtf3Iz8Fj0gvqnfXfelc,6381
|
|
14
|
+
aicert/providers/base.py,sha256=pp3mGD6EY8AMRgOEOfsB1ZXXZHAGxLEufUWtfMX3-tE,966
|
|
15
|
+
aicert/providers/openai.py,sha256=aQCqGUxEWjOUyUpu9ceCaqNUYig1dRWqWiF0Qu3z_iI,5272
|
|
16
|
+
aicert/providers/openai_compatible.py,sha256=o9BWUcNQUmWJljWIqXx5LH0Q1ysl7tW52I9Wok66FQA,5373
|
|
17
|
+
aicert-0.1.0.dist-info/licenses/LICENSE,sha256=m8Wu9ollES96-xdQn31baYdbK4xIDfHR8S0lCQt0Aow,1068
|
|
18
|
+
aicert-0.1.0.dist-info/METADATA,sha256=uIgMchOoGJKD8shY0W1I-yAsafy9WtRd3GlGygvw6dc,6413
|
|
19
|
+
aicert-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
20
|
+
aicert-0.1.0.dist-info/entry_points.txt,sha256=iCeFwGlD159K_db1wkq5ExrF6RTklQYVioQC7ZtnRoo,42
|
|
21
|
+
aicert-0.1.0.dist-info/top_level.txt,sha256=RnxinJ8aabE4rxqgfQ68ZutBZKUC2NRDgMBVfciv4dQ,7
|
|
22
|
+
aicert-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matt Quinto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aicert
|