""" 图谱构建服务 接口2:使用Zep API构建Standalone Graph """ import hashlib import uuid import time import threading from typing import Dict, Any, List, Optional, Callable from dataclasses import dataclass from zep_cloud import BatchAddItem, EntityEdgeSourceTarget, NotFoundError 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 ( MAX_ONTOLOGY_TYPES, RESERVED_ONTOLOGY_ATTRIBUTE_NAMES, normalize_ontology_attributes, normalize_ontology_source_targets, ) from ..utils.zep import ( call_zep_read_with_retry, get_zep_client, is_retryable_zep_error, ) from .text_processor import TextProcessor from ..utils.locale import t, get_locale, set_locale @dataclass class GraphInfo: """图谱信息""" graph_id: str node_count: int edge_count: int entity_types: List[str] def to_dict(self) -> Dict[str, Any]: return { "graph_id": self.graph_id, "node_count": self.node_count, "edge_count": self.edge_count, "entity_types": self.entity_types, } @dataclass(frozen=True) class BatchSubmission: """Durable identity for one Zep Batch API ingestion operation.""" batch_id: str operation_id: str episode_uuids: List[str] item_count: int class GraphBuilderService: """ 图谱构建服务 负责调用Zep API构建知识图谱 """ def __init__(self, api_key: Optional[str] = None): self.api_key = api_key or Config.ZEP_API_KEY if not self.api_key: raise ValueError("ZEP_API_KEY 未配置") self.client = get_zep_client(self.api_key) self.task_manager = TaskManager() def build_graph_async( self, text: str, ontology: Dict[str, Any], graph_name: str = "MiroFish Graph", chunk_size: int = 500, chunk_overlap: int = 50, batch_size: int = 350 ) -> str: """ 异步构建图谱 Args: text: 输入文本 ontology: 本体定义(来自接口1的输出) graph_name: 图谱名称 chunk_size: 文本块大小 chunk_overlap: 块重叠大小 batch_size: 每批发送的块数量 Returns: 任务ID """ # 创建任务 task_id = self.task_manager.create_task( task_type="graph_build", metadata={ "graph_name": graph_name, "chunk_size": chunk_size, "text_length": len(text), } ) # Capture locale before spawning background thread current_locale = get_locale() # 在后台线程中执行构建 thread = threading.Thread( target=self._build_graph_worker, args=(task_id, text, ontology, graph_name, chunk_size, chunk_overlap, batch_size, current_locale) ) thread.daemon = True thread.start() return task_id def _build_graph_worker( self, task_id: str, text: str, ontology: Dict[str, Any], graph_name: str, chunk_size: int, chunk_overlap: int, batch_size: int, locale: str = 'zh' ): """图谱构建工作线程""" set_locale(locale) try: self.task_manager.update_task( task_id, status=TaskStatus.PROCESSING, progress=5, message=t('progress.startBuildingGraph') ) # Validate the complete ingestion payload before the first Cloud # mutation, including this legacy service entry point. chunks = TextProcessor.split_text(text, chunk_size, chunk_overlap) self.validate_batch_chunks(chunks, batch_size=batch_size) total_chunks = len(chunks) # 1. 创建图谱 graph_id = self.create_graph(graph_name) self.task_manager.update_task( task_id, progress=10, message=t('progress.graphCreated', graphId=graph_id) ) # 2. 设置本体 self.set_ontology(graph_id, ontology) self.task_manager.update_task( task_id, progress=15, message=t('progress.ontologySet') ) # 3. 文本分块已在 Cloud mutation 前完成并验证 self.task_manager.update_task( task_id, progress=20, message=t('progress.textSplit', count=total_chunks) ) # 4. 分批发送数据 submission = self.add_text_batches( graph_id, chunks, batch_size, lambda msg, prog: self.task_manager.update_task( task_id, progress=20 + int(prog * 0.4), # 20-60% message=msg ) ) # 5. 等待Zep处理完成 self.task_manager.update_task( task_id, progress=60, message=t('progress.waitingZepProcess') ) self._wait_for_batch( submission, lambda msg, prog: self.task_manager.update_task( task_id, progress=60 + int(prog * 0.3), # 60-90% message=msg ) ) # 6. 获取图谱信息 self.task_manager.update_task( task_id, progress=90, message=t('progress.fetchingGraphInfo') ) graph_info = self._get_graph_info(graph_id) # 完成 self.task_manager.complete_task(task_id, { "graph_id": graph_id, "graph_info": graph_info.to_dict(), "chunks_processed": total_chunks, }) except Exception as e: import traceback error_msg = f"{str(e)}\n{traceback.format_exc()}" self.task_manager.fail_task(task_id, error_msg) def create_graph( self, name: str, *, graph_id: str | None = None, graph_id_callback: Optional[Callable[[str], None]] = None, ) -> str: """Create a graph with a caller-durable ID and reconcile lost replies.""" graph_id = graph_id or f"mirofish_{uuid.uuid4().hex[:16]}" # Persist the client-generated ID before the non-idempotent POST so a # later reset can clean up a graph whose successful response was lost. if graph_id_callback: graph_id_callback(graph_id) try: self.client.graph.create( graph_id=graph_id, name=name, description="MiroFish Social Simulation Graph" ) except Exception as error: if not is_retryable_zep_error(error): raise reconciliation_error = None for attempt in range(3): try: call_zep_read_with_retry( lambda: self.client.graph.get(graph_id), operation_name=f"reconcile graph create {graph_id}", ) reconciliation_error = None break except NotFoundError as not_found: reconciliation_error = not_found if attempt < 2: time.sleep(attempt + 1) except Exception as read_error: reconciliation_error = read_error break if reconciliation_error is not None: raise error from reconciliation_error return graph_id @staticmethod def build_operation_id(graph_id: str, chunks: List[str]) -> str: payload_hash = hashlib.sha256("\0".join(chunks).encode("utf-8")).hexdigest() return hashlib.sha256( f"{graph_id}:{payload_hash}".encode("utf-8") ).hexdigest() def _find_batch_by_operation_id( self, graph_id: str, operation_id: str, *, max_attempts: int = 3, ) -> Any | None: """Find one server-created batch after an ambiguous create reply.""" for attempt in range(1, max_attempts + 1): matches: List[Any] = [] cursor: int | None = None seen_cursors: set[int] = set() while True: page = call_zep_read_with_retry( lambda: self.client.batch.list(limit=100, cursor=cursor), operation_name=f"reconcile batch create {operation_id}", ) for batch in getattr(page, "batches", None) or []: metadata = getattr(batch, "metadata", None) or {} if ( metadata.get("mirofish_operation_id") == operation_id and metadata.get("graph_id") == graph_id ): matches.append(batch) next_cursor = getattr(page, "next_cursor", None) if next_cursor is None: break if next_cursor == cursor or next_cursor in seen_cursors: raise RuntimeError("Zep batch list cursor did not advance") seen_cursors.add(next_cursor) cursor = next_cursor if len(matches) > 1: raise RuntimeError( f"Multiple Zep batches match operation {operation_id}; refusing ambiguity" ) if matches: return matches[0] if attempt < max_attempts: time.sleep(attempt) return None def set_ontology(self, graph_id: str, ontology: Dict[str, Any]): """设置图谱本体(公开方法)""" import warnings from typing import Optional from pydantic import Field from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel # 抑制 Pydantic v2 关于 Field(default=None) 的警告 # 这是 Zep SDK 要求的用法,警告来自动态类创建,可以安全忽略 warnings.filterwarnings('ignore', category=UserWarning, module='pydantic') def safe_attr_name(attr_name: str) -> str: """将保留名称转换为安全名称""" 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", [])[:MAX_ONTOLOGY_TYPES]: name = entity_def["name"] description = entity_def.get("description", f"A {name} entity.") # 创建属性字典和类型注解(Pydantic v2 需要) attrs = {"__doc__": description} annotations = {} 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,这是必需的 attrs[attr_name] = Field(description=attr_desc, default=None) annotations[attr_name] = Optional[EntityText] # 类型注解 attrs["__annotations__"] = annotations # 动态创建类 entity_class = type(name, (EntityModel,), attrs) entity_class.__doc__ = description entity_types[name] = entity_class # 动态创建边类型 edge_definitions = {} for edge_def in ontology.get("edge_types", [])[:MAX_ONTOLOGY_TYPES]: name = edge_def["name"] description = edge_def.get("description", f"A {name} relationship.") # 创建属性字典和类型注解 attrs = {"__doc__": description} annotations = {} 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,这是必需的 attrs[attr_name] = Field(description=attr_desc, default=None) annotations[attr_name] = Optional[str] # 边属性用str类型 attrs["__annotations__"] = annotations # 动态创建类 class_name = ''.join(word.capitalize() for word in name.split('_')) edge_class = type(class_name, (EdgeModel,), attrs) edge_class.__doc__ = description # 构建source_targets source_targets = [] for st in normalize_ontology_source_targets( edge_def.get("source_targets", []) ): source_targets.append( EntityEdgeSourceTarget( source=st.get("source", "Entity"), target=st.get("target", "Entity") ) ) if source_targets: edge_definitions[name] = (edge_class, source_targets) # 调用Zep API设置本体 if entity_types or edge_definitions: self.client.graph.set_ontology( graph_ids=[graph_id], # Zep iterates entities.items(), so edge-only ontologies must # pass an empty dictionary rather than None. entities=entity_types, edges=edge_definitions if edge_definitions else None, ) def add_text_batches( self, graph_id: str, chunks: List[str], batch_size: int = 350, progress_callback: Optional[Callable] = None, batch_created_callback: Optional[Callable[[str | None, str], None]] = None, ) -> BatchSubmission: """Submit document chunks through Zep's current Batch API. Mutating calls are deliberately not retried: create/add are not documented as idempotent, and an ambiguous replay can duplicate graph episodes. The returned batch identity allows callers to persist and reconcile the operation instead. """ if not graph_id: raise ValueError("graph_id is required") self.validate_batch_chunks(chunks, batch_size=batch_size) total_chunks = len(chunks) operation_id = self.build_operation_id(graph_id, chunks) if batch_created_callback: # Journal the deterministic operation before the server-generated # batch ID POST. This leaves enough identity for later diagnosis # even if both the response and immediate list reconciliation fail. batch_created_callback(None, operation_id) try: batch = self.client.batch.create( metadata={ "mirofish_operation_id": operation_id, "graph_id": graph_id, "chunk_count": total_chunks, } ) except Exception as error: if not is_retryable_zep_error(error): raise batch = self._find_batch_by_operation_id(graph_id, operation_id) if batch is None: raise RuntimeError( "Zep batch creation is unconfirmed and no matching operation was found" ) from error batch_id = getattr(batch, "batch_id", None) if not batch_id: raise RuntimeError("Zep Batch API returned no batch_id") if batch_created_callback: batch_created_callback(batch_id, operation_id) episode_uuids: List[str] = [] for i in range(0, total_chunks, batch_size): batch_chunks = chunks[i:i + batch_size] batch_num = i // batch_size + 1 total_batches = (total_chunks + batch_size - 1) // batch_size if progress_callback: progress = (i + len(batch_chunks)) / total_chunks progress_callback( t('progress.sendingBatch', current=batch_num, total=total_batches, chunks=len(batch_chunks)), progress ) items = [ BatchAddItem( type="graph_episode", graph_id=graph_id, data=chunk, data_type="text", source_description="MiroFish source document chunk", metadata={ "mirofish_operation_id": operation_id, "chunk_index": i + offset, "chunk_sha256": hashlib.sha256( chunk.encode("utf-8") ).hexdigest(), }, ) for offset, chunk in enumerate(batch_chunks) ] expected_item_count = i + len(items) try: item_details = self.client.batch.add( batch_id=batch_id, items=items, ) except Exception as e: if progress_callback: progress_callback(t('progress.batchFailed', batch=batch_num, error=str(e)), 0) if is_retryable_zep_error(e): recovered_items = self._reconcile_batch_item_count( batch_id, expected_item_count, ) recovered_indexes = { getattr(item, "sequence_index", None) for item in recovered_items } if ( len(recovered_items) == expected_item_count and recovered_indexes == set(range(expected_item_count)) ): item_details = recovered_items[i:expected_item_count] else: raise RuntimeError( f"Zep batch {batch_id} item submission is unconfirmed; " "the draft was not processed or replayed" ) from e else: raise RuntimeError( f"Zep batch {batch_id} item submission failed" ) from e if len(item_details or []) != len(items): recovered_items = self._reconcile_batch_item_count( batch_id, expected_item_count, ) recovered_indexes = { getattr(item, "sequence_index", None) for item in recovered_items } if ( len(recovered_items) == expected_item_count and recovered_indexes == set(range(expected_item_count)) ): item_details = recovered_items[i:expected_item_count] else: raise RuntimeError( f"Zep batch {batch_id} acknowledged {len(item_details or [])} " f"of {len(items)} items" ) for item in item_details: episode_uuid = getattr(item, "episode_uuid", None) if episode_uuid: episode_uuids.append(episode_uuid) try: self.client.batch.process(batch_id=batch_id) except Exception as error: # A process response can be lost after the server accepted it. # Reconcile with a safe GET instead of issuing a second POST. summary = call_zep_read_with_retry( lambda: self.client.batch.get(batch_id=batch_id), operation_name=f"reconcile batch {batch_id}", ) if getattr(summary, "status", None) in {None, "draft"}: raise RuntimeError( f"Zep batch {batch_id} processing is unconfirmed" ) from error return BatchSubmission( batch_id=batch_id, operation_id=operation_id, episode_uuids=episode_uuids, item_count=total_chunks, ) @staticmethod def validate_batch_chunks(chunks: List[str], *, batch_size: int = 350) -> None: """Validate every Batch API limit before the first Cloud mutation.""" if not chunks: raise ValueError("At least one text chunk is required") if not 1 <= batch_size <= 350: raise ValueError("batch_size must be between 1 and 350") if len(chunks) > 50_000: raise ValueError("A Zep batch cannot contain more than 50,000 items") oversized = [index for index, chunk in enumerate(chunks) if len(chunk) > 10_000] if oversized: raise ValueError( f"Zep batch item exceeds 10,000 characters at chunk {oversized[0]}" ) def _list_batch_items(self, batch_id: str) -> List[Any]: items: List[Any] = [] cursor: int | None = None seen_cursors: set[int] = set() while True: page = call_zep_read_with_retry( lambda: self.client.batch.list_items( batch_id=batch_id, limit=100, cursor=cursor, ), operation_name=f"list batch items {batch_id}", ) items.extend(getattr(page, "items", None) or []) next_cursor = getattr(page, "next_cursor", None) if next_cursor is None: break if next_cursor == cursor or next_cursor in seen_cursors: raise RuntimeError(f"Zep batch {batch_id} item cursor did not advance") seen_cursors.add(next_cursor) cursor = next_cursor return items def _reconcile_batch_item_count( self, batch_id: str, expected_item_count: int, *, max_attempts: int = 3, ) -> List[Any]: """Allow a short propagation window after an ambiguous add reply.""" items: List[Any] = [] for attempt in range(1, max_attempts + 1): items = self._list_batch_items(batch_id) if len(items) >= expected_item_count: return items if attempt < max_attempts: time.sleep(attempt) return items def get_batch_summary(self, batch_id: str) -> Any: """Read a persisted batch identity for restart reconciliation.""" return call_zep_read_with_retry( lambda: self.client.batch.get(batch_id=batch_id), operation_name=f"get batch {batch_id}", ) def _wait_for_batch( self, submission: BatchSubmission, progress_callback: Optional[Callable] = None, timeout: int | None = None, ) -> List[str]: """Wait for a Batch API terminal state and validate every item.""" timeout = timeout or Config.ZEP_INGESTION_TIMEOUT_SECONDS start_time = time.time() terminal_states = {"succeeded", "partial", "failed", "invalid", "canceled"} while True: if time.time() - start_time > timeout: raise TimeoutError( f"Zep batch {submission.batch_id} did not finish within {timeout}s" ) summary = call_zep_read_with_retry( lambda: self.client.batch.get(batch_id=submission.batch_id), operation_name=f"poll batch {submission.batch_id}", ) status = getattr(summary, "status", None) progress = getattr(summary, "progress", None) percent = float(getattr(progress, "percent_complete", 0) or 0) / 100 if progress_callback: completed = int(getattr(progress, "succeeded_items", 0) or 0) progress_callback( t( 'progress.zepProcessing', completed=completed, total=submission.item_count, pending=max(submission.item_count - completed, 0), elapsed=int(time.time() - start_time), ), min(max(percent, 0.0), 1.0), ) if status in terminal_states: break time.sleep(3) items = self._list_batch_items(submission.batch_id) if status != "succeeded": failed_items = [ item for item in items if getattr(item, "status", None) not in {"succeeded", "skipped"} ] first_error = getattr(failed_items[0], "error", None) if failed_items else None raise RuntimeError( f"Zep batch {submission.batch_id} ended as {status}; " f"failed_items={len(failed_items)}; first_error={first_error}" ) if len(items) != submission.item_count: raise RuntimeError( f"Zep batch {submission.batch_id} contains {len(items)} items, " f"expected {submission.item_count}" ) ordered_items = sorted( items, key=lambda item: getattr(item, "sequence_index", 0) or 0, ) episode_uuids: List[str] = [] for item in ordered_items: item_status = getattr(item, "status", None) episode_uuid = getattr(item, "episode_uuid", None) source_uuid = getattr(item, "source_uuid", None) if item_status != "succeeded" or not episode_uuid: raise RuntimeError( f"Zep batch {submission.batch_id} returned an incomplete item" ) if source_uuid and source_uuid != episode_uuid: raise RuntimeError( f"Zep batch {submission.batch_id} returned mismatched episode UUIDs" ) episode_uuids.append(episode_uuid) if progress_callback: progress_callback( t( 'progress.processingComplete', completed=len(episode_uuids), total=submission.item_count, ), 1.0, ) return episode_uuids def _wait_for_episodes( self, episode_uuids: List[str], progress_callback: Optional[Callable] = None, timeout: int = 600 ): """等待所有 episode 处理完成(通过查询每个 episode 的 processed 状态)""" if not episode_uuids: if progress_callback: progress_callback(t('progress.noEpisodesWait'), 1.0) return start_time = time.time() pending_episodes = set(episode_uuids) completed_count = 0 total_episodes = len(episode_uuids) if progress_callback: progress_callback(t('progress.waitingEpisodes', count=total_episodes), 0) while pending_episodes: if time.time() - start_time > timeout: if progress_callback: progress_callback( t('progress.episodesTimeout', completed=completed_count, total=total_episodes), completed_count / total_episodes ) raise TimeoutError( f"Zep episode processing timed out with " f"{len(pending_episodes)} episode(s) still pending" ) # 检查每个 episode 的处理状态 for ep_uuid in list(pending_episodes): episode = call_zep_read_with_retry( lambda: self.client.graph.episode.get(uuid_=ep_uuid), operation_name=f"poll episode {ep_uuid}", ) is_processed = getattr(episode, 'processed', False) if is_processed: pending_episodes.remove(ep_uuid) completed_count += 1 elapsed = int(time.time() - start_time) if progress_callback: progress_callback( t('progress.zepProcessing', completed=completed_count, total=total_episodes, pending=len(pending_episodes), elapsed=elapsed), completed_count / total_episodes if total_episodes > 0 else 0 ) if pending_episodes: time.sleep(3) # 每3秒检查一次 if progress_callback: progress_callback(t('progress.processingComplete', completed=completed_count, total=total_episodes), 1.0) def _get_graph_info(self, graph_id: str) -> GraphInfo: """获取图谱信息""" # 获取节点(分页) nodes = fetch_all_nodes(self.client, graph_id) # 获取边(分页) edges = fetch_all_edges(self.client, graph_id) # 统计实体类型 entity_types = set() for node in nodes: if node.labels: for label in node.labels: if label not in ["Entity", "Node"]: entity_types.add(label) return GraphInfo( graph_id=graph_id, node_count=len(nodes), edge_count=len(edges), entity_types=list(entity_types) ) def get_graph_data(self, graph_id: str) -> Dict[str, Any]: """ 获取完整图谱数据(包含详细信息) Args: graph_id: 图谱ID Returns: 包含nodes和edges的字典,包括时间信息、属性等详细数据 """ nodes = fetch_all_nodes(self.client, graph_id) edges = fetch_all_edges(self.client, graph_id) # 创建节点映射用于获取节点名称 node_map = {} for node in nodes: node_map[node.uuid_] = node.name or "" nodes_data = [] for node in nodes: # 获取创建时间 created_at = getattr(node, 'created_at', None) if created_at: created_at = str(created_at) nodes_data.append({ "uuid": node.uuid_, "name": node.name, "labels": node.labels or [], "summary": node.summary or "", "attributes": node.attributes or {}, "created_at": created_at, }) edges_data = [] for edge in edges: # 获取时间信息 created_at = getattr(edge, 'created_at', None) valid_at = getattr(edge, 'valid_at', None) invalid_at = getattr(edge, 'invalid_at', None) expired_at = getattr(edge, 'expired_at', None) # 获取 episodes episodes = getattr(edge, 'episodes', None) or getattr(edge, 'episode_ids', None) if episodes and not isinstance(episodes, list): episodes = [str(episodes)] elif episodes: episodes = [str(e) for e in episodes] # 获取 fact_type fact_type = getattr(edge, 'fact_type', None) or edge.name or "" edges_data.append({ "uuid": edge.uuid_, "name": edge.name or "", "fact": edge.fact or "", "fact_type": fact_type, "source_node_uuid": edge.source_node_uuid, "target_node_uuid": edge.target_node_uuid, "source_node_name": node_map.get(edge.source_node_uuid, ""), "target_node_name": node_map.get(edge.target_node_uuid, ""), "attributes": edge.attributes or {}, "created_at": str(created_at) if created_at else None, "valid_at": str(valid_at) if valid_at else None, "invalid_at": str(invalid_at) if invalid_at else None, "expired_at": str(expired_at) if expired_at else None, "episodes": episodes or [], }) return { "graph_id": graph_id, "nodes": nodes_data, "edges": edges_data, "node_count": len(nodes_data), "edge_count": len(edges_data), } def delete_graph(self, graph_id: str): """删除图谱""" self.client.graph.delete(graph_id=graph_id)