From a07841e91bbbc357ace333510cdce82836e45ede Mon Sep 17 00:00:00 2001 From: octo-patch Date: Sun, 26 Apr 2026 11:29:26 +0800 Subject: [PATCH 1/3] fix: handle string attributes in graph ontology to prevent TypeError crash (fixes #135) When the LLM returns ontology attributes as plain strings instead of dicts, set_ontology() crashes with "TypeError: string indices must be integers, not 'str'" at attr_def["name"]. Two-layer fix: 1. ontology_generator.py: normalize string attrs to {"name", "type", "description"} dicts during validation, so downstream code always receives well-formed structures. 2. graph_builder.py: add isinstance guard as a safety net in set_ontology() for both entity and edge attribute loops. Co-Authored-By: Octopus --- backend/app/services/graph_builder.py | 18 ++++++++++++++---- backend/app/services/ontology_generator.py | 10 ++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/backend/app/services/graph_builder.py b/backend/app/services/graph_builder.py index 37c9969c..a882377f 100644 --- a/backend/app/services/graph_builder.py +++ b/backend/app/services/graph_builder.py @@ -233,8 +233,13 @@ class GraphBuilderService: annotations = {} for attr_def in entity_def.get("attributes", []): - attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称 - attr_desc = attr_def.get("description", attr_name) + # LLM may return attributes as strings instead of dicts + if isinstance(attr_def, str): + attr_name = safe_attr_name(attr_def) + attr_desc = attr_def + else: + attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称 + attr_desc = attr_def.get("description", attr_name) # Zep API 需要 Field 的 description,这是必需的 attrs[attr_name] = Field(description=attr_desc, default=None) annotations[attr_name] = Optional[EntityText] # 类型注解 @@ -257,8 +262,13 @@ class GraphBuilderService: annotations = {} for attr_def in edge_def.get("attributes", []): - attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称 - attr_desc = attr_def.get("description", attr_name) + # LLM may return attributes as strings instead of dicts + if isinstance(attr_def, str): + attr_name = safe_attr_name(attr_def) + attr_desc = attr_def + else: + attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称 + attr_desc = attr_def.get("description", attr_name) # Zep API 需要 Field 的 description,这是必需的 attrs[attr_name] = Field(description=attr_desc, default=None) annotations[attr_name] = Optional[str] # 边属性用str类型 diff --git a/backend/app/services/ontology_generator.py b/backend/app/services/ontology_generator.py index 7619a79f..0d8432c4 100644 --- a/backend/app/services/ontology_generator.py +++ b/backend/app/services/ontology_generator.py @@ -432,6 +432,11 @@ class OntologyGenerator: entity_name_map[original_name] = entity["name"] if "attributes" not in entity: entity["attributes"] = [] + # Normalize attributes: LLM may return strings instead of dicts + entity["attributes"] = [ + attr if isinstance(attr, dict) else {"name": attr, "type": "text", "description": attr} + for attr in entity["attributes"] + ] if "examples" not in entity: entity["examples"] = [] # 确保description不超过100字符 @@ -456,6 +461,11 @@ class OntologyGenerator: edge["source_targets"] = [] if "attributes" not in edge: edge["attributes"] = [] + # Normalize attributes: LLM may return strings instead of dicts + edge["attributes"] = [ + attr if isinstance(attr, dict) else {"name": attr, "type": "text", "description": attr} + for attr in edge["attributes"] + ] if len(edge.get("description", "")) > 100: edge["description"] = edge["description"][:97] + "..." From 9506e6f5e037cea93ff160d572224927c2e602d2 Mon Sep 17 00:00:00 2001 From: 666ghj <670939375@qq.com> Date: Wed, 22 Jul 2026 01:09:33 +0800 Subject: [PATCH 2/3] fix: normalize ontology attributes centrally --- backend/app/services/graph_builder.py | 26 ++++----- backend/app/services/ontology_generator.py | 11 ++-- backend/app/utils/ontology.py | 29 ++++++++++ backend/tests/test_ontology_attributes.py | 64 ++++++++++++++++++++++ 4 files changed, 111 insertions(+), 19 deletions(-) create mode 100644 backend/app/utils/ontology.py create mode 100644 backend/tests/test_ontology_attributes.py diff --git a/backend/app/services/graph_builder.py b/backend/app/services/graph_builder.py index a882377f..308499c1 100644 --- a/backend/app/services/graph_builder.py +++ b/backend/app/services/graph_builder.py @@ -16,6 +16,7 @@ from zep_cloud import EpisodeData, EntityEdgeSourceTarget from ..config import Config from ..models.task import TaskManager, TaskStatus from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges +from ..utils.ontology import normalize_ontology_attribute from .text_processor import TextProcessor from ..utils.locale import t, get_locale, set_locale @@ -233,13 +234,11 @@ class GraphBuilderService: annotations = {} for attr_def in entity_def.get("attributes", []): - # LLM may return attributes as strings instead of dicts - if isinstance(attr_def, str): - attr_name = safe_attr_name(attr_def) - attr_desc = attr_def - else: - attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称 - attr_desc = attr_def.get("description", attr_name) + normalized = normalize_ontology_attribute(attr_def) + if normalized is None: + continue + attr_name = safe_attr_name(normalized["name"]) # 使用安全名称 + attr_desc = normalized["description"] # Zep API 需要 Field 的 description,这是必需的 attrs[attr_name] = Field(description=attr_desc, default=None) annotations[attr_name] = Optional[EntityText] # 类型注解 @@ -262,13 +261,11 @@ class GraphBuilderService: annotations = {} for attr_def in edge_def.get("attributes", []): - # LLM may return attributes as strings instead of dicts - if isinstance(attr_def, str): - attr_name = safe_attr_name(attr_def) - attr_desc = attr_def - else: - attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称 - attr_desc = attr_def.get("description", attr_name) + normalized = normalize_ontology_attribute(attr_def) + if normalized is None: + continue + attr_name = safe_attr_name(normalized["name"]) # 使用安全名称 + attr_desc = normalized["description"] # Zep API 需要 Field 的 description,这是必需的 attrs[attr_name] = Field(description=attr_desc, default=None) annotations[attr_name] = Optional[str] # 边属性用str类型 @@ -513,4 +510,3 @@ class GraphBuilderService: def delete_graph(self, graph_id: str): """删除图谱""" self.client.graph.delete(graph_id=graph_id) - diff --git a/backend/app/services/ontology_generator.py b/backend/app/services/ontology_generator.py index 0d8432c4..f57211af 100644 --- a/backend/app/services/ontology_generator.py +++ b/backend/app/services/ontology_generator.py @@ -10,6 +10,7 @@ from typing import Dict, Any, List, Optional from ..utils.llm_client import LLMClient from ..utils.locale import get_language_instruction from ..utils.file_parser import split_text_into_chunks +from ..utils.ontology import normalize_ontology_attribute logger = logging.getLogger(__name__) @@ -432,10 +433,11 @@ class OntologyGenerator: entity_name_map[original_name] = entity["name"] if "attributes" not in entity: entity["attributes"] = [] - # Normalize attributes: LLM may return strings instead of dicts + # Normalize LLM output and discard unusable attribute definitions. entity["attributes"] = [ - attr if isinstance(attr, dict) else {"name": attr, "type": "text", "description": attr} + normalized for attr in entity["attributes"] + if (normalized := normalize_ontology_attribute(attr)) is not None ] if "examples" not in entity: entity["examples"] = [] @@ -461,10 +463,11 @@ class OntologyGenerator: edge["source_targets"] = [] if "attributes" not in edge: edge["attributes"] = [] - # Normalize attributes: LLM may return strings instead of dicts + # Normalize LLM output and discard unusable attribute definitions. edge["attributes"] = [ - attr if isinstance(attr, dict) else {"name": attr, "type": "text", "description": attr} + normalized for attr in edge["attributes"] + if (normalized := normalize_ontology_attribute(attr)) is not None ] if len(edge.get("description", "")) > 100: edge["description"] = edge["description"][:97] + "..." diff --git a/backend/app/utils/ontology.py b/backend/app/utils/ontology.py new file mode 100644 index 00000000..5c5ba578 --- /dev/null +++ b/backend/app/utils/ontology.py @@ -0,0 +1,29 @@ +"""Helpers for validating LLM-generated ontology structures.""" + +from typing import Any, Dict, Optional + + +def normalize_ontology_attribute(attribute: Any) -> Optional[Dict[str, Any]]: + """Return a safe attribute definition, or ``None`` for unusable values.""" + + if isinstance(attribute, str): + if not attribute.strip(): + return None + return { + "name": attribute, + "type": "text", + "description": attribute, + } + + if not isinstance(attribute, dict): + return None + + name = attribute.get("name") + if not isinstance(name, str) or not name.strip(): + return None + + normalized = dict(attribute) + description = normalized.get("description") + if not isinstance(description, str) or not description: + normalized["description"] = name + return normalized diff --git a/backend/tests/test_ontology_attributes.py b/backend/tests/test_ontology_attributes.py new file mode 100644 index 00000000..87304fdd --- /dev/null +++ b/backend/tests/test_ontology_attributes.py @@ -0,0 +1,64 @@ +from app.services.ontology_generator import OntologyGenerator +from app.services.graph_builder import GraphBuilderService +from app.utils.ontology import normalize_ontology_attribute + + +def test_normalize_string_attribute(): + assert normalize_ontology_attribute("role") == { + "name": "role", + "type": "text", + "description": "role", + } + + +def test_preserve_valid_dictionary_attribute(): + original = {"name": "role", "type": "text", "description": "Public role"} + assert normalize_ontology_attribute(original) == original + assert normalize_ontology_attribute(original) is not original + + +def test_reject_unusable_attribute_shapes(): + for value in (None, 7, [], {}, {"name": None}, {"name": ""}, " "): + assert normalize_ontology_attribute(value) is None + + +def test_generator_normalizes_entity_and_edge_attributes(): + result = OntologyGenerator(llm_client=object())._validate_and_process({ + "entity_types": [{"name": "speaker", "attributes": ["role", None]}], + "edge_types": [{"name": "quotes", "attributes": ["source_url", {}]}], + }) + + assert result["entity_types"][0]["attributes"] == [{ + "name": "role", + "type": "text", + "description": "role", + }] + assert result["edge_types"][0]["attributes"] == [{ + "name": "source_url", + "type": "text", + "description": "source_url", + }] + + +def test_graph_builder_safety_net_accepts_strings_and_skips_invalid_values(): + captured = {} + + class GraphApi: + def set_ontology(self, **kwargs): + captured.update(kwargs) + + class Client: + graph = GraphApi() + + builder = object.__new__(GraphBuilderService) + builder.client = Client() + builder.set_ontology("graph-id", { + "entity_types": [{ + "name": "Speaker", + "attributes": ["role", None, {"name": "summary"}], + }], + "edge_types": [], + }) + + speaker = captured["entities"]["Speaker"] + assert set(speaker.__annotations__) == {"role", "entity_summary"} From d2f9e56eeaeac6646ffcc3ebd88f57252b18a40d Mon Sep 17 00:00:00 2001 From: 666ghj <670939375@qq.com> Date: Wed, 22 Jul 2026 19:53:59 +0800 Subject: [PATCH 3/3] fix: enforce Zep ontology contract limits --- backend/app/services/graph_builder.py | 33 ++++--- backend/app/services/ontology_generator.py | 39 ++++---- backend/app/utils/ontology.py | 42 +++++++- backend/tests/test_ontology_attributes.py | 108 ++++++++++++++++++++- 4 files changed, 183 insertions(+), 39 deletions(-) diff --git a/backend/app/services/graph_builder.py b/backend/app/services/graph_builder.py index 308499c1..4a12c148 100644 --- a/backend/app/services/graph_builder.py +++ b/backend/app/services/graph_builder.py @@ -16,7 +16,11 @@ from zep_cloud import EpisodeData, EntityEdgeSourceTarget from ..config import Config from ..models.task import TaskManager, TaskStatus from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges -from ..utils.ontology import normalize_ontology_attribute +from ..utils.ontology import ( + MAX_ONTOLOGY_TYPES, + RESERVED_ONTOLOGY_ATTRIBUTE_NAMES, + normalize_ontology_attributes, +) from .text_processor import TextProcessor from ..utils.locale import t, get_locale, set_locale @@ -214,18 +218,15 @@ class GraphBuilderService: # 这是 Zep SDK 要求的用法,警告来自动态类创建,可以安全忽略 warnings.filterwarnings('ignore', category=UserWarning, module='pydantic') - # Zep 保留名称,不能作为属性名 - RESERVED_NAMES = {'uuid', 'name', 'group_id', 'name_embedding', 'summary', 'created_at'} - def safe_attr_name(attr_name: str) -> str: """将保留名称转换为安全名称""" - if attr_name.lower() in RESERVED_NAMES: + if attr_name.lower() in RESERVED_ONTOLOGY_ATTRIBUTE_NAMES: return f"entity_{attr_name}" return attr_name # 动态创建实体类型 entity_types = {} - for entity_def in ontology.get("entity_types", []): + for entity_def in ontology.get("entity_types", [])[:MAX_ONTOLOGY_TYPES]: name = entity_def["name"] description = entity_def.get("description", f"A {name} entity.") @@ -233,10 +234,9 @@ class GraphBuilderService: attrs = {"__doc__": description} annotations = {} - for attr_def in entity_def.get("attributes", []): - normalized = normalize_ontology_attribute(attr_def) - if normalized is None: - continue + for normalized in normalize_ontology_attributes( + entity_def.get("attributes", []) + ): attr_name = safe_attr_name(normalized["name"]) # 使用安全名称 attr_desc = normalized["description"] # Zep API 需要 Field 的 description,这是必需的 @@ -252,7 +252,7 @@ class GraphBuilderService: # 动态创建边类型 edge_definitions = {} - for edge_def in ontology.get("edge_types", []): + for edge_def in ontology.get("edge_types", [])[:MAX_ONTOLOGY_TYPES]: name = edge_def["name"] description = edge_def.get("description", f"A {name} relationship.") @@ -260,10 +260,9 @@ class GraphBuilderService: attrs = {"__doc__": description} annotations = {} - for attr_def in edge_def.get("attributes", []): - normalized = normalize_ontology_attribute(attr_def) - if normalized is None: - continue + for normalized in normalize_ontology_attributes( + edge_def.get("attributes", []) + ): attr_name = safe_attr_name(normalized["name"]) # 使用安全名称 attr_desc = normalized["description"] # Zep API 需要 Field 的 description,这是必需的 @@ -294,7 +293,9 @@ class GraphBuilderService: if entity_types or edge_definitions: self.client.graph.set_ontology( graph_ids=[graph_id], - entities=entity_types if entity_types else None, + # zep-cloud 3.13.0 iterates entities.items(), so an edge-only + # ontology must pass an empty dictionary rather than None. + entities=entity_types, edges=edge_definitions if edge_definitions else None, ) diff --git a/backend/app/services/ontology_generator.py b/backend/app/services/ontology_generator.py index f57211af..b719a8dc 100644 --- a/backend/app/services/ontology_generator.py +++ b/backend/app/services/ontology_generator.py @@ -10,7 +10,10 @@ from typing import Dict, Any, List, Optional from ..utils.llm_client import LLMClient from ..utils.locale import get_language_instruction from ..utils.file_parser import split_text_into_chunks -from ..utils.ontology import normalize_ontology_attribute +from ..utils.ontology import ( + MAX_ONTOLOGY_TYPES, + normalize_ontology_attributes, +) logger = logging.getLogger(__name__) @@ -128,7 +131,7 @@ B. **具体类型(8个,根据文本内容设计)**: ### 3. 属性设计 - 每个实体类型1-3个关键属性 -- **注意**:属性名不能使用 `name`、`uuid`、`group_id`、`created_at`、`summary`(这些是系统保留字) +- **注意**:属性名不能使用 `name`、`uuid`、`group_id`、`graph_id`、`created_at`、`summary`(这些是系统保留字) - 推荐使用:`full_name`, `title`, `role`, `position`, `location`, `description` 等 ## 实体类型参考 @@ -268,7 +271,7 @@ class OntologyGenerator: 2. 最后2个必须是兜底类型:Person(个人兜底)和 Organization(组织兜底) 3. 前8个是根据文本内容设计的具体类型 4. 所有实体类型必须是现实中可以发声的主体,不能是抽象概念 -5. 属性名不能使用 name、uuid、group_id 等保留字,用 full_name、org_name 等替代 +5. 属性名不能使用 name、uuid、group_id、graph_id 等保留字,用 full_name、org_name 等替代 """ return message @@ -431,14 +434,11 @@ class OntologyGenerator: if entity["name"] != original_name: logger.warning(f"Entity type name '{original_name}' auto-converted to '{entity['name']}'") entity_name_map[original_name] = entity["name"] - if "attributes" not in entity: - entity["attributes"] = [] - # Normalize LLM output and discard unusable attribute definitions. - entity["attributes"] = [ - normalized - for attr in entity["attributes"] - if (normalized := normalize_ontology_attribute(attr)) is not None - ] + # Normalize LLM output, enforce Zep field limits, and guarantee at + # least one property for every custom ontology type. + entity["attributes"] = normalize_ontology_attributes( + entity.get("attributes", []) + ) if "examples" not in entity: entity["examples"] = [] # 确保description不超过100字符 @@ -461,20 +461,17 @@ class OntologyGenerator: st["target"] = entity_name_map[st["target"]] if "source_targets" not in edge: edge["source_targets"] = [] - if "attributes" not in edge: - edge["attributes"] = [] - # Normalize LLM output and discard unusable attribute definitions. - edge["attributes"] = [ - normalized - for attr in edge["attributes"] - if (normalized := normalize_ontology_attribute(attr)) is not None - ] + # Normalize LLM output, enforce Zep field limits, and guarantee at + # least one property for every custom ontology type. + edge["attributes"] = normalize_ontology_attributes( + edge.get("attributes", []) + ) if len(edge.get("description", "")) > 100: edge["description"] = edge["description"][:97] + "..." # Zep API 限制:最多 10 个自定义实体类型,最多 10 个自定义边类型 - MAX_ENTITY_TYPES = 10 - MAX_EDGE_TYPES = 10 + MAX_ENTITY_TYPES = MAX_ONTOLOGY_TYPES + MAX_EDGE_TYPES = MAX_ONTOLOGY_TYPES # 去重:按 name 去重,保留首次出现的 seen_names = set() diff --git a/backend/app/utils/ontology.py b/backend/app/utils/ontology.py index 5c5ba578..d1596c68 100644 --- a/backend/app/utils/ontology.py +++ b/backend/app/utils/ontology.py @@ -1,6 +1,25 @@ """Helpers for validating LLM-generated ontology structures.""" -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional + + +MAX_ONTOLOGY_TYPES = 10 +MAX_ONTOLOGY_ATTRIBUTES = 10 +RESERVED_ONTOLOGY_ATTRIBUTE_NAMES = frozenset({ + "uuid", + "name", + "group_id", + "graph_id", + "name_embedding", + "summary", + "created_at", +}) + +_FALLBACK_ATTRIBUTE = { + "name": "details", + "type": "text", + "description": "Additional details about this ontology type.", +} def normalize_ontology_attribute(attribute: Any) -> Optional[Dict[str, Any]]: @@ -27,3 +46,24 @@ def normalize_ontology_attribute(attribute: Any) -> Optional[Dict[str, Any]]: if not isinstance(description, str) or not description: normalized["description"] = name return normalized + + +def normalize_ontology_attributes(attributes: Any) -> List[Dict[str, Any]]: + """Return a non-empty Zep-compatible attribute list within service limits.""" + + if not isinstance(attributes, list): + attributes = [] + + normalized_attributes: List[Dict[str, Any]] = [] + for attribute in attributes: + normalized = normalize_ontology_attribute(attribute) + if normalized is None: + continue + normalized_attributes.append(normalized) + if len(normalized_attributes) == MAX_ONTOLOGY_ATTRIBUTES: + break + + if not normalized_attributes: + normalized_attributes.append(dict(_FALLBACK_ATTRIBUTE)) + + return normalized_attributes diff --git a/backend/tests/test_ontology_attributes.py b/backend/tests/test_ontology_attributes.py index 87304fdd..38e96546 100644 --- a/backend/tests/test_ontology_attributes.py +++ b/backend/tests/test_ontology_attributes.py @@ -1,6 +1,14 @@ from app.services.ontology_generator import OntologyGenerator from app.services.graph_builder import GraphBuilderService -from app.utils.ontology import normalize_ontology_attribute +from app.utils.ontology import ( + MAX_ONTOLOGY_ATTRIBUTES, + normalize_ontology_attribute, + normalize_ontology_attributes, +) +from zep_cloud.external_clients.ontology import ( + edge_model_to_api_schema, + entity_model_to_api_schema, +) def test_normalize_string_attribute(): @@ -22,6 +30,22 @@ def test_reject_unusable_attribute_shapes(): assert normalize_ontology_attribute(value) is None +def test_attribute_list_is_non_empty_and_capped_for_zep(): + assert normalize_ontology_attributes(None) == [{ + "name": "details", + "type": "text", + "description": "Additional details about this ontology type.", + }] + + attributes = [None] + [f"field_{index}" for index in range(12)] + normalized = normalize_ontology_attributes(attributes) + + assert len(normalized) == MAX_ONTOLOGY_ATTRIBUTES + assert [attribute["name"] for attribute in normalized] == [ + f"field_{index}" for index in range(MAX_ONTOLOGY_ATTRIBUTES) + ] + + def test_generator_normalizes_entity_and_edge_attributes(): result = OntologyGenerator(llm_client=object())._validate_and_process({ "entity_types": [{"name": "speaker", "attributes": ["role", None]}], @@ -40,6 +64,16 @@ def test_generator_normalizes_entity_and_edge_attributes(): }] +def test_generator_adds_a_property_to_empty_custom_types(): + result = OntologyGenerator(llm_client=object())._validate_and_process({ + "entity_types": [{"name": "speaker", "attributes": []}], + "edge_types": [{"name": "quotes", "attributes": []}], + }) + + assert result["entity_types"][0]["attributes"][0]["name"] == "details" + assert result["edge_types"][0]["attributes"][0]["name"] == "details" + + def test_graph_builder_safety_net_accepts_strings_and_skips_invalid_values(): captured = {} @@ -62,3 +96,75 @@ def test_graph_builder_safety_net_accepts_strings_and_skips_invalid_values(): speaker = captured["entities"]["Speaker"] assert set(speaker.__annotations__) == {"role", "entity_summary"} + + +def test_graph_builder_emits_a_pinned_zep_sdk_compatible_schema(): + captured = {} + + class GraphApi: + def set_ontology(self, **kwargs): + captured.update(kwargs) + + class Client: + graph = GraphApi() + + builder = object.__new__(GraphBuilderService) + builder.client = Client() + builder.set_ontology("graph-id", { + "entity_types": [{ + "name": "Speaker", + "attributes": ["graph_id"] + [ + f"field_{index}" for index in range(10) + ], + }], + "edge_types": [{ + "name": "MENTIONS", + "attributes": [], + "source_targets": [{"source": "Speaker", "target": "Speaker"}], + }], + }) + + assert captured["graph_ids"] == ["graph-id"] + + speaker = captured["entities"]["Speaker"] + entity_schema = entity_model_to_api_schema(speaker, "Speaker") + assert len(entity_schema["properties"]) == MAX_ONTOLOGY_ATTRIBUTES + assert entity_schema["properties"][0] == { + "name": "entity_graph_id", + "type": "Text", + "description": "graph_id", + } + + mentions, source_targets = captured["edges"]["MENTIONS"] + edge_schema = edge_model_to_api_schema(mentions, "MENTIONS") + assert edge_schema["properties"] == [{ + "name": "details", + "type": "Text", + "description": "Additional details about this ontology type.", + }] + assert source_targets[0].source == "Speaker" + assert source_targets[0].target == "Speaker" + + +def test_graph_builder_passes_an_empty_entity_mapping_for_edge_only_ontology(): + captured = {} + + class GraphApi: + def set_ontology(self, **kwargs): + captured.update(kwargs) + + class Client: + graph = GraphApi() + + builder = object.__new__(GraphBuilderService) + builder.client = Client() + builder.set_ontology("graph-id", { + "entity_types": [], + "edge_types": [{ + "name": "RELATED_TO", + "attributes": ["reason"], + "source_targets": [{"source": "Entity", "target": "Entity"}], + }], + }) + + assert captured["entities"] == {}