diff --git a/.env.example b/.env.example index 78a3b72c..a83e18bb 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,18 @@ LLM_API_KEY=your_api_key_here LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 LLM_MODEL_NAME=qwen-plus -# ===== ZEP记忆图谱配置 ===== +# ===== 图谱构建引擎配置 ===== +# 默认引擎:"claude" 或 "zep",也可在前端 Step 02 卡片中按项目切换 +GRAPH_ENGINE_DEFAULT=claude + +# ----- Claude 图谱引擎(默认,Graphify 风格的 Claude Code 智能体抽取) ----- +# https://console.anthropic.com/ +ANTHROPIC_API_KEY=your_anthropic_api_key_here +CLAUDE_MODEL_NAME=claude-sonnet-5 +# 如需通过自建网关/代理访问 Anthropic API,可选填 +# ANTHROPIC_BASE_URL=https://your-proxy.example.com + +# ----- Zep记忆图谱配置(可选,切换引擎为 zep 时需要) ----- # 每月免费额度即可支撑简单使用:https://app.getzep.com/ ZEP_API_KEY=your_zep_api_key_here diff --git a/README-ZH.md b/README-ZH.md index 0b20424d..3a77f0c0 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -40,6 +40,29 @@ MiroFish 致力于打造映射现实的群体智能镜像,通过捕捉个体 从严肃预测到趣味仿真,我们让每一个如果都能看见结果,让预测万物成为可能。 +## 🧠 本 Fork:Claude Code 图谱引擎 + +本 Fork 在原有 Zep 图谱引擎之外,新增了一个由 Claude 驱动的图谱构建引擎,采用 **Graphify 风格**的理念: +基于本体约束、逐块透明抽取,让图谱的构建过程节点级可见、可追溯。 + +- **智能体驱动的抽取**:不再依赖 Zep Cloud,每个文本块会连同由本体(实体类型、关系类型、允许的 + source/target)动态生成的结构化 `tool_use` schema 一起发送给 Claude,只抽取文本中明确出现的事实。 +- **本地可检查的图谱存储**:抽取结果(节点、边、事实、来源)以 JSON 形式按项目本地持久化,无需外部图数据库即可体验。 +- **即插即用,可视化不变**:Claude 引擎实现了与 Zep 引擎完全一致的服务接口(`create_graph`、`set_ontology`、 + `add_text_batches`、`get_graph_data`、`delete_graph`),因此现有的 D3 图谱面板、实体图例、节点/边详情面板无需改动即可复用。 +- **按项目切换**:在图谱构建步骤中通过 Claude / Zep 切换按钮选择引擎,默认使用 Claude。 + +在 `.env` 中配置: + +```bash +GRAPH_ENGINE_DEFAULT=claude +ANTHROPIC_API_KEY=your_anthropic_api_key_here +CLAUDE_MODEL_NAME=claude-sonnet-5 +``` + +详见 `backend/app/services/claude_graph_builder.py`(抽取智能体)与 +`backend/app/models/graph_store.py`(本地图谱持久化层)。 + ## 🌐 在线体验 欢迎访问在线 Demo 演示环境,体验我们为你准备的一次关于热点舆情事件的推演预测:[mirofish-live-demo](https://666ghj.github.io/mirofish-demo/) diff --git a/README.md b/README.md index 4b8369f4..6885a8ed 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,35 @@ MiroFish is dedicated to creating a swarm intelligence mirror that maps reality. From serious predictions to playful simulations, we let every "what if" see its outcome, making it possible to predict anything. +## 🧠 This Fork: Claude Code Graph Engine + +This fork adds a second, Claude-powered graph construction engine alongside the original Zep-based one, with a +**Graphify-style** philosophy: transparent, incremental, ontology-constrained entity/relationship extraction that +you can watch build up node by node. + +- **Agent-driven extraction**: instead of delegating extraction to Zep Cloud, each text chunk is sent to Claude + with a structured `tool_use` schema derived from your generated ontology (entity types, edge types, allowed + source/target pairs). Claude returns only entities and relationships that are explicitly grounded in that + fragment — no hallucinated facts, no silent inference. +- **Local, inspectable graph store**: the resulting graph (nodes, edges, facts, provenance) is persisted as plain + JSON per project — no external graph database required to try it out. +- **Drop-in engine, same visualization**: the Claude engine implements the exact same service interface as the + Zep engine (`create_graph`, `set_ontology`, `add_text_batches`, `get_graph_data`, `delete_graph`), so the + existing D3 graph panel, entity legend, and node/edge inspector work unmodified. +- **Pick per project**: choose the engine ("Claude" or "Zep") from a pill toggle on the Graph Build step before + building — Claude is the default. + +Configure it via `.env`: + +```bash +GRAPH_ENGINE_DEFAULT=claude +ANTHROPIC_API_KEY=your_anthropic_api_key_here +CLAUDE_MODEL_NAME=claude-sonnet-5 +``` + +See `backend/app/services/claude_graph_builder.py` for the extraction agent and +`backend/app/models/graph_store.py` for the local graph persistence layer. + ## 🌐 Live Demo Welcome to visit our online demo environment and experience a prediction simulation on trending public opinion events we've prepared for you: [mirofish-live-demo](https://666ghj.github.io/mirofish-demo/) diff --git a/backend/app/api/graph.py b/backend/app/api/graph.py index 759ff48b..544a15e3 100644 --- a/backend/app/api/graph.py +++ b/backend/app/api/graph.py @@ -12,6 +12,7 @@ from . import graph_bp from ..config import Config from ..services.ontology_generator import OntologyGenerator from ..services.graph_builder import GraphBuilderService +from ..services.claude_graph_builder import ClaudeGraphBuilderService from ..services.text_processor import TextProcessor from ..utils.file_parser import FileParser from ..utils.logger import get_logger @@ -22,6 +23,9 @@ from ..models.project import ProjectManager, ProjectStatus # 获取日志器 logger = get_logger('mirofish.api') +# 支持的图谱构建引擎 +GRAPH_ENGINES = ('claude', 'zep') + def allowed_file(filename: str) -> bool: """检查文件扩展名是否允许""" @@ -31,6 +35,20 @@ def allowed_file(filename: str) -> bool: return ext in Config.ALLOWED_EXTENSIONS +def get_graph_builder(engine: str): + """按引擎名称创建对应的图谱构建服务实例""" + if engine == 'claude': + return ClaudeGraphBuilderService(api_key=Config.ANTHROPIC_API_KEY) + if engine == 'zep': + return GraphBuilderService(api_key=Config.ZEP_API_KEY) + raise ValueError(t('api.unknownEngine', engine=engine)) + + +def infer_engine_from_graph_id(graph_id: str) -> str: + """从 graph_id 命名规则推断所属引擎(claude 引擎的 id 带有 _claude_ 标记)""" + return 'claude' if '_claude_' in graph_id else 'zep' + + # ============== 项目管理接口 ============== @graph_bp.route('/project/', methods=['GET']) @@ -107,9 +125,10 @@ def reset_project(project_id: str): project.graph_id = None project.graph_build_task_id = None + project.graph_engine = None project.error = None ProjectManager.save_project(project) - + return jsonify({ "success": True, "message": t('api.projectReset', id=project_id), @@ -282,23 +301,32 @@ def build_graph(): """ try: logger.info("=== 开始构建图谱 ===") - - # 检查配置 + + # 解析请求 + data = request.get_json() or {} + project_id = data.get('project_id') + engine = data.get('engine', Config.GRAPH_ENGINE_DEFAULT) + logger.debug(f"请求参数: project_id={project_id}, engine={engine}") + + if engine not in GRAPH_ENGINES: + return jsonify({ + "success": False, + "error": t('api.unknownEngine', engine=engine) + }), 400 + + # 检查所选引擎所需的配置 errors = [] - if not Config.ZEP_API_KEY: + if engine == 'zep' and not Config.ZEP_API_KEY: errors.append(t('api.zepApiKeyMissing')) + if engine == 'claude' and not Config.ANTHROPIC_API_KEY: + errors.append(t('api.anthropicApiKeyMissing')) if errors: logger.error(f"配置错误: {errors}") return jsonify({ "success": False, "error": t('api.configError', details="; ".join(errors)) }), 500 - - # 解析请求 - data = request.get_json() or {} - project_id = data.get('project_id') - logger.debug(f"请求参数: project_id={project_id}") - + if not project_id: return jsonify({ "success": False, @@ -369,8 +397,9 @@ def build_graph(): # 更新项目状态 project.status = ProjectStatus.GRAPH_BUILDING project.graph_build_task_id = task_id + project.graph_engine = engine ProjectManager.save_project(project) - + # Capture locale before spawning background thread current_locale = get_locale() @@ -386,8 +415,8 @@ def build_graph(): message=t('progress.initGraphService') ) - # 创建图谱构建服务 - builder = GraphBuilderService(api_key=Config.ZEP_API_KEY) + # 创建图谱构建服务(按所选引擎) + builder = get_graph_builder(engine) # 分块 task_manager.update_task( @@ -405,7 +434,7 @@ def build_graph(): # 创建图谱 task_manager.update_task( task_id, - message=t('progress.creatingZepGraph'), + message=t('progress.creatingClaudeGraph') if engine == 'claude' else t('progress.creatingZepGraph'), progress=10 ) graph_id = builder.create_graph(name=graph_name) @@ -444,10 +473,10 @@ def build_graph(): progress_callback=add_progress_callback ) - # 等待Zep处理完成(查询每个episode的processed状态) + # 等待处理完成(Zep 引擎需轮询 episode 状态,Claude 引擎是同步抽取) task_manager.update_task( task_id, - message=t('progress.waitingZepProcess'), + message=t('progress.claudeExtractionDone') if engine == 'claude' else t('progress.waitingZepProcess'), progress=55 ) @@ -572,20 +601,27 @@ def get_graph_data(graph_id: str): 获取图谱数据(节点和边) """ try: - if not Config.ZEP_API_KEY: + engine = request.args.get('engine') or infer_engine_from_graph_id(graph_id) + + if engine == 'zep' and not Config.ZEP_API_KEY: return jsonify({ "success": False, "error": t('api.zepApiKeyMissing') }), 500 - - builder = GraphBuilderService(api_key=Config.ZEP_API_KEY) + if engine == 'claude' and not Config.ANTHROPIC_API_KEY: + return jsonify({ + "success": False, + "error": t('api.anthropicApiKeyMissing') + }), 500 + + builder = get_graph_builder(engine) graph_data = builder.get_graph_data(graph_id) - + return jsonify({ "success": True, "data": graph_data }) - + except Exception as e: return jsonify({ "success": False, @@ -597,23 +633,30 @@ def get_graph_data(graph_id: str): @graph_bp.route('/delete/', methods=['DELETE']) def delete_graph(graph_id: str): """ - 删除Zep图谱 + 删除图谱(Claude 本地图谱或 Zep 云端图谱) """ try: - if not Config.ZEP_API_KEY: + engine = request.args.get('engine') or infer_engine_from_graph_id(graph_id) + + if engine == 'zep' and not Config.ZEP_API_KEY: return jsonify({ "success": False, "error": t('api.zepApiKeyMissing') }), 500 - - builder = GraphBuilderService(api_key=Config.ZEP_API_KEY) + if engine == 'claude' and not Config.ANTHROPIC_API_KEY: + return jsonify({ + "success": False, + "error": t('api.anthropicApiKeyMissing') + }), 500 + + builder = get_graph_builder(engine) builder.delete_graph(graph_id) - + return jsonify({ "success": True, "message": t('api.graphDeleted', id=graph_id) }) - + except Exception as e: return jsonify({ "success": False, diff --git a/backend/app/config.py b/backend/app/config.py index 953dfa50..d313c804 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -34,7 +34,15 @@ class Config: # Zep配置 ZEP_API_KEY = os.environ.get('ZEP_API_KEY') - + + # Claude图谱引擎配置(Anthropic API,作为图谱构建的智能体) + ANTHROPIC_API_KEY = os.environ.get('ANTHROPIC_API_KEY') + ANTHROPIC_BASE_URL = os.environ.get('ANTHROPIC_BASE_URL') + CLAUDE_MODEL_NAME = os.environ.get('CLAUDE_MODEL_NAME', 'claude-sonnet-5') + + # 图谱构建默认引擎:"claude" 或 "zep" + GRAPH_ENGINE_DEFAULT = os.environ.get('GRAPH_ENGINE_DEFAULT', 'claude') + # 文件上传配置 MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), '../uploads') @@ -69,7 +77,10 @@ class Config: errors = [] if not cls.LLM_API_KEY: errors.append("LLM_API_KEY 未配置") - if not cls.ZEP_API_KEY: + # 图谱构建引擎所需的密钥按默认引擎校验,另一个引擎仍可在请求时按需选用 + if cls.GRAPH_ENGINE_DEFAULT == 'zep' and not cls.ZEP_API_KEY: errors.append("ZEP_API_KEY 未配置") + if cls.GRAPH_ENGINE_DEFAULT == 'claude' and not cls.ANTHROPIC_API_KEY: + errors.append("ANTHROPIC_API_KEY 未配置") return errors diff --git a/backend/app/models/graph_store.py b/backend/app/models/graph_store.py new file mode 100644 index 00000000..21ebe28c --- /dev/null +++ b/backend/app/models/graph_store.py @@ -0,0 +1,71 @@ +""" +本地图谱存储 +为 Claude 图谱引擎提供轻量级的 JSON 持久化存储 +(Zep 引擎使用 Zep Cloud 托管图谱,Claude 引擎使用本地存储) +""" + +import os +import json +import threading +from datetime import datetime +from typing import Dict, Any, Optional + +from ..config import Config + + +class GraphStore: + """基于 JSON 文件的图谱存储,按 graph_id 持久化 nodes/edges/ontology""" + + GRAPHS_DIR = os.path.join(Config.UPLOAD_FOLDER, 'graphs') + _lock = threading.Lock() + + @classmethod + def _ensure_dir(cls): + os.makedirs(cls.GRAPHS_DIR, exist_ok=True) + + @classmethod + def _path(cls, graph_id: str) -> str: + return os.path.join(cls.GRAPHS_DIR, f"{graph_id}.json") + + @classmethod + def create(cls, graph_id: str, name: str, description: str = "") -> Dict[str, Any]: + cls._ensure_dir() + data = { + "graph_id": graph_id, + "name": name, + "description": description, + "engine": "claude", + "ontology": None, + "nodes": {}, # uuid -> node dict + "edges": [], # list of edge dicts + "created_at": datetime.now().isoformat(), + } + cls.save(graph_id, data) + return data + + @classmethod + def load(cls, graph_id: str) -> Optional[Dict[str, Any]]: + path = cls._path(graph_id) + if not os.path.exists(path): + return None + with cls._lock: + with open(path, 'r', encoding='utf-8') as f: + return json.load(f) + + @classmethod + def save(cls, graph_id: str, data: Dict[str, Any]) -> None: + cls._ensure_dir() + path = cls._path(graph_id) + with cls._lock: + tmp_path = f"{path}.tmp" + with open(tmp_path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(tmp_path, path) + + @classmethod + def delete(cls, graph_id: str) -> bool: + path = cls._path(graph_id) + if not os.path.exists(path): + return False + os.remove(path) + return True diff --git a/backend/app/models/project.py b/backend/app/models/project.py index 08978937..ae615634 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -43,6 +43,7 @@ class Project: # 图谱信息(接口2完成后填充) graph_id: Optional[str] = None graph_build_task_id: Optional[str] = None + graph_engine: Optional[str] = None # "claude" 或 "zep" # 配置 simulation_requirement: Optional[str] = None @@ -66,6 +67,7 @@ class Project: "analysis_summary": self.analysis_summary, "graph_id": self.graph_id, "graph_build_task_id": self.graph_build_task_id, + "graph_engine": self.graph_engine, "simulation_requirement": self.simulation_requirement, "chunk_size": self.chunk_size, "chunk_overlap": self.chunk_overlap, @@ -91,6 +93,7 @@ class Project: analysis_summary=data.get('analysis_summary'), graph_id=data.get('graph_id'), graph_build_task_id=data.get('graph_build_task_id'), + graph_engine=data.get('graph_engine'), simulation_requirement=data.get('simulation_requirement'), chunk_size=data.get('chunk_size', 500), chunk_overlap=data.get('chunk_overlap', 50), diff --git a/backend/app/services/claude_graph_builder.py b/backend/app/services/claude_graph_builder.py new file mode 100644 index 00000000..6dc5f585 --- /dev/null +++ b/backend/app/services/claude_graph_builder.py @@ -0,0 +1,352 @@ +""" +图谱构建服务 - Claude 引擎 +使用 Claude(Anthropic API)作为图谱构建的智能体,对文本分片进行实体/关系抽取, +按照 Graphify 的思路做"增量式、透明化"的图谱构建:每个文本块都会被 Claude +以结构化 tool-use 的方式抽取实体与关系,逐步合并进本地图谱存储。 + +与 GraphBuilderService(Zep 引擎)保持一致的公开接口,可在 API 层互换使用: + create_graph / set_ontology / add_text_batches / _wait_for_episodes / + get_graph_data / delete_graph +""" + +import uuid +from datetime import datetime +from typing import Dict, Any, List, Optional, Callable + +import anthropic + +from ..config import Config +from ..models.graph_store import GraphStore +from ..utils.locale import t, get_language_instruction + + +def _extraction_tool(ontology: Dict[str, Any]) -> Dict[str, Any]: + """根据本体定义动态构建 Claude tool-use 的抽取工具schema""" + entity_names = [e["name"] for e in ontology.get("entity_types", [])] or ["Entity"] + edge_names = [e["name"] for e in ontology.get("edge_types", [])] or ["RELATED_TO"] + + return { + "name": "record_graph_fragment", + "description": ( + "Record the entities and relationships that are explicitly grounded in the " + "given text fragment, strictly following the provided ontology types." + ), + "input_schema": { + "type": "object", + "properties": { + "entities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Canonical name of the entity, consistent across mentions in the whole document." + }, + "type": {"type": "string", "enum": entity_names}, + "summary": { + "type": "string", + "description": "One-sentence summary of this entity grounded in the text." + }, + "attributes": { + "type": "object", + "description": "Key/value attributes for this entity matching its ontology type, string values only.", + "additionalProperties": {"type": "string"} + } + }, + "required": ["name", "type"] + } + }, + "relationships": { + "type": "array", + "items": { + "type": "object", + "properties": { + "source": {"type": "string", "description": "Name of the source entity, must match one of the entities above."}, + "target": {"type": "string", "description": "Name of the target entity, must match one of the entities above."}, + "relation": {"type": "string", "enum": edge_names}, + "fact": {"type": "string", "description": "The specific fact/sentence from the text that supports this relationship."} + }, + "required": ["source", "target", "relation", "fact"] + } + } + }, + "required": ["entities", "relationships"] + } + } + + +def _system_prompt(ontology: Dict[str, Any]) -> str: + entity_lines = [] + for e in ontology.get("entity_types", []): + entity_lines.append(f"- {e['name']}: {e.get('description', '')}") + edge_lines = [] + for edge in ontology.get("edge_types", []): + targets = ", ".join( + f"{st.get('source')}->{st.get('target')}" for st in edge.get("source_targets", []) + ) + edge_lines.append(f"- {edge['name']}: {edge.get('description', '')} (allowed: {targets})") + + return f"""You are a precise knowledge-graph extraction agent, acting as the graph-construction engine of MiroFish. + +Your job: read one text fragment at a time and call the `record_graph_fragment` tool with the +entities and relationships that are EXPLICITLY grounded in that fragment. Do not invent facts. +Reuse entity names exactly as they appear elsewhere so the graph can be merged correctly. + +## Entity types +{chr(10).join(entity_lines) or '- Entity: generic entity'} + +## Relationship types +{chr(10).join(edge_lines) or '- RELATED_TO: generic relationship'} + +## Rules +1. Only extract entities/relationships that are supported by the text fragment given to you. +2. Entity `name` must be the canonical, real-world name (e.g. a person's full name), not a pronoun. +3. Every relationship's `source` and `target` must refer to an entity you also listed in `entities`. +4. If nothing relevant is in the fragment, call the tool with empty `entities` and `relationships` arrays. +5. {get_language_instruction()} (this applies to `summary` and `fact` fields only; `name`/`type`/`relation` stay as defined by the ontology). +""" + + +class ClaudeGraphBuilderService: + """ + 图谱构建服务 - Claude 引擎 + 使用 Anthropic Claude API 作为图谱构建的智能体 + """ + + def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None): + self.api_key = api_key or Config.ANTHROPIC_API_KEY + if not self.api_key: + raise ValueError("ANTHROPIC_API_KEY 未配置") + + self.model = model or Config.CLAUDE_MODEL_NAME + client_kwargs = {"api_key": self.api_key} + if Config.ANTHROPIC_BASE_URL: + client_kwargs["base_url"] = Config.ANTHROPIC_BASE_URL + + self.client = anthropic.Anthropic(**client_kwargs) + + # ============== 与 GraphBuilderService 对齐的公开接口 ============== + + def create_graph(self, name: str) -> str: + """创建本地图谱(公开方法,与 Zep 引擎接口对齐)""" + graph_id = f"mirofish_claude_{uuid.uuid4().hex[:16]}" + GraphStore.create(graph_id, name=name, description="MiroFish Claude-powered Graph") + return graph_id + + def set_ontology(self, graph_id: str, ontology: Dict[str, Any]) -> None: + """设置图谱本体(公开方法)""" + data = GraphStore.load(graph_id) + if data is None: + raise ValueError(f"图谱不存在: {graph_id}") + data["ontology"] = ontology + GraphStore.save(graph_id, data) + + def add_text_batches( + self, + graph_id: str, + chunks: List[str], + batch_size: int = 3, + progress_callback: Optional[Callable] = None + ) -> List[str]: + """ + 对每个文本块调用 Claude 进行实体/关系抽取,逐步合并进图谱 + 返回处理过的 episode id 列表(用于与 Zep 引擎接口对齐) + """ + data = GraphStore.load(graph_id) + if data is None: + raise ValueError(f"图谱不存在: {graph_id}") + + ontology = data.get("ontology") or {} + tool = _extraction_tool(ontology) + system_prompt = _system_prompt(ontology) + + episode_uuids = [] + total_chunks = len(chunks) + failures = 0 + + for i, chunk in enumerate(chunks): + episode_id = f"ep_{uuid.uuid4().hex[:12]}" + + if progress_callback: + progress_callback( + t('progress.claudeExtractingChunk', current=i + 1, total=total_chunks), + (i + 1) / total_chunks + ) + + try: + fragment = self._extract_fragment(chunk, tool, system_prompt) + self._merge_fragment(data, fragment, episode_id) + GraphStore.save(graph_id, data) + episode_uuids.append(episode_id) + except Exception as e: + failures += 1 + if progress_callback: + progress_callback( + t('progress.claudeChunkFailed', current=i + 1, error=str(e)), + (i + 1) / total_chunks + ) + + if failures == total_chunks and total_chunks > 0: + raise RuntimeError(t('progress.claudeAllChunksFailed')) + + return episode_uuids + + def _wait_for_episodes( + self, + episode_uuids: List[str], + progress_callback: Optional[Callable] = None, + timeout: int = 600 + ) -> None: + """Claude 引擎是同步抽取的,无需等待,直接汇报完成""" + if progress_callback: + progress_callback( + t('progress.processingComplete', completed=len(episode_uuids), total=len(episode_uuids)), + 1.0 + ) + + def get_graph_data(self, graph_id: str) -> Dict[str, Any]: + """获取完整图谱数据(nodes/edges),与 Zep 引擎返回格式保持一致""" + data = GraphStore.load(graph_id) + if data is None: + raise ValueError(f"图谱不存在: {graph_id}") + + nodes_data = list(data.get("nodes", {}).values()) + edges_data = data.get("edges", []) + + 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) -> None: + """删除本地图谱""" + GraphStore.delete(graph_id) + + # ============== 内部实现 ============== + + def _extract_fragment( + self, + chunk: str, + tool: Dict[str, Any], + system_prompt: str + ) -> Dict[str, Any]: + """调用 Claude,对单个文本块做结构化实体/关系抽取""" + message = self.client.messages.create( + model=self.model, + max_tokens=2048, + system=system_prompt, + tools=[tool], + tool_choice={"type": "tool", "name": "record_graph_fragment"}, + messages=[{"role": "user", "content": chunk}], + ) + + for block in message.content: + if getattr(block, "type", None) == "tool_use" and block.name == "record_graph_fragment": + return block.input + + raise RuntimeError("Claude 未返回有效的图谱抽取结果") + + def _merge_fragment(self, data: Dict[str, Any], fragment: Dict[str, Any], episode_id: str) -> None: + """将单个文本块的抽取结果合并进图谱存储""" + nodes = data["nodes"] + edges = data["edges"] + now = datetime.now().isoformat() + + # 本次抽取内 name -> uuid 的映射,便于关系解析 + local_name_index: Dict[str, str] = {} + + for entity in fragment.get("entities", []): + name = (entity.get("name") or "").strip() + if not name: + continue + entity_type = entity.get("type") or "Entity" + + existing_uuid = self._find_node(nodes, name, entity_type) + if existing_uuid: + node = nodes[existing_uuid] + # 合并属性(新值补充空缺字段) + attrs = node.get("attributes") or {} + for k, v in (entity.get("attributes") or {}).items(): + if v and not attrs.get(k): + attrs[k] = v + node["attributes"] = attrs + summary = entity.get("summary") + if summary and summary not in (node.get("summary") or ""): + node["summary"] = (node.get("summary") or "").strip() + node["summary"] = f"{node['summary']} {summary}".strip() + local_name_index[name.lower()] = existing_uuid + continue + + node_uuid = uuid.uuid4().hex + nodes[node_uuid] = { + "uuid": node_uuid, + "name": name, + "labels": ["Entity", entity_type], + "summary": entity.get("summary") or "", + "attributes": entity.get("attributes") or {}, + "created_at": now, + } + local_name_index[name.lower()] = node_uuid + + for rel in fragment.get("relationships", []): + source_name = (rel.get("source") or "").strip() + target_name = (rel.get("target") or "").strip() + relation = rel.get("relation") or "RELATED_TO" + fact = rel.get("fact") or "" + + source_uuid = local_name_index.get(source_name.lower()) or self._find_node_by_name(nodes, source_name) + target_uuid = local_name_index.get(target_name.lower()) or self._find_node_by_name(nodes, target_name) + + if not source_uuid or not target_uuid: + # 关系引用了未抽取到的实体,跳过而不是伪造节点 + continue + + if self._edge_exists(edges, source_uuid, target_uuid, relation, fact): + continue + + edges.append({ + "uuid": uuid.uuid4().hex, + "name": relation, + "fact": fact, + "fact_type": relation, + "source_node_uuid": source_uuid, + "target_node_uuid": target_uuid, + "attributes": {}, + "created_at": now, + "valid_at": now, + "invalid_at": None, + "expired_at": None, + "episodes": [episode_id], + }) + + @staticmethod + def _find_node(nodes: Dict[str, Any], name: str, entity_type: str) -> Optional[str]: + name_l = name.lower() + for node_uuid, node in nodes.items(): + if node["name"].lower() == name_l and entity_type in (node.get("labels") or []): + return node_uuid + return None + + @staticmethod + def _find_node_by_name(nodes: Dict[str, Any], name: str) -> Optional[str]: + name_l = name.lower() + for node_uuid, node in nodes.items(): + if node["name"].lower() == name_l: + return node_uuid + return None + + @staticmethod + def _edge_exists(edges: List[Dict[str, Any]], source_uuid: str, target_uuid: str, relation: str, fact: str) -> bool: + for edge in edges: + if ( + edge["source_node_uuid"] == source_uuid + and edge["target_node_uuid"] == target_uuid + and edge["name"] == relation + and edge["fact"] == fact + ): + return True + return False diff --git a/backend/requirements.txt b/backend/requirements.txt index 4f146296..a0b931b9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,6 +12,8 @@ flask-cors>=6.0.0 # ============= LLM 相关 ============= # OpenAI SDK(统一使用 OpenAI 格式调用 LLM) openai>=1.0.0 +# Anthropic SDK(Claude 图谱构建引擎) +anthropic>=0.40.0 # ============= Zep Cloud ============= zep-cloud==3.13.0 diff --git a/frontend/src/components/Step1GraphBuild.vue b/frontend/src/components/Step1GraphBuild.vue index 687d1c7b..316ff992 100644 --- a/frontend/src/components/Step1GraphBuild.vue +++ b/frontend/src/components/Step1GraphBuild.vue @@ -124,7 +124,34 @@

{{ $t('step1.graphRagDesc') }}

- + + +
+ {{ $t('step1.graphEngine') }} +
+ + +
+
+
@@ -201,10 +228,11 @@ const props = defineProps({ ontologyProgress: Object, buildProgress: Object, graphData: Object, - systemLogs: { type: Array, default: () => [] } + systemLogs: { type: Array, default: () => [] }, + graphEngine: { type: String, default: 'claude' } }) -defineEmits(['next-step']) +defineEmits(['next-step', 'update:graph-engine']) const selectedOntologyItem = ref(null) const logContent = ref(null) @@ -570,6 +598,76 @@ watch(() => props.systemLogs.length, () => { color: #BBB; } +/* Step 02 Engine Selector */ +.engine-selector { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} + +.engine-label { + font-size: 11px; + font-weight: 600; + color: #999; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.engine-pills { + display: flex; + gap: 6px; + background: #F5F5F5; + padding: 3px; + border-radius: 20px; + border: 1px solid #EAEAEA; +} + +.engine-pill { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border: none; + border-radius: 16px; + background: transparent; + color: #777; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.engine-pill:hover:not(:disabled) { + color: #333; +} + +.engine-pill.active { + background: #FFF; + color: #000; + box-shadow: 0 1px 4px rgba(0,0,0,0.1); +} + +.engine-pill:disabled { + cursor: not-allowed; + opacity: 0.7; +} + +.engine-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.engine-dot.claude { + background: #D97757; +} + +.engine-dot.zep { + background: #3498db; +} + /* Step 02 Stats */ .stats-grid { display: grid; diff --git a/frontend/src/views/MainView.vue b/frontend/src/views/MainView.vue index 513c70d8..13bf6d6e 100644 --- a/frontend/src/views/MainView.vue +++ b/frontend/src/views/MainView.vue @@ -51,7 +51,7 @@
- @@ -109,6 +111,7 @@ const currentPhase = ref(-1) // -1: Upload, 0: Ontology, 1: Build, 2: Complete const ontologyProgress = ref(null) const buildProgress = ref(null) const systemLogs = ref([]) +const graphEngine = ref('claude') // 'claude' | 'zep' - 图谱构建引擎 // Polling timers let pollTimer = null @@ -238,6 +241,7 @@ const loadProject = async () => { const res = await getProject(currentProjectId.value) if (res.success) { projectData.value = res.data + if (res.data.graph_engine) graphEngine.value = res.data.graph_engine updatePhaseByStatus(res.data.status) addLog(`Project loaded. Status: ${res.data.status}`) @@ -279,7 +283,7 @@ const startBuildGraph = async () => { buildProgress.value = { progress: 0, message: 'Starting build...' } addLog('Initiating graph build...') - const res = await buildGraph({ project_id: currentProjectId.value }) + const res = await buildGraph({ project_id: currentProjectId.value, engine: graphEngine.value }) if (res.success) { addLog(`Graph build task started. Task ID: ${res.data.task_id}`) startGraphPolling() diff --git a/locales/en.json b/locales/en.json index 544c68b1..46085df4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -85,7 +85,8 @@ "ontologyDesc": "LLM analyzes document content and simulation requirements, extracts reality seeds, and auto-generates a suitable ontology structure", "analyzingDocs": "Analyzing documents...", "graphRagBuild": "GraphRAG Build", - "graphRagDesc": "Based on the generated ontology, documents are auto-chunked and sent to Zep to build a knowledge graph, extracting entities and relations, forming temporal memory and community summaries", + "graphRagDesc": "Based on the generated ontology, documents are auto-chunked and sent to the selected engine (Claude or Zep) to build a knowledge graph, extracting entities and relations, forming temporal memory and community summaries", + "graphEngine": "Engine", "entityNodes": "Entity Nodes", "relationEdges": "Relation Edges", "schemaTypes": "Schema Types", @@ -329,6 +330,8 @@ "requireProjectId": "Please provide project_id", "configError": "Configuration error: {details}", "zepApiKeyMissing": "ZEP_API_KEY not configured", + "anthropicApiKeyMissing": "ANTHROPIC_API_KEY not configured", + "unknownEngine": "Unknown graph engine: {engine}", "ontologyNotGenerated": "Ontology not yet generated. Please call /ontology/generate first.", "graphBuilding": "Graph build in progress. Do not resubmit. To force rebuild, add force: true.", "textNotFound": "Extracted text content not found", @@ -394,6 +397,11 @@ "initGraphService": "Initializing graph build service...", "textChunking": "Chunking text...", "creatingZepGraph": "Creating Zep graph...", + "creatingClaudeGraph": "Creating Claude-powered graph...", + "claudeExtractingChunk": "Claude extracting chunk {current}/{total}...", + "claudeExtractionDone": "Claude extraction complete, merging graph...", + "claudeChunkFailed": "Chunk {current} extraction failed: {error}", + "claudeAllChunksFailed": "All chunks failed Claude extraction", "settingOntology": "Setting ontology definition...", "addingChunks": "Adding {count} text chunks...", "waitingZepProcess": "Waiting for Zep to process data...", diff --git a/locales/zh.json b/locales/zh.json index cd747e2f..6188e221 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -85,7 +85,8 @@ "ontologyDesc": "LLM分析文档内容与模拟需求,提取出现实种子,自动生成合适的本体结构", "analyzingDocs": "正在分析文档...", "graphRagBuild": "GraphRAG构建", - "graphRagDesc": "基于生成的本体,将文档自动分块后调用 Zep 构建知识图谱,提取实体和关系,并形成时序记忆与社区摘要", + "graphRagDesc": "基于生成的本体,将文档自动分块后调用所选引擎(Claude 或 Zep)构建知识图谱,提取实体和关系,并形成时序记忆与社区摘要", + "graphEngine": "引擎", "entityNodes": "实体节点", "relationEdges": "关系边", "schemaTypes": "SCHEMA类型", @@ -329,6 +330,8 @@ "requireProjectId": "请提供 project_id", "configError": "配置错误: {details}", "zepApiKeyMissing": "ZEP_API_KEY未配置", + "anthropicApiKeyMissing": "ANTHROPIC_API_KEY未配置", + "unknownEngine": "未知的图谱引擎: {engine}", "ontologyNotGenerated": "项目尚未生成本体,请先调用 /ontology/generate", "graphBuilding": "图谱正在构建中,请勿重复提交。如需强制重建,请添加 force: true", "textNotFound": "未找到提取的文本内容", @@ -394,6 +397,11 @@ "initGraphService": "初始化图谱构建服务...", "textChunking": "文本分块中...", "creatingZepGraph": "创建Zep图谱...", + "creatingClaudeGraph": "创建Claude驱动的图谱...", + "claudeExtractingChunk": "Claude正在抽取第 {current}/{total} 个文本块...", + "claudeExtractionDone": "Claude抽取完成,正在合并图谱...", + "claudeChunkFailed": "第 {current} 个文本块抽取失败: {error}", + "claudeAllChunksFailed": "所有文本块的Claude抽取均失败", "settingOntology": "设置本体定义...", "addingChunks": "开始添加 {count} 个文本块...", "waitingZepProcess": "等待Zep处理数据...",