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

1"""AI response schema validation.""" 

2 

3from typing import Any 

4 

5 

6class ValidationError(Exception): 

7 """Raised when AI response fails validation.""" 

8 

9 def __init__(self, message: str, errors: list[str] | None = None): 

10 super().__init__(message) 

11 self.errors = errors or [] 

12 

13 

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} 

131 

132 

133class AIResponseValidator: 

134 """Validates AI responses against expected schemas.""" 

135 

136 def validate(self, prompt_type: str, response: Any) -> dict | list: 

137 """Validate an AI response against its expected schema. 

138 

139 Args: 

140 prompt_type: The type of prompt (e.g., 'recipe_remix'). 

141 response: The parsed JSON response from the AI. 

142 

143 Returns: 

144 The validated response. 

145 

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}') 

151 

152 schema = RESPONSE_SCHEMAS[prompt_type] 

153 errors = self._validate_value(response, schema, 'response') 

154 

155 if errors: 

156 raise ValidationError( 

157 f'AI response validation failed for {prompt_type}', 

158 errors=errors 

159 ) 

160 

161 return response 

162 

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. 

170 

171 Returns a list of error messages. 

172 """ 

173 errors = [] 

174 expected_type = schema.get('type') 

175 

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 

199 

200 # Type validation 

201 if expected_type == 'object': 

202 if not isinstance(value, dict): 

203 return [f'{path}: expected object, got {type(value).__name__}'] 

204 

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}"') 

210 

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 ) 

218 

219 elif expected_type == 'array': 

220 if not isinstance(value, list): 

221 return [f'{path}: expected array, got {type(value).__name__}'] 

222 

223 # Check array length constraints 

224 min_items = schema.get('minItems') 

225 max_items = schema.get('maxItems') 

226 

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 ) 

235 

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 ) 

243 

244 elif expected_type == 'string': 

245 if not isinstance(value, str): 

246 errors.append(f'{path}: expected string, got {type(value).__name__}') 

247 

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__}') 

251 

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__}') 

255 

256 elif expected_type == 'boolean': 

257 if not isinstance(value, bool): 

258 errors.append(f'{path}: expected boolean, got {type(value).__name__}') 

259 

260 return errors 

261 

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) 

← Back to Dashboard