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

1"""AI response schema validation using jsonschema library.""" 

2 

3import re 

4from typing import Any 

5 

6import jsonschema 

7 

8 

9class ValidationError(Exception): 

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

11 

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

13 super().__init__(message) 

14 self.errors = errors or [] 

15 

16 

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} 

134 

135 

136class AIResponseValidator: 

137 """Validates AI responses against expected schemas using jsonschema.""" 

138 

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

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

141 

142 Args: 

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

144 response: The parsed JSON response from the AI. 

145 

146 Returns: 

147 The validated response. 

148 

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

154 

155 schema = RESPONSE_SCHEMAS[prompt_type] 

156 

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) 

163 

164 return response 

165 

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) 

169 

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 } 

176 

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

182 

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

194 

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

203 

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

208 

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

213 

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) 

← Back to Dashboard