Coverage for apps / ai / services / validator.py: 10%
79 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +0000
1"""AI response schema validation."""
3from typing import Any
6class ValidationError(Exception):
7 """Raised when AI response fails validation."""
9 def __init__(self, message: str, errors: list[str] | None = None):
10 super().__init__(message)
11 self.errors = errors or []
14# Schema definitions for each prompt type
15RESPONSE_SCHEMAS = {
16 'recipe_remix': {
17 'type': 'object',
18 'required': ['title', 'ingredients', 'instructions', 'description'],
19 'properties': {
20 'title': {'type': 'string'},
21 'description': {'type': 'string'},
22 'ingredients': {'type': 'array', 'items': {'type': 'string'}},
23 'instructions': {'type': 'array', 'items': {'type': 'string'}},
24 'prep_time': {'type': 'string'},
25 'cook_time': {'type': 'string'},
26 'total_time': {'type': 'string'},
27 'yields': {'type': 'string'},
28 },
29 },
30 'serving_adjustment': {
31 'type': 'object',
32 'required': ['ingredients'],
33 'properties': {
34 'ingredients': {'type': 'array', 'items': {'type': 'string'}},
35 'instructions': {'type': 'array', 'items': {'type': 'string'}}, # QA-031
36 'notes': {'type': 'array', 'items': {'type': 'string'}},
37 'prep_time': {'type': ['string', 'null']}, # QA-032
38 'cook_time': {'type': ['string', 'null']}, # QA-032
39 'total_time': {'type': ['string', 'null']}, # QA-032
40 },
41 },
42 'tips_generation': {
43 'type': 'array',
44 'items': {'type': 'string'},
45 'minItems': 3,
46 'maxItems': 5,
47 },
48 'timer_naming': {
49 'type': 'object',
50 'required': ['label'],
51 'properties': {
52 'label': {'type': 'string'},
53 },
54 },
55 'remix_suggestions': {
56 'type': 'array',
57 'items': {'type': 'string'},
58 'minItems': 6,
59 'maxItems': 6,
60 },
61 'discover_favorites': {
62 'type': 'array',
63 'items': {
64 'type': 'object',
65 'required': ['search_query', 'title', 'description'],
66 'properties': {
67 'search_query': {'type': 'string'},
68 'title': {'type': 'string'},
69 'description': {'type': 'string'},
70 },
71 },
72 'minItems': 1,
73 'maxItems': 5,
74 },
75 'discover_seasonal': {
76 'type': 'array',
77 'items': {
78 'type': 'object',
79 'required': ['search_query', 'title', 'description'],
80 'properties': {
81 'search_query': {'type': 'string'},
82 'title': {'type': 'string'},
83 'description': {'type': 'string'},
84 },
85 },
86 'minItems': 1,
87 'maxItems': 5,
88 },
89 'discover_new': {
90 'type': 'array',
91 'items': {
92 'type': 'object',
93 'required': ['search_query', 'title', 'description'],
94 'properties': {
95 'search_query': {'type': 'string'},
96 'title': {'type': 'string'},
97 'description': {'type': 'string'},
98 },
99 },
100 'minItems': 1,
101 'maxItems': 5,
102 },
103 'search_ranking': {
104 'type': 'array',
105 'items': {'type': 'integer'},
106 },
107 'selector_repair': {
108 'type': 'object',
109 'required': ['suggestions', 'confidence'],
110 'properties': {
111 'suggestions': {'type': 'array', 'items': {'type': 'string'}},
112 'confidence': {'type': 'number'},
113 },
114 },
115 'nutrition_estimate': {
116 'type': 'object',
117 'required': ['calories'],
118 'properties': {
119 'calories': {'type': 'string'},
120 'carbohydrateContent': {'type': 'string'},
121 'proteinContent': {'type': 'string'},
122 'fatContent': {'type': 'string'},
123 'saturatedFatContent': {'type': 'string'},
124 'unsaturatedFatContent': {'type': 'string'},
125 'cholesterolContent': {'type': 'string'},
126 'sodiumContent': {'type': 'string'},
127 'fiberContent': {'type': 'string'},
128 },
129 },
130}
133class AIResponseValidator:
134 """Validates AI responses against expected schemas."""
136 def validate(self, prompt_type: str, response: Any) -> dict | list:
137 """Validate an AI response against its expected schema.
139 Args:
140 prompt_type: The type of prompt (e.g., 'recipe_remix').
141 response: The parsed JSON response from the AI.
143 Returns:
144 The validated response.
146 Raises:
147 ValidationError: If the response doesn't match the schema.
148 """
149 if prompt_type not in RESPONSE_SCHEMAS:
150 raise ValidationError(f'Unknown prompt type: {prompt_type}')
152 schema = RESPONSE_SCHEMAS[prompt_type]
153 errors = self._validate_value(response, schema, 'response')
155 if errors:
156 raise ValidationError(
157 f'AI response validation failed for {prompt_type}',
158 errors=errors
159 )
161 return response
163 def _validate_value(
164 self,
165 value: Any,
166 schema: dict,
167 path: str
168 ) -> list[str]:
169 """Validate a value against a schema definition.
171 Returns a list of error messages.
172 """
173 errors = []
174 expected_type = schema.get('type')
176 # Handle union types (e.g., ['string', 'null'])
177 if isinstance(expected_type, list):
178 valid = False
179 for t in expected_type:
180 if t == 'null' and value is None:
181 valid = True
182 break
183 elif t == 'string' and isinstance(value, str):
184 valid = True
185 break
186 elif t == 'integer' and isinstance(value, int) and not isinstance(value, bool):
187 valid = True
188 break
189 elif t == 'number' and isinstance(value, (int, float)) and not isinstance(value, bool):
190 valid = True
191 break
192 elif t == 'boolean' and isinstance(value, bool):
193 valid = True
194 break
195 if not valid:
196 types_str = ' or '.join(expected_type)
197 errors.append(f'{path}: expected {types_str}, got {type(value).__name__}')
198 return errors
200 # Type validation
201 if expected_type == 'object':
202 if not isinstance(value, dict):
203 return [f'{path}: expected object, got {type(value).__name__}']
205 # Check required fields
206 required = schema.get('required', [])
207 for field in required:
208 if field not in value:
209 errors.append(f'{path}: missing required field "{field}"')
211 # Validate properties if defined
212 properties = schema.get('properties', {})
213 for key, val in value.items():
214 if key in properties:
215 errors.extend(
216 self._validate_value(val, properties[key], f'{path}.{key}')
217 )
219 elif expected_type == 'array':
220 if not isinstance(value, list):
221 return [f'{path}: expected array, got {type(value).__name__}']
223 # Check array length constraints
224 min_items = schema.get('minItems')
225 max_items = schema.get('maxItems')
227 if min_items is not None and len(value) < min_items:
228 errors.append(
229 f'{path}: expected at least {min_items} items, got {len(value)}'
230 )
231 if max_items is not None and len(value) > max_items:
232 errors.append(
233 f'{path}: expected at most {max_items} items, got {len(value)}'
234 )
236 # Validate items
237 items_schema = schema.get('items')
238 if items_schema:
239 for i, item in enumerate(value):
240 errors.extend(
241 self._validate_value(item, items_schema, f'{path}[{i}]')
242 )
244 elif expected_type == 'string':
245 if not isinstance(value, str):
246 errors.append(f'{path}: expected string, got {type(value).__name__}')
248 elif expected_type == 'integer':
249 if not isinstance(value, int) or isinstance(value, bool):
250 errors.append(f'{path}: expected integer, got {type(value).__name__}')
252 elif expected_type == 'number':
253 if not isinstance(value, (int, float)) or isinstance(value, bool):
254 errors.append(f'{path}: expected number, got {type(value).__name__}')
256 elif expected_type == 'boolean':
257 if not isinstance(value, bool):
258 errors.append(f'{path}: expected boolean, got {type(value).__name__}')
260 return errors
262 def get_schema(self, prompt_type: str) -> dict | None:
263 """Get the schema for a prompt type."""
264 return RESPONSE_SCHEMAS.get(prompt_type)