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] 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"] == {}