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