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:
parent
fa0f6519b1
commit
ff096b72db
13
.env.example
13
.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
|
||||
|
||||
|
|
|
|||
23
README-ZH.md
23
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/)
|
||||
|
|
|
|||
29
README.md
29
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/)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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处理数据...",
|
||||
|
|
|
|||
Loading…
Reference in New Issue