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

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

2 

3from typing import Any 

4 

5import jsonschema 

6 

7 

8class ValidationError(Exception): 

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

10 

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

12 super().__init__(message) 

13 self.errors = errors or [] 

14 

15 

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} 

133 

134 

135class AIResponseValidator: 

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

137 

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

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

140 

141 Args: 

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

143 response: The parsed JSON response from the AI. 

144 

145 Returns: 

146 The validated response. 

147 

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

153 

154 schema = RESPONSE_SCHEMAS[prompt_type] 

155 

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) 

162 

163 return response 

164 

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) 

168 

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 

174 

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

183 

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

190 

191 elif error.validator == "minItems": 

192 return f"{path}: expected at least {error.validator_value} items, got {len(error.instance)}" 

193 

194 elif error.validator == "maxItems": 

195 return f"{path}: expected at most {error.validator_value} items, got {len(error.instance)}" 

196 

197 # Fallback to the default jsonschema message 

198 return f"{path}: {error.message}" 

199 

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) 

← Back to Dashboard