feat: add Claude-powered graph engine (Graphify-style)

Add a Claude/Anthropic-driven graph construction engine as a drop-in
alternative to the Zep-based one. Each text chunk is sent to Claude
with a tool-use schema derived from the generated ontology, extracting
only entities/relationships explicitly grounded in the text and
merging them into a local JSON graph store. Same service interface and
graph data shape as the Zep engine, so the existing D3 visualization
works unmodified.

- backend/app/services/claude_graph_builder.py: Claude extraction agent
- backend/app/models/graph_store.py: local JSON graph persistence
- backend/app/api/graph.py: engine selection (claude/zep) on build/data/delete routes
- frontend: Claude/Zep engine toggle on the Graph Build step, defaults to Claude
- config, requirements, locales, README/.env.example updated accordingly
This commit is contained in:
Claude 2026-08-03 19:51:37 +00:00
parent fa0f6519b1
commit ff096b72db
No known key found for this signature in database
13 changed files with 700 additions and 37 deletions

View File

@ -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

View File

@ -40,6 +40,29 @@ MiroFish 致力于打造映射现实的群体智能镜像,通过捕捉个体
从严肃预测到趣味仿真,我们让每一个如果都能看见结果,让预测万物成为可能。
## 🧠 本 ForkClaude 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/)

View File

@ -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/)

View File

@ -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/<project_id>', 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/<graph_id>', 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,

View File

@ -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

View File

@ -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

View File

@ -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),

View File

@ -0,0 +1,352 @@
"""
图谱构建服务 - Claude 引擎
使用 ClaudeAnthropic API作为图谱构建的智能体对文本分片进行实体/关系抽取
按照 Graphify 的思路做"增量式、透明化"的图谱构建每个文本块都会被 Claude
以结构化 tool-use 的方式抽取实体与关系逐步合并进本地图谱存储
GraphBuilderServiceZep 引擎保持一致的公开接口可在 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

View File

@ -12,6 +12,8 @@ flask-cors>=6.0.0
# ============= LLM 相关 =============
# OpenAI SDK统一使用 OpenAI 格式调用 LLM
openai>=1.0.0
# Anthropic SDKClaude 图谱构建引擎)
anthropic>=0.40.0
# ============= Zep Cloud =============
zep-cloud==3.13.0

View File

@ -124,7 +124,34 @@
<p class="description">
{{ $t('step1.graphRagDesc') }}
</p>
<!-- Engine Selector (Graphify-style) -->
<div class="engine-selector">
<span class="engine-label">{{ $t('step1.graphEngine') }}</span>
<div class="engine-pills">
<button
type="button"
class="engine-pill"
:class="{ active: graphEngine === 'claude' }"
:disabled="currentPhase >= 1"
@click="$emit('update:graph-engine', 'claude')"
>
<span class="engine-dot claude"></span>
Claude
</button>
<button
type="button"
class="engine-pill"
:class="{ active: graphEngine === 'zep' }"
:disabled="currentPhase >= 1"
@click="$emit('update:graph-engine', 'zep')"
>
<span class="engine-dot zep"></span>
Zep
</button>
</div>
</div>
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
@ -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;

View File

@ -51,7 +51,7 @@
<!-- Right Panel: Step Components -->
<div class="panel-wrapper right" :style="rightPanelStyle">
<!-- Step 1: 图谱构建 -->
<Step1GraphBuild
<Step1GraphBuild
v-if="currentStep === 1"
:currentPhase="currentPhase"
:projectData="projectData"
@ -59,6 +59,8 @@
:buildProgress="buildProgress"
:graphData="graphData"
:systemLogs="systemLogs"
:graphEngine="graphEngine"
@update:graph-engine="val => graphEngine = val"
@next-step="handleNextStep"
/>
<!-- Step 2: 环境搭建 -->
@ -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()

View File

@ -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...",

View File

@ -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处理数据...",