c56bef871b
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
290 lines
11 KiB
Python
290 lines
11 KiB
Python
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
#
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any, Literal
|
|
|
|
from haystack import component, default_from_dict, default_to_dict, logging
|
|
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
|
from haystack.dataclasses import ByteStream, Document
|
|
from haystack.lazy_imports import LazyImport
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
with LazyImport("Run 'pip install jq'") as jq_import:
|
|
import jq
|
|
|
|
|
|
@component
|
|
class JSONConverter:
|
|
"""
|
|
Converts one or more JSON files into a text document.
|
|
|
|
### Usage examples
|
|
|
|
```python
|
|
import json
|
|
|
|
from haystack.components.converters import JSONConverter
|
|
from haystack.dataclasses import ByteStream
|
|
|
|
source = ByteStream.from_string(json.dumps({"text": "This is the content of my document"}))
|
|
|
|
converter = JSONConverter(content_key="text")
|
|
results = converter.run(sources=[source])
|
|
documents = results["documents"]
|
|
print(documents[0].content)
|
|
# 'This is the content of my document'
|
|
```
|
|
|
|
Optionally, you can also provide a `jq_schema` string to filter the JSON source files and `extra_meta_fields`
|
|
to extract from the filtered data:
|
|
|
|
```python
|
|
import json
|
|
|
|
from haystack.components.converters import JSONConverter
|
|
from haystack.dataclasses import ByteStream
|
|
|
|
data = {
|
|
"laureates": [
|
|
{
|
|
"firstname": "Enrico",
|
|
"surname": "Fermi",
|
|
"motivation": "for his demonstrations of the existence of new radioactive elements produced "
|
|
"by neutron irradiation, and for his related discovery of nuclear reactions brought about by"
|
|
" slow neutrons",
|
|
},
|
|
{
|
|
"firstname": "Rita",
|
|
"surname": "Levi-Montalcini",
|
|
"motivation": "for their discoveries of growth factors",
|
|
},
|
|
],
|
|
}
|
|
source = ByteStream.from_string(json.dumps(data))
|
|
converter = JSONConverter(
|
|
jq_schema=".laureates[]", content_key="motivation", extra_meta_fields={"firstname", "surname"}
|
|
)
|
|
|
|
results = converter.run(sources=[source])
|
|
documents = results["documents"]
|
|
print(documents[0].content)
|
|
# 'for his demonstrations of the existence of new radioactive elements produced by
|
|
# neutron irradiation, and for his related discovery of nuclear reactions brought
|
|
# about by slow neutrons'
|
|
|
|
print(documents[0].meta)
|
|
# {'firstname': 'Enrico', 'surname': 'Fermi'}
|
|
|
|
print(documents[1].content)
|
|
# 'for their discoveries of growth factors'
|
|
|
|
print(documents[1].meta)
|
|
# {'firstname': 'Rita', 'surname': 'Levi-Montalcini'}
|
|
```
|
|
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
jq_schema: str | None = None,
|
|
content_key: str | None = None,
|
|
extra_meta_fields: set[str] | Literal["*"] | None = None,
|
|
store_full_path: bool = False,
|
|
) -> None:
|
|
"""
|
|
Creates a JSONConverter component.
|
|
|
|
An optional `jq_schema` can be provided to extract nested data in the JSON source files.
|
|
See the [official jq documentation](https://jqlang.github.io/jq/) for more info on the filters syntax.
|
|
If `jq_schema` is not set, whole JSON source files will be used to extract content.
|
|
|
|
Optionally, you can provide a `content_key` to specify which key in the extracted object must
|
|
be set as the document's content.
|
|
|
|
If both `jq_schema` and `content_key` are set, the component will search for the `content_key` in
|
|
the JSON object extracted by `jq_schema`. If the extracted data is not a JSON object, it will be skipped.
|
|
|
|
If only `jq_schema` is set, the extracted data must be a scalar value. If it's a JSON object or array,
|
|
it will be skipped.
|
|
|
|
If only `content_key` is set, the source JSON file must be a JSON object, else it will be skipped.
|
|
|
|
`extra_meta_fields` can either be set to a set of strings or a literal `"*"` string.
|
|
If it's a set of strings, it must specify fields in the extracted objects that must be set in
|
|
the extracted documents. If a field is not found, the meta value will be `None`.
|
|
If set to `"*"`, all fields that are not `content_key` found in the filtered JSON object will
|
|
be saved as metadata.
|
|
|
|
Initialization will fail if neither `jq_schema` nor `content_key` are set.
|
|
|
|
:param jq_schema:
|
|
Optional jq filter string to extract content.
|
|
If not specified, whole JSON object will be used to extract information.
|
|
:param content_key:
|
|
Optional key to extract document content.
|
|
If `jq_schema` is specified, the `content_key` will be extracted from that object.
|
|
:param extra_meta_fields:
|
|
An optional set of meta keys to extract from the content.
|
|
If `jq_schema` is specified, all keys will be extracted from that object.
|
|
:param store_full_path:
|
|
If True, the full path of the file is stored in the metadata of the document.
|
|
If False, only the file name is stored.
|
|
"""
|
|
self._compiled_filter = None
|
|
if jq_schema:
|
|
jq_import.check()
|
|
self._compiled_filter = jq.compile(jq_schema)
|
|
|
|
self._jq_schema = jq_schema
|
|
self._content_key = content_key
|
|
self._meta_fields = extra_meta_fields
|
|
self._store_full_path = store_full_path
|
|
|
|
if self._compiled_filter is None and self._content_key is None:
|
|
msg = "No `jq_schema` nor `content_key` specified. Set either or both to extract data."
|
|
raise ValueError(msg)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""
|
|
Serializes the component to a dictionary.
|
|
|
|
:returns:
|
|
Dictionary with serialized data.
|
|
"""
|
|
return default_to_dict(
|
|
self,
|
|
jq_schema=self._jq_schema,
|
|
content_key=self._content_key,
|
|
extra_meta_fields=self._meta_fields,
|
|
store_full_path=self._store_full_path,
|
|
)
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any]) -> "JSONConverter":
|
|
"""
|
|
Deserializes the component from a dictionary.
|
|
|
|
:param data:
|
|
Dictionary to deserialize from.
|
|
:returns:
|
|
Deserialized component.
|
|
"""
|
|
return default_from_dict(cls, data)
|
|
|
|
def _get_content_and_meta(self, source: ByteStream) -> list[tuple[str, dict[str, Any]]]:
|
|
"""
|
|
Utility function to extract text and metadata from a JSON file.
|
|
|
|
:param source:
|
|
UTF-8 byte stream.
|
|
:returns:
|
|
Collection of text and metadata dict tuples, each corresponding
|
|
to a different document.
|
|
"""
|
|
try:
|
|
file_content = source.data.decode("utf-8")
|
|
except UnicodeError as exc:
|
|
logger.warning(
|
|
"Failed to extract text from {source}. Skipping it. Error: {error}",
|
|
source=source.meta["file_path"],
|
|
error=exc,
|
|
)
|
|
return []
|
|
|
|
meta_fields = self._meta_fields or set()
|
|
|
|
if self._compiled_filter is not None:
|
|
try:
|
|
objects = list(self._compiled_filter.input_text(file_content))
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Failed to extract text from {source}. Skipping it. Error: {error}",
|
|
source=source.meta["file_path"],
|
|
error=exc,
|
|
)
|
|
return []
|
|
else:
|
|
# We just load the whole file as JSON if the user didn't provide a jq filter.
|
|
# We put it in a list even if it's not to ease handling it later on.
|
|
objects = [json.loads(file_content)]
|
|
|
|
result = []
|
|
if self._content_key is not None:
|
|
for obj in objects:
|
|
if not isinstance(obj, dict):
|
|
logger.warning("Expected a dictionary but got {obj}. Skipping it.", obj=obj)
|
|
continue
|
|
if self._content_key not in obj:
|
|
logger.warning(
|
|
"'{content_key}' not found in {obj}. Skipping it.", content_key=self._content_key, obj=obj
|
|
)
|
|
continue
|
|
|
|
text = obj[self._content_key]
|
|
if isinstance(text, (dict, list)):
|
|
logger.warning("Expected a scalar value but got {obj}. Skipping it.", obj=obj)
|
|
continue
|
|
|
|
meta = {}
|
|
if meta_fields == "*":
|
|
meta = {k: v for k, v in obj.items() if k != self._content_key}
|
|
else:
|
|
for field in meta_fields:
|
|
meta[field] = obj.get(field, None)
|
|
result.append((text, meta))
|
|
else:
|
|
for obj in objects:
|
|
if isinstance(obj, (dict, list)):
|
|
logger.warning("Expected a scalar value but got {obj}. Skipping it.", obj=obj)
|
|
continue
|
|
result.append((str(obj), {}))
|
|
|
|
return result
|
|
|
|
@component.output_types(documents=list[Document])
|
|
def run(
|
|
self, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Converts a list of JSON files to documents.
|
|
|
|
:param sources:
|
|
A list of file paths or ByteStream objects.
|
|
:param meta:
|
|
Optional metadata to attach to the documents.
|
|
This value can be either a list of dictionaries or a single dictionary.
|
|
If it's a single dictionary, its content is added to the metadata of all produced documents.
|
|
If it's a list, the length of the list must match the number of sources.
|
|
If `sources` contain ByteStream objects, their `meta` will be added to the output documents.
|
|
|
|
:returns:
|
|
A dictionary with the following keys:
|
|
- `documents`: A list of created documents.
|
|
"""
|
|
documents = []
|
|
meta_list = normalize_metadata(meta=meta, sources_count=len(sources))
|
|
|
|
for source, metadata in zip(sources, meta_list, strict=True):
|
|
try:
|
|
bytestream = get_bytestream_from_source(source)
|
|
except Exception as exc:
|
|
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=exc)
|
|
continue
|
|
|
|
data = self._get_content_and_meta(bytestream)
|
|
|
|
for text, extra_meta in data:
|
|
merged_metadata = {**bytestream.meta, **metadata, **extra_meta}
|
|
|
|
if not self._store_full_path and (file_path := bytestream.meta.get("file_path")):
|
|
merged_metadata["file_path"] = os.path.basename(file_path)
|
|
document = Document(content=text, meta=merged_metadata)
|
|
documents.append(document)
|
|
|
|
return {"documents": documents}
|