Coverage for haystack/components/validators/json_schema.py: 89%

71 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 13:53 +0000

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import json 

6from typing import Any 

7 

8from jsonschema import ValidationError, validate 

9 

10from haystack import component 

11from haystack.dataclasses import ChatMessage 

12 

13 

14def is_valid_json(s: str) -> bool: 

15 """ 

16 Check if the provided string is a valid JSON. 

17 

18 :param s: The string to be checked. 

19 :returns: `True` if the string is a valid JSON; otherwise, `False`. 

20 """ 

21 try: 

22 json.loads(s) 

23 except ValueError: 

24 return False 

25 return True 

26 

27 

28@component 

29class JsonSchemaValidator: 

30 """ 

31 Validates JSON content of `ChatMessage` against a specified [JSON Schema](https://json-schema.org/). 

32 

33 If JSON content of a message conforms to the provided schema, the message is passed along the "validated" output. 

34 If the JSON content does not conform to the schema, the message is passed along the "validation_error" output. 

35 In the latter case, the error message is constructed using the provided `error_template` or a default template. 

36 These error ChatMessages can be used by LLMs in Haystack 2.x recovery loops. 

37 

38 Usage example: 

39 

40 ```python 

41 from haystack import Pipeline 

42 from haystack.components.generators.chat import OpenAIChatGenerator 

43 from haystack.components.joiners import BranchJoiner 

44 from haystack.components.validators import JsonSchemaValidator 

45 from haystack import component 

46 from haystack.dataclasses import ChatMessage 

47 

48 

49 @component 

50 class MessageProducer: 

51 

52 @component.output_types(messages=list[ChatMessage]) 

53 def run(self, messages: list[ChatMessage]) -> dict: 

54 return {"messages": messages} 

55 

56 

57 p = Pipeline() 

58 p.add_component("llm", OpenAIChatGenerator(generation_kwargs={"response_format": {"type": "json_object"}})) 

59 p.add_component("schema_validator", JsonSchemaValidator()) 

60 p.add_component("joiner_for_llm", BranchJoiner(list[ChatMessage])) 

61 p.add_component("message_producer", MessageProducer()) 

62 

63 p.connect("message_producer.messages", "joiner_for_llm") 

64 p.connect("joiner_for_llm", "llm") 

65 p.connect("llm.replies", "schema_validator.messages") 

66 p.connect("schema_validator.validation_error", "joiner_for_llm") 

67 

68 result = p.run(data={ 

69 "message_producer": { 

70 "messages":[ChatMessage.from_user("Generate JSON for person with name 'John' and age 30")]}, 

71 "schema_validator": { 

72 "json_schema": { 

73 "type": "object", 

74 "properties": {"name": {"type": "string"}, 

75 "age": {"type": "integer"} 

76 } 

77 } 

78 } 

79 }) 

80 print(result) 

81 # >> {'schema_validator': {'validated': [ChatMessage(_role=<ChatRole.ASSISTANT: 'assistant'>, 

82 # _content=[TextContent(text="\\n{\\n "name": "John",\\n "age": 30\\n}")], 

83 # _name=None, _meta={'index': 0, 'finish_reason': 'stop', 'usage': {'completion_tokens': 17, 'prompt_tokens': 20, 

84 # 'total_tokens': 37}})]}} 

85 ``` 

86 """ 

87 

88 # Default error description template 

89 default_error_template = ( 

90 "The following generated JSON does not conform to the provided schema.\n" 

91 "Generated JSON: {failing_json}\n" 

92 "Error details:\n- Message: {error_message}\n" 

93 "- Error Path in JSON: {error_path}\n" 

94 "- Schema Path: {error_schema_path}\n" 

95 "Please match the following schema:\n" 

96 "{json_schema}\n" 

97 "and provide the corrected JSON content ONLY. Please do not output anything else than the raw corrected " 

98 "JSON string, this is the most important part of the task. Don't use any markdown and don't add any comment." 

99 ) 

100 

101 def __init__(self, json_schema: dict[str, Any] | None = None, error_template: str | None = None) -> None: 

102 """ 

103 Initialize the JsonSchemaValidator component. 

104 

105 :param json_schema: A dictionary representing the [JSON schema](https://json-schema.org/) against which 

106 the messages' content is validated. 

107 :param error_template: A custom template string for formatting the error message in case of validation failure. 

108 """ 

109 self.json_schema = json_schema 

110 self.error_template = error_template 

111 

112 @component.output_types(validated=list[ChatMessage], validation_error=list[ChatMessage]) 

113 def run( 

114 self, messages: list[ChatMessage], json_schema: dict[str, Any] | None = None, error_template: str | None = None 

115 ) -> dict[str, list[ChatMessage]]: 

116 """ 

117 Validates the last of the provided messages against the specified json schema. 

118 

119 If it does, the message is passed along the "validated" output. If it does not, the message is passed along 

120 the "validation_error" output. 

121 

122 :param messages: A list of ChatMessage instances to be validated. The last message in this list is the one 

123 that is validated. 

124 :param json_schema: A dictionary representing the [JSON schema](https://json-schema.org/) 

125 against which the messages' content is validated. If not provided, the schema from the component init 

126 is used. 

127 :param error_template: A custom template string for formatting the error message in case of validation. If not 

128 provided, the `error_template` from the component init is used. 

129 :return: A dictionary with the following keys: 

130 - "validated": A list of messages if the last message is valid. 

131 - "validation_error": A list of messages if the last message is invalid. 

132 :raises ValueError: If no JSON schema is provided or if the message content is not a dictionary or a list of 

133 dictionaries. 

134 """ 

