Coverage for haystack/components/converters/json.py: 88%

91 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 

6import os 

7from pathlib import Path 

8from typing import Any, Literal 

9 

10from haystack import component, default_from_dict, default_to_dict, logging 

11from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata 

12from haystack.dataclasses import ByteStream, Document 

13from haystack.lazy_imports import LazyImport 

14 

15logger = logging.getLogger(__name__) 

16 

17with LazyImport("Run 'pip install jq'") as jq_import: 

18 import jq 

19 

20 

21@component 

22class JSONConverter: 

23 """ 

24 Converts one or more JSON files into a text document. 

25 

26 ### Usage examples 

27 

28 ```python 

29 import json 

30 

31 from haystack.components.converters import JSONConverter 

32 from haystack.dataclasses import ByteStream 

33 

34 source = ByteStream.from_string(json.dumps({"text": "This is the content of my document"})) 

35 

36 converter = JSONConverter(content_key="text") 

37 results = converter.run(sources=[source]) 

38 documents = results["documents"] 

39 print(documents[0].content) 

40 # 'This is the content of my document' 

41 ``` 

42 

43 Optionally, you can also provide a `jq_schema` string to filter the JSON source files and `extra_meta_fields` 

44 to extract from the filtered data: 

45 

46 ```python 

47 import json 

48 

49 from haystack.components.converters import JSONConverter 

50 from haystack.dataclasses import ByteStream 

51 

52 data = { 

53 "laureates": [ 

54 { 

55 "firstname": "Enrico", 

56 "surname": "Fermi", 

57 "motivation": "for his demonstrations of the existence of new radioactive elements produced " 

58 "by neutron irradiation, and for his related discovery of nuclear reactions brought about by" 

59 " slow neutrons", 

60 }, 

61 { 

62 "firstname": "Rita", 

63 "surname": "Levi-Montalcini", 

64 "motivation": "for their discoveries of growth factors", 

65 }, 

66 ], 

67 } 

68 source = ByteStream.from_string(json.dumps(data)) 

69 converter = JSONConverter( 

70 jq_schema=".laureates[]", content_key="motivation", extra_meta_fields={"firstname", "surname"} 

71 ) 

72 

73 results = converter.run(sources=[source]) 

74 documents = results["documents"] 

75 print(documents[0].content) 

76 # 'for his demonstrations of the existence of new radioactive elements produced by 

77 # neutron irradiation, and for his related discovery of nuclear reactions brought 

78 # about by slow neutrons' 

79 

80 print(documents[0].meta) 

81 # {'firstname': 'Enrico', 'surname': 'Fermi'} 

82 

83 print(documents[1].content) 

84 # 'for their discoveries of growth factors' 

85 

86 print(documents[1].meta) 

87 # {'firstname': 'Rita', 'surname': 'Levi-Montalcini'} 

88 ``` 

89 

90 """ 

91 

92 def __init__( 

93 self, 

94 jq_schema: str | None = None, 

95 content_key: str | None = None, 

96 extra_meta_fields: set[str] | Literal["*"] | None = None, 

97 store_full_path: bool = False, 

98 ) -> None: 

99 """ 

100 Creates a JSONConverter component. 

101 

102 An optional `jq_schema` can be provided to extract nested data in the JSON source files. 

103 See the [official jq documentation](https://jqlang.github.io/jq/) for more info on the filters syntax. 

104 If `jq_schema` is not set, whole JSON source files will be used to extract content. 

105 

106 Optionally, you can provide a `content_key` to specify which key in the extracted object must 

107 be set as the document's content. 

108 

109 If both `jq_schema` and `content_key` are set, the component will search for the `content_key` in 

110 the JSON object extracted by `jq_schema`. If the extracted data is not a JSON object, it will be skipped. 

111 

112 If only `jq_schema` is set, the extracted data must be a scalar value. If it's a JSON object or array, 

113 it will be skipped. 

114 

115 If only `content_key` is set, the source JSON file must be a JSON object, else it will be skipped. 

116 

117 `extra_meta_fields` can either be set to a set of strings or a literal `"*"` string. 

118 If it's a set of strings, it must specify fields in the extracted objects that must be set in 

119 the extracted documents. If a field is not found, the meta value will be `None`. 

120 If set to `"*"`, all fields that are not `content_key` found in the filtered JSON object will 

121 be saved as metadata. 

122 

123 Initialization will fail if neither `jq_schema` nor `content_key` are set. 

124 

125 :param jq_schema: 

126 Optional jq filter string to extract content. 

127 If not specified, whole JSON object will be used to extract information. 

128 :param content_key: 

129 Optional key to extract document content. 

130 If `jq_schema` is specified, the `content_key` will be extracted from that object. 

131 :param extra_meta_fields: 

132 An optional set of meta keys to extract from the content. 

133 If `jq_schema` is specified, all keys will be extracted from that object. 

134 :param store_full_path: 

135 If True, the full path of the file is stored in the metadata of the document. 

136 If False, only the file name is stored. 

137 """ 

138 self._compiled_filter = None 

139 if jq_schema: 

140 jq_import.check() 

141 self._compiled_filter = jq.compile(jq_schema) 

142 

143 self._jq_schema = jq_schema 

144 self._content_key = content_key 

145 self._meta_fields = extra_meta_fields 

146 self._store_full_path = store_full_path 

147 

148 if self._compiled_filter is None and self._content_key is None: 

149 msg = "No `jq_schema` nor `content_key` specified. Set either or both to extract data." 

150 raise ValueError(msg) 

151 

152 def to_dict(self) -> dict[str, Any]: 

153 """ 

154 Serializes the component to a dictionary. 

155 

156 :returns: 

157 Dictionary with serialized data. 

158 """ 

159 return default_to_dict( 

160 self, 

161 jq_schema=self._jq_schema, 

162 content_key=self._content_key, 

163 extra_meta_fields=self._meta_fields, 

164 store_full_path=self._store_full_path, 

165 ) 

166 

167 @classmethod 

168 def from_dict(cls, data: dict[str, Any]) -> "JSONConverter": 

169 """ 

170 Deserializes the component from a dictionary. 

171 

172 :param data: 

173 Dictionary to deserialize from. 

174 :returns: 

175 Deserialized component. 

176 """ 

177 return default_from_dict(cls, data) 

178 

179 def _get_content_and_meta(self, source: ByteStream) -> list[tuple[str, dict[str, Any]]]: 

180 """ 

181 Utility function to extract text and metadata from a JSON file. 

182 

183 :param source: 

184 UTF-8 byte stream. 

185 :returns: 

186 Collection of text and metadata dict tuples, each corresponding 

187 to a different document. 

188 """ 

189 try: 

190 file_content = source.data.decode("utf-8") 

191 except UnicodeError as exc: 

192 logger.warning( 

193 "Failed to extract text from {source}. Skipping it. Error: {error}", 

194 source=source.meta.get("file_path", "unknown"), 

195 error=exc, 

196 ) 

197 return [] 

198 

199 meta_fields = self._meta_fields or set() 

200 

201 if self._compiled_filter is not None: 

202 try: 

203 objects = list(self._compiled_filter.input_text(file_content)) 

204 except Exception as exc: 

205 logger.warning( 

206 "Failed to extract text from {source}. Skipping it. Error: {error}", 

207 source=source.meta.get("file_path", "unknown"), 

208 error=exc, 

209 ) 

210 return [] 

211 else: 

212 # We just load the whole file as JSON if the user didn't provide a jq filter. 

213 # We put it in a list even if it's not to ease handling it later on. 

214 try: 

215 objects = [json.loads(file_content)] 

216 except json.JSONDecodeError as exc: 

217 logger.warning( 

218 "Failed to extract text from {source}. Skipping it. Error: {error}", 

219 source=source.meta.get("file_path", "unknown"), 

220 error=exc, 

221 ) 

222 return [] 

223 

224 result = [] 

225 if self._content_key is not None: 

226 for obj in objects: 

227 if not isinstance(obj, dict): 

228 logger.warning("Expected a dictionary but got {obj}. Skipping it.", obj=obj) 

229 continue 

230 if self._content_key not in obj: 

231 logger.warning( 

232 "'{content_key}' not found in {obj}. Skipping it.", content_key=self._content_key, obj=obj 

233 ) 

234 continue 

235 

236 text = obj[self._content_key] 

237 if isinstance(text, (dict, list)): 

238 logger.warning("Expected a scalar value but got {obj}. Skipping it.", obj=obj) 

239 continue 

240 

241 meta = {} 

242 if meta_fields == "*": 

243 meta = {k: v for k, v in obj.items() if k != self._content_key} 

244 else: 

245 for field in meta_fields: 

246 meta[field] = obj.get(field, None) 

247 result.append((text, meta)) 

248 else: 

249 for obj in objects: 

250 if isinstance(obj, (dict, list)): 

251 logger.warning("Expected a scalar value but got {obj}. Skipping it.", obj=obj) 

252 continue 

253 result.append((str(obj), {})) 

254 

255 return result 

256 

257 @component.output_types(documents=list[Document]) 

258 def run( 

259 self, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None 

260 ) -> dict[str, Any]: 

261 """ 

262 Converts a list of JSON files to documents. 

263 

264 :param sources: 

265 A list of file paths or ByteStream objects. 

266 :param meta: 

267 Optional metadata to attach to the documents. 

268 This value can be either a list of dictionaries or a single dictionary. 

269 If it's a single dictionary, its content is added to the metadata of all produced documents. 

270 If it's a list, the length of the list must match the number of sources. 

271 If `sources` contain ByteStream objects, their `meta` will be added to the output documents. 

272 

273 :returns: 

274 A dictionary with the following keys: 

275 - `documents`: A list of created documents. 

276 """ 

277 documents = [] 

278 meta_list = normalize_metadata(meta=meta, sources_count=len(sources)) 

279 

280 for source, metadata in zip(sources, meta_list, strict=True): 

281 try: 

282 bytestream = get_bytestream_from_source(source) 

283 except Exception as exc: 

284 logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=exc) 

285 continue 

286 

287 data = self._get_content_and_meta(bytestream) 

288 

289 for text, extra_meta in data: 

290 merged_metadata = {**bytestream.meta, **metadata, **extra_meta} 

291 

292 if not self._store_full_path and (file_path := bytestream.meta.get("file_path")): 

293 merged_metadata["file_path"] = os.path.basename(file_path) 

294 document = Document(content=text, meta=merged_metadata) 

295 documents.append(document) 

296 

297 return {"documents": documents}