Coverage for apps / ai / services / validator.py: 88%
50 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +0000
1"""AI response schema validation using jsonschema library."""
3import re
4from typing import Any
6import jsonschema
9class ValidationError(Exception):
10 """Raised when AI response fails validation."""
12 def __init__(self, message: str, errors: list[str] | None = None):
13 super().__init__(message)
14 self.errors = errors or []
17# Schema definitions for each prompt type
18RESPONSE_SCHEMAS = {
19 "recipe_remix": {
20 "type": "object",
21 "required": ["title", "ingredients", "instructions", "description"],
22 "properties": {
23 "title": {"type": "string"},
24 "description": {"type": "string"},
25 "ingredients": {"type": "array", "items": {"type": "string"}},
26 "instructions": {"type": "array", "items": {"type": "string"}},
27 "prep_time": {"type": "string"},
28 "cook_time": {"type": "string"},
29 "total_time": {"type": "string"},
30 "yields": {"type": "string"},
31 },
32 },
33 "serving_adjustment": {
34 "type": "object",
35 "required": ["ingredients"],
36 "properties": {
37 "ingredients": {"type": "array", "items": {"type": "string"}},
38 "instructions": {"type": "array", "items": {"type": "string"}}, # QA-031
39 "notes": {"type": "array", "items": {"type": "string"}},
40 "prep_time": {"type": ["string", "null"]}, # QA-032
41 "cook_time": {"type": ["string", "null"]}, # QA-032
42 "total_time": {"type": ["string", "null"]}, # QA-032
43 },
44 },
45 "tips_generation": {
46 "type": "array",
47 "items": {"type": "string"},
48 "minItems": 3,
49 "maxItems": 5,
50 },
51 "timer_naming": {
52 "type": "object",
53 "required": ["label"],
54 "properties": {
55 "label": {"type": "string"},
56 },
57 },
58 "remix_suggestions": {
59 "type": "array",
60 "items": {"type": "string"},
61 "minItems": 6,
62 "maxItems": 6,
63 },
64 "discover_favorites": {
65 "type": "array",
66 "items": {
67 "type": "object",
68 "required": ["search_query", "title", "description"],
69 "properties": {
70 "search_query": {"type": "string"},
71 "title": {"type": "string"},
72 "description": {"type": "string"},
73 },
74 },
75 "minItems": 1,
76 "maxItems": 5,
77 },
78 "discover_seasonal": {
79 "type": "array",
80 "items": {
81 "type": "object",
82 "required": ["search_query", "title", "description"],
83 "properties": {
84 "search_query": {"type": "string"},
85 "title": {"type": "string"},
86 "description": {"type": "string"},
87 },
88 },
89 "minItems": 1,
90 "maxItems": 5,
91 },
92 "discover_new": {
93 "type": "array",
94 "items": {
95 "type": "object",
96 "required": ["search_query", "title", "description"],
97 "properties": {
98 "search_query": {"type": "string"},
99 "title": {"type": "string"},
100 "description": {"type": "string"},
101 },
102 },
103 "minItems": 1,
104 "maxItems": 5,
105 },
106 "search_ranking": {
107 "type": "array",
108 "items": {"type": "integer"},
109 },
110 "selector_repair": {
111 "type": "object",
112 "required": ["suggestions", "confidence"],
113 "properties": {
114 "suggestions": {"type": "array", "items": {"type": "string"}},
115 "confidence": {"type": "number"},
116 },
117 },
118 "nutrition_estimate": {
119 "type": "object",
120 "required": ["calories"],
121 "properties": {
122 "calories": {"type": "string"},
123 "carbohydrateContent": {"type": "string"},
124 "proteinContent": {"type": "string"},
125 "fatContent": {"type": "string"},
126 "saturatedFatContent": {"type": "string"},
127 "unsaturatedFatContent": {"type": "string"},
128 "cholesterolContent": {"type": "string"},
129 "sodiumContent": {"type": "string"},
130 "fiberContent": {"type": "string"},
131 },
132 },
133}
136class AIResponseValidator:
137 """Validates AI responses against expected schemas using jsonschema."""
139 def validate(self, prompt_type: str, response: Any) -> dict | list:
140 """Validate an AI response against its expected schema.
142 Args:
143 prompt_type: The type of prompt (e.g., 'recipe_remix').
144 response: The parsed JSON response from the AI.
146 Returns:
147 The validated response.
149 Raises:
150 ValidationError: If the response doesn't match the schema.
151 """
152 if prompt_type not in RESPONSE_SCHEMAS:
153 raise ValidationError(f"Unknown prompt type: {prompt_type}")
155 schema = RESPONSE_SCHEMAS[prompt_type]
157 try:
158 jsonschema.validate(response, schema)
159 except jsonschema.ValidationError as e:
160 # Convert jsonschema error to human-readable format
161 errors = [self._format_error(e)]
162 raise ValidationError(f"AI response validation failed for {prompt_type}", errors=errors)
164 return response
166 def _format_error(self, error: jsonschema.ValidationError) -> str:
167 """Convert a jsonschema ValidationError to a human-readable message."""
168 path = "response" + "".join(f"[{p}]" if isinstance(p, int) else f".{p}" for p in error.absolute_path)
170 formatters = {
171 "required": self._format_required,
172 "type": self._format_type,
173 "minItems": self._format_min_items,
174 "maxItems": self._format_max_items,
175 }
177 formatter = formatters.get(error.validator)
178 if formatter:
179 return formatter(path, error)
180 # Fallback to the default jsonschema message
181 return f"{path}: {error.message}"
183 @staticmethod
184 def _format_required(path: str, error: jsonschema.ValidationError) -> str:
185 """Format a 'required' validation error."""
186 match = re.search(r"'([^']+)' is a required property", error.message)
187 if match:
188 return f'{path}: missing required field "{match.group(1)}"'
189 # Fallback: find which field is actually missing
190 for field in error.validator_value:
191 if field not in error.instance:
192 return f'{path}: missing required field "{field}"'
193 return f"{path}: {error.message}"
195 @staticmethod
196 def _format_type(path: str, error: jsonschema.ValidationError) -> str:
197 """Format a 'type' validation error."""
198 expected = error.validator_value
199 if isinstance(expected, list):
200 expected = " or ".join(expected)
201 actual = type(error.instance).__name__
202 return f"{path}: expected {expected}, got {actual}"
204 @staticmethod
205 def _format_min_items(path: str, error: jsonschema.ValidationError) -> str:
206 """Format a 'minItems' validation error."""
207 return f"{path}: expected at least {error.validator_value} items, got {len(error.instance)}"
209 @staticmethod
210 def _format_max_items(path: str, error: jsonschema.ValidationError) -> str:
211 """Format a 'maxItems' validation error."""
212 return f"{path}: expected at most {error.validator_value} items, got {len(error.instance)}"
214 def get_schema(self, prompt_type: str) -> dict | None:
215 """Get the schema for a prompt type."""
216 return RESPONSE_SCHEMAS.get(prompt_type)