135 last_message = messages[-1] 

136 if last_message.text is None: 

137 raise ValueError(f"The provided ChatMessage has no text. ChatMessage: {last_message}") 

138 if not is_valid_json(last_message.text): 

139 return { 

140 "validation_error": [ 

141 ChatMessage.from_user( 

142 f"The message '{last_message.text}' is not a valid JSON object. " 

143 f"Please provide only a valid JSON object in string format." 

144 f"Don't use any markdown and don't add any comment." 

145 ) 

146 ] 

147 } 

148 

149 last_message_content = json.loads(last_message.text) 

150 json_schema = json_schema or self.json_schema 

151 error_template = error_template or self.error_template or self.default_error_template 

152 

153 if not json_schema: 

154 raise ValueError("Provide a JSON schema for validation either in the run method or in the component init.") 

155 # fc payload is json object but subtree `parameters` is string - we need to convert to json object 

156 # we need complete json to validate it against schema 

157 last_message_json = self._recursive_json_to_object(last_message_content) 

158 using_openai_schema: bool = self._is_openai_function_calling_schema(json_schema) 

159 if using_openai_schema: 

160 validation_schema = json_schema["parameters"] 

161 else: 

162 validation_schema = json_schema 

163 try: 

164 last_message_json = [last_message_json] if not isinstance(last_message_json, list) else last_message_json 

165 for content in last_message_json: 

166 if using_openai_schema: 

167 validate(instance=content["function"]["arguments"], schema=validation_schema) 

168 else: 

169 validate(instance=content, schema=validation_schema) 

170 

171 return {"validated": [last_message]} 

172 except ValidationError as e: 

173 error_path = " -> ".join(map(str, e.absolute_path)) if e.absolute_path else "N/A" 

174 error_schema_path = " -> ".join(map(str, e.absolute_schema_path)) if e.absolute_schema_path else "N/A" 

175 

176 error_template = error_template or self.default_error_template 

177 

178 recovery_prompt = self._construct_error_recovery_message( 

179 error_template, str(e), error_path, error_schema_path, validation_schema, failing_json=last_message.text 

180 ) 

181 return {"validation_error": [ChatMessage.from_user(recovery_prompt)]} 

182 

183 def _construct_error_recovery_message( 

184 self, 

185 error_template: str, 

186 error_message: str, 

187 error_path: str, 

188 error_schema_path: str, 

189 json_schema: dict[str, Any], 

190 failing_json: str, 

191 ) -> str: 

192 """ 

193 Constructs an error recovery message using a specified template or the default one if none is provided. 

194 

195 :param error_template: A custom template string for formatting the error message in case of validation failure. 

196 :param error_message: The error message returned by the JSON schema validator. 

197 :param error_path: The path in the JSON content where the error occurred. 

198 :param error_schema_path: The path in the JSON schema where the error occurred. 

199 :param json_schema: The JSON schema against which the content is validated. 

200 :param failing_json: The generated invalid JSON string. 

201 """ 

202 error_template = error_template or self.default_error_template 

203 

204 return error_template.format( 

205 error_message=error_message, 

206 error_path=error_path, 

207 error_schema_path=error_schema_path, 

208 json_schema=json_schema, 

209 failing_json=failing_json, 

210 ) 

211 

212 def _is_openai_function_calling_schema(self, json_schema: dict[str, Any]) -> bool: 

213 """ 

214 Checks if the provided schema is a valid OpenAI function calling schema. 

215 

216 :param json_schema: The JSON schema to check 

217 :return: `True` if the schema is a valid OpenAI function calling schema; otherwise, `False`. 

218 """ 

219 return all(key in json_schema for key in ["name", "description", "parameters"]) 

220 

221 def _recursive_json_to_object(self, data: Any) -> Any: 

222 """ 

223 Convert any string values that are valid JSON objects into dictionary objects. 

224 

225 Returns a new data structure. 

226 

227 :param data: The data structure to be traversed. 

228 :return: A new data structure with JSON strings converted to dictionary objects. 

229 """ 

230 if isinstance(data, list): 

231 return [self._recursive_json_to_object(item) for item in data] 

232 

233 if isinstance(data, dict): 

234 new_dict = {} 

235 for key, value in data.items(): 

236 if isinstance(value, str): 

237 try: 

238 json_value = json.loads(value) 

239 if isinstance(json_value, (dict, list)): 

240 new_dict[key] = self._recursive_json_to_object(json_value) 

241 else: 

242 new_dict[key] = value # Preserve the original string value 

243 except json.JSONDecodeError: 

244 new_dict[key] = value 

245 elif isinstance(value, dict): 

246 new_dict[key] = self._recursive_json_to_object(value) 

247 else: 

248 new_dict[key] = value 

249 return new_dict 

250 

251 # If it's neither a list nor a dictionary, return the value directly 

252 raise ValueError("Input must be a dictionary or a list of dictionaries.")