785 lines
28 KiB
Python
785 lines
28 KiB
Python
"""Memory Gateway MCP Server.
|
||
|
||
通用 Memory Gateway 服务,为 AI agent / harness 提供统一的 OpenViking 记忆检索、总结和知识沉淀入口。
|
||
"""
|
||
import asyncio
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
import re
|
||
import tempfile
|
||
from datetime import datetime, timezone
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
from typing import Any, Optional
|
||
|
||
from fastapi import APIRouter, Depends, FastAPI, File, Form, Header, HTTPException, Request, UploadFile, status
|
||
from fastapi.responses import JSONResponse
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from mcp.server import Server
|
||
from mcp.types import TextContent, Tool
|
||
from sse_starlette import EventSourceResponse
|
||
|
||
from .config import get_config, set_config, Config
|
||
from .openviking_client import get_openviking_client, close_openviking_client
|
||
from .document_ingest import convert_file_to_markdown, save_markdown_to_obsidian, slugify
|
||
from .llm import LLMConfigurationError, LLMSummaryError, summarize_with_llm
|
||
from .mcp_tools_v1 import MEMORY_GATEWAY_MCP_TOOLS
|
||
from .schemas import (
|
||
AccessContext,
|
||
CommitSessionRequest,
|
||
EpisodeAppendRequest,
|
||
MemoryFeedbackRequest,
|
||
MemorySearchRequest,
|
||
MemoryUpsertRequest,
|
||
)
|
||
from .services import service as v1_service
|
||
from .types import SearchRequest, AddMemoryRequest, AddResourceRequest, CommitSummaryRequest
|
||
|
||
# 配置日志
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# 创建 MCP Server
|
||
mcp_server = Server("memory-gateway")
|
||
|
||
|
||
@mcp_server.list_tools()
|
||
async def list_tools() -> list[Tool]:
|
||
"""列出可用的 MCP 工具"""
|
||
legacy_tools = [
|
||
Tool(
|
||
name="search",
|
||
description="语义搜索记忆和资源",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {"type": "string", "description": "搜索查询"},
|
||
"namespace": {"type": "string", "description": "命名空间(可选)"},
|
||
"limit": {"type": "integer", "description": "返回结果数量(默认10)"},
|
||
"uri": {"type": "string", "description": "资源 URI(可选)"},
|
||
},
|
||
"required": ["query"],
|
||
},
|
||
),
|
||
Tool(
|
||
name="add_memory",
|
||
description="添加新记忆",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {
|
||
"content": {"type": "string", "description": "记忆内容"},
|
||
"namespace": {"type": "string", "description": "命名空间(可选)"},
|
||
"memory_type": {"type": "string", "description": "记忆类型(默认general)"},
|
||
},
|
||
"required": ["content"],
|
||
},
|
||
),
|
||
Tool(
|
||
name="add_resource",
|
||
description="添加资源",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {
|
||
"uri": {"type": "string", "description": "资源 URI"},
|
||
"content": {"type": "string", "description": "资源内容"},
|
||
"resource_type": {"type": "string", "description": "资源类型(默认text)"},
|
||
},
|
||
"required": ["uri", "content"],
|
||
},
|
||
),
|
||
Tool(
|
||
name="commit_summary",
|
||
description="总结一段通用内容并按需沉淀为 OpenViking memory/resource",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {
|
||
"content": {"type": "string", "description": "需要总结和沉淀的原文内容"},
|
||
"title": {"type": "string", "description": "标题(可选)"},
|
||
"summary": {"type": "string", "description": "人工提供的摘要(可选)"},
|
||
"namespace": {"type": "string", "description": "OpenViking memory namespace(可选)"},
|
||
"memory_type": {"type": "string", "description": "记忆类型,默认 summary"},
|
||
"tags": {"type": "array", "items": {"type": "string"}, "description": "标签列表"},
|
||
"source": {"type": "string", "description": "来源说明或外部链接"},
|
||
"resource_uri": {"type": "string", "description": "写入 resource 的 URI(可选)"},
|
||
"resource_type": {"type": "string", "description": "资源类型,默认 json"},
|
||
"persist_as": {"type": "string", "enum": ["memory", "resource", "both", "none"], "description": "沉淀方式"},
|
||
"max_summary_chars": {"type": "integer", "description": "摘要最大长度"},
|
||
},
|
||
"required": ["content"],
|
||
},
|
||
),
|
||
Tool(
|
||
name="get_status",
|
||
description="检查系统状态",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {},
|
||
},
|
||
),
|
||
Tool(
|
||
name="list_memories",
|
||
description="列出已存储的记忆",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {
|
||
"namespace": {"type": "string", "description": "命名空间(可选)"},
|
||
"memory_type": {"type": "string", "description": "记忆类型(可选)"},
|
||
"limit": {"type": "integer", "description": "返回数量(默认10)"},
|
||
},
|
||
},
|
||
),
|
||
Tool(
|
||
name="list_resources",
|
||
description="列出已存储的资源",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {
|
||
"namespace": {"type": "string", "description": "命名空间(可选)"},
|
||
"limit": {"type": "integer", "description": "返回数量(默认10)"},
|
||
},
|
||
},
|
||
),
|
||
]
|
||
v1_tools = [
|
||
Tool(
|
||
name=definition["name"],
|
||
description=definition["description"],
|
||
inputSchema=definition["inputSchema"],
|
||
)
|
||
for definition in MEMORY_GATEWAY_MCP_TOOLS
|
||
]
|
||
return legacy_tools + v1_tools
|
||
|
||
|
||
@mcp_server.call_tool()
|
||
async def call_tool(name: str, arguments: Any) -> list[TextContent]:
|
||
"""调用 MCP 工具"""
|
||
try:
|
||
if name.startswith("memory_"):
|
||
result = await call_v1_memory_tool(name, arguments or {})
|
||
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, default=str))]
|
||
|
||
ov_client = await get_openviking_client()
|
||
|
||
if name == "search":
|
||
result = await ov_client.search(
|
||
query=arguments.get("query"),
|
||
namespace=arguments.get("namespace"),
|
||
limit=arguments.get("limit"),
|
||
uri=arguments.get("uri"),
|
||
)
|
||
return [TextContent(type="text", text=str(result.results))]
|
||
|
||
elif name == "add_memory":
|
||
result = await ov_client.add_memory(
|
||
content=arguments.get("content"),
|
||
namespace=arguments.get("namespace"),
|
||
memory_type=arguments.get("memory_type", "general"),
|
||
)
|
||
return [TextContent(type="text", text=str(result))]
|
||
|
||
elif name == "add_resource":
|
||
result = await ov_client.add_resource(
|
||
uri=arguments.get("uri"),
|
||
content=arguments.get("content"),
|
||
resource_type=arguments.get("resource_type", "text"),
|
||
)
|
||
return [TextContent(type="text", text=str(result))]
|
||
|
||
elif name == "commit_summary":
|
||
request = CommitSummaryRequest(**arguments)
|
||
result = await commit_summary_to_openviking(request)
|
||
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|
||
|
||
elif name == "get_status":
|
||
ov_status = await ov_client.health_check()
|
||
return [TextContent(type="text", text=f"Memory Gateway: OK\nOpenViking: {ov_status}")]
|
||
|
||
elif name == "list_memories":
|
||
memories = await ov_client.list_memories(
|
||
namespace=arguments.get("namespace"),
|
||
memory_type=arguments.get("memory_type"),
|
||
limit=arguments.get("limit"),
|
||
)
|
||
return [TextContent(type="text", text=str([m.model_dump() for m in memories]))]
|
||
|
||
elif name == "list_resources":
|
||
resources = await ov_client.list_resources(
|
||
namespace=arguments.get("namespace"),
|
||
limit=arguments.get("limit"),
|
||
)
|
||
return [TextContent(type="text", text=str([r.model_dump() for r in resources]))]
|
||
|
||
else:
|
||
raise ValueError(f"Unknown tool: {name}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"工具执行失败: {e}")
|
||
return [TextContent(type="text", text=f"Error: {str(e)}")]
|
||
|
||
|
||
async def call_v1_memory_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||
"""Dispatch v1 Memory Gateway MCP tools to the same service used by /v1."""
|
||
if name == "memory_search":
|
||
return _jsonable(await v1_service.search_memory_with_openviking(MemorySearchRequest(**arguments)))
|
||
if name == "memory_upsert":
|
||
return v1_service.upsert_memory(MemoryUpsertRequest(**arguments)).model_dump(mode="json")
|
||
if name == "memory_append_episode":
|
||
return v1_service.append_episode(EpisodeAppendRequest(**arguments)).model_dump(mode="json")
|
||
if name == "memory_commit_session":
|
||
session_id = arguments.get("session_id")
|
||
if not session_id:
|
||
raise ValueError("session_id is required")
|
||
return _jsonable(v1_service.commit_session(session_id, CommitSessionRequest(**arguments)))
|
||
if name == "memory_get_profile":
|
||
return v1_service.get_profile(arguments["user_id"]).model_dump(mode="json")
|
||
if name == "memory_list_namespaces":
|
||
return {
|
||
"namespaces": [
|
||
item.model_dump(mode="json")
|
||
for item in v1_service.list_namespaces(
|
||
AccessContext(
|
||
user_id=arguments["user_id"],
|
||
agent_id=arguments.get("agent_id"),
|
||
workspace_id=arguments.get("workspace_id"),
|
||
session_id=arguments.get("session_id"),
|
||
)
|
||
)
|
||
]
|
||
}
|
||
if name == "memory_delete":
|
||
return v1_service.delete_memory(
|
||
arguments["memory_id"],
|
||
AccessContext(
|
||
user_id=arguments["user_id"],
|
||
agent_id=arguments.get("agent_id"),
|
||
workspace_id=arguments.get("workspace_id"),
|
||
session_id=arguments.get("session_id"),
|
||
),
|
||
)
|
||
if name == "memory_feedback":
|
||
return v1_service.add_feedback(arguments["memory_id"], MemoryFeedbackRequest(**arguments))
|
||
raise ValueError(f"Unknown v1 memory tool: {name}")
|
||
|
||
|
||
def _jsonable(value: Any) -> Any:
|
||
if hasattr(value, "model_dump"):
|
||
return value.model_dump(mode="json")
|
||
if isinstance(value, list):
|
||
return [_jsonable(item) for item in value]
|
||
if isinstance(value, dict):
|
||
return {key: _jsonable(item) for key, item in value.items()}
|
||
return value
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
"""应用生命周期管理"""
|
||
logger.info("Memory Gateway 启动中...")
|
||
config = get_config()
|
||
logger.info(f"配置加载完成: {config.server.host}:{config.server.port}")
|
||
logger.info(f"OpenViking 后端: {config.openviking.url}")
|
||
|
||
# 测试 OpenViking 连接
|
||
try:
|
||
ov_client = await get_openviking_client()
|
||
status = await ov_client.health_check()
|
||
logger.info(f"OpenViking 连接状态: {status}")
|
||
except Exception as e:
|
||
logger.warning(f"OpenViking 连接失败: {e}")
|
||
|
||
yield
|
||
|
||
logger.info("Memory Gateway 关闭中...")
|
||
await close_openviking_client()
|
||
|
||
|
||
def verify_api_key(x_api_key: Optional[str] = Header(default=None)) -> None:
|
||
"""在配置了 API Key 时校验请求头。"""
|
||
expected_key = get_config().server.api_key
|
||
if not expected_key:
|
||
return
|
||
if x_api_key != expected_key:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="Invalid or missing API key",
|
||
)
|
||
|
||
|
||
_SENTENCE_RE = re.compile(r"(?<=[。!?.!?])\s+")
|
||
_WORD_RE = re.compile(r"[^a-zA-Z0-9\u4e00-\u9fff_-]+")
|
||
|
||
|
||
def _normalize_whitespace(value: str) -> str:
|
||
return re.sub(r"\s+", " ", value).strip()
|
||
|
||
|
||
def _slugify(value: str, fallback: str) -> str:
|
||
slug = _WORD_RE.sub("-", value.lower()).strip("-")
|
||
slug = re.sub(r"-+", "-", slug)[:80].strip("-")
|
||
return slug or fallback
|
||
|
||
|
||
def _derive_title(content: str, title: Optional[str]) -> str:
|
||
if title and title.strip():
|
||
return title.strip()
|
||
for line in content.splitlines():
|
||
line = line.strip("# -*\t")
|
||
if line:
|
||
return line[:120]
|
||
return "Untitled summary"
|
||
|
||
|
||
def _derive_summary(content: str, provided: Optional[str], max_chars: int) -> str:
|
||
if provided and provided.strip():
|
||
return provided.strip()[:max_chars]
|
||
|
||
normalized = _normalize_whitespace(content)
|
||
if not normalized:
|
||
return ""
|
||
|
||
sentences = [part.strip() for part in _SENTENCE_RE.split(normalized) if part.strip()]
|
||
if not sentences:
|
||
return normalized[:max_chars]
|
||
|
||
summary = " ".join(sentences[:3])
|
||
return summary[:max_chars]
|
||
|
||
|
||
def _extract_key_points(content: str, limit: int = 8) -> list[str]:
|
||
points: list[str] = []
|
||
for raw_line in content.splitlines():
|
||
line = raw_line.strip()
|
||
if not line:
|
||
continue
|
||
stripped = re.sub(r"^(?:[-*•]\s*|\d+[.、)]\s*)", "", line).strip()
|
||
if not stripped:
|
||
continue
|
||
is_structured = line.startswith(("-", "*", "•")) or re.match(r"^\d+[.、)]\s+", line)
|
||
has_signal = any(token in stripped.lower() for token in [
|
||
"verdict", "result", "finding", "evidence", "action", "risk", "ioc",
|
||
"结论", "结果", "证据", "建议", "动作", "风险", "命中", "关联",
|
||
])
|
||
if is_structured or has_signal:
|
||
point = _normalize_whitespace(stripped)
|
||
if point and point not in points:
|
||
points.append(point[:240])
|
||
if len(points) >= limit:
|
||
break
|
||
|
||
if points:
|
||
return points
|
||
|
||
summary = _derive_summary(content, None, 500)
|
||
return [summary] if summary else []
|
||
|
||
|
||
def _render_memory_text(artifact: dict[str, Any]) -> str:
|
||
lines = [
|
||
f"Title: {artifact['title']}",
|
||
f"Summary: {artifact['summary']}",
|
||
]
|
||
if artifact.get("tags"):
|
||
lines.append("Tags: " + ", ".join(artifact["tags"]))
|
||
if artifact.get("source"):
|
||
lines.append("Source: " + artifact["source"])
|
||
if artifact.get("key_points"):
|
||
lines.append("Key points:")
|
||
lines.extend(f"- {point}" for point in artifact["key_points"])
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _default_summary_resource_uri(request: CommitSummaryRequest, title: str) -> str:
|
||
namespace = (request.namespace or get_config().memory.default_namespace or "general").strip("/")
|
||
memory_type = (request.memory_type or "summary").strip("/")
|
||
digest = hashlib.sha1(request.content.encode("utf-8")).hexdigest()[:12]
|
||
slug = _slugify(title, digest)
|
||
return f"viking://resources/{namespace}/{memory_type}/{slug}-{digest}.json"
|
||
|
||
|
||
async def build_summary_artifact(request: CommitSummaryRequest) -> dict[str, Any]:
|
||
max_chars = max(120, min(request.max_summary_chars, 4000))
|
||
llm_result = await summarize_with_llm(
|
||
request.content,
|
||
title=request.title,
|
||
summary_hint=request.summary,
|
||
tags=request.tags,
|
||
max_summary_chars=max_chars,
|
||
purpose=request.purpose or "generic knowledge memory",
|
||
)
|
||
title = llm_result.get("title") or _derive_title(request.content, request.title)
|
||
return {
|
||
"schema_version": "memory-gateway.summary.v1",
|
||
"id": hashlib.sha1(request.content.encode("utf-8")).hexdigest()[:16],
|
||
"title": title,
|
||
"summary": llm_result.get("summary", ""),
|
||
"key_points": llm_result.get("key_points", []),
|
||
"tags": llm_result.get("tags", request.tags),
|
||
"source": request.source,
|
||
"namespace": request.namespace or get_config().memory.default_namespace,
|
||
"memory_type": request.memory_type or "summary",
|
||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||
"content": request.content,
|
||
"llm": llm_result.get("llm"),
|
||
}
|
||
|
||
|
||
async def commit_summary_to_openviking(request: CommitSummaryRequest) -> dict[str, Any]:
|
||
artifact = await build_summary_artifact(request)
|
||
ov_client = await get_openviking_client()
|
||
|
||
memory_result: Optional[dict[str, Any]] = None
|
||
resource_result: Optional[dict[str, Any]] = None
|
||
|
||
if request.persist_as in {"memory", "both"}:
|
||
memory_result = await ov_client.add_memory(
|
||
content=_render_memory_text(artifact),
|
||
namespace=artifact["namespace"],
|
||
memory_type=artifact["memory_type"],
|
||
)
|
||
|
||
if request.persist_as in {"resource", "both"}:
|
||
resource_uri = request.resource_uri or _default_summary_resource_uri(request, artifact["title"])
|
||
artifact["resource_uri"] = resource_uri
|
||
resource_result = await ov_client.add_resource(
|
||
uri=resource_uri,
|
||
content=json.dumps(artifact, ensure_ascii=False, indent=2),
|
||
resource_type=request.resource_type or "json",
|
||
)
|
||
|
||
return {
|
||
"status": "ok",
|
||
"artifact": artifact,
|
||
"memory_result": memory_result,
|
||
"resource_result": resource_result,
|
||
}
|
||
|
||
|
||
# FastAPI 应用
|
||
app = FastAPI(title="Memory Gateway", version="0.1.0", lifespan=lifespan)
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"],
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
|
||
@app.get("/health", dependencies=[Depends(verify_api_key)])
|
||
async def health_check():
|
||
"""健康检查"""
|
||
try:
|
||
ov_client = await get_openviking_client()
|
||
ov_status = await ov_client.health_check()
|
||
evermemos_status = v1_service.evermemos_health()
|
||
return {
|
||
"status": "ok",
|
||
"gateway": "memory-gateway",
|
||
"openviking": ov_status,
|
||
"evermemos": evermemos_status,
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"status": "degraded",
|
||
"gateway": "memory-gateway",
|
||
"error": str(e),
|
||
}
|
||
|
||
mcp_router = APIRouter()
|
||
|
||
|
||
async def mcp_server_events(request: Request, _: None = Depends(verify_api_key)):
|
||
"""MCP Server-Sent Events 端点 - 使用 stdio 模式模拟"""
|
||
async def event_generator():
|
||
# 发送初始化消息
|
||
yield {"event": "initialize", "data": json.dumps({"protocolVersion": "2024-11-05"})}
|
||
|
||
# 保持连接
|
||
try:
|
||
while True:
|
||
await asyncio.sleep(30)
|
||
yield {"event": "ping", "data": ""}
|
||
except asyncio.CancelledError:
|
||
pass
|
||
|
||
return EventSourceResponse(event_generator())
|
||
|
||
|
||
mcp_router.add_api_route("/sse", mcp_server_events, methods=["GET"])
|
||
|
||
|
||
# MCP JSON-RPC 端点(简化实现)
|
||
async def mcp_rpc(request: Request, _: None = Depends(verify_api_key)):
|
||
"""处理 MCP JSON-RPC 请求"""
|
||
body = await request.json()
|
||
|
||
method = body.get("method")
|
||
params = body.get("params", {})
|
||
msg_id = body.get("id")
|
||
|
||
try:
|
||
if method == "tools/list":
|
||
tools = await list_tools()
|
||
result = {
|
||
"tools": [
|
||
{
|
||
"name": t.name,
|
||
"description": t.description,
|
||
"inputSchema": t.inputSchema,
|
||
}
|
||
for t in tools
|
||
]
|
||
}
|
||
elif method == "tools/call":
|
||
tool_name = params.get("name")
|
||
tool_args = params.get("arguments", {})
|
||
result_content = await call_tool_tool(tool_name, tool_args)
|
||
result = {"content": [c.model_dump() for c in result_content]}
|
||
else:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"jsonrpc": "2.0", "error": {"code": -32601, "message": f"Method not found: {method}"}, "id": msg_id}
|
||
)
|
||
|
||
return {"jsonrpc": "2.0", "result": result, "id": msg_id}
|
||
|
||
except Exception as e:
|
||
logger.error(f"MCP RPC 错误: {e}")
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={"jsonrpc": "2.0", "error": {"code": -32603, "message": str(e)}, "id": msg_id}
|
||
)
|
||
|
||
|
||
async def call_tool_tool(name: str, arguments: dict) -> list[TextContent]:
|
||
"""调用工具的内部函数"""
|
||
return await call_tool(name, arguments)
|
||
|
||
|
||
mcp_router.add_api_route("/rpc", mcp_rpc, methods=["POST"])
|
||
|
||
|
||
# 注册 MCP 路由
|
||
app.include_router(mcp_router, prefix="/mcp", tags=["mcp"])
|
||
|
||
# Generic Memory Gateway v1 routes are imported lazily here to avoid changing
|
||
# the existing legacy /api and /mcp startup path.
|
||
from .api_v1 import router as api_v1_router # noqa: E402
|
||
|
||
app.include_router(api_v1_router)
|
||
|
||
|
||
@app.post("/api/search", dependencies=[Depends(verify_api_key)])
|
||
async def api_search(request: SearchRequest):
|
||
"""REST API: 搜索"""
|
||
ov_client = await get_openviking_client()
|
||
result = await ov_client.search(
|
||
query=request.query,
|
||
namespace=request.namespace or get_config().memory.default_namespace,
|
||
limit=request.limit or get_config().memory.search_limit,
|
||
uri=request.uri,
|
||
)
|
||
return {"results": result.results, "total": result.total}
|
||
|
||
|
||
@app.post("/api/memory", dependencies=[Depends(verify_api_key)])
|
||
async def api_add_memory(request: AddMemoryRequest):
|
||
"""REST API: 添加记忆"""
|
||
ov_client = await get_openviking_client()
|
||
result = await ov_client.add_memory(
|
||
content=request.content,
|
||
namespace=request.namespace or get_config().memory.default_namespace,
|
||
memory_type=request.memory_type,
|
||
)
|
||
return result
|
||
|
||
|
||
@app.post("/api/resource", dependencies=[Depends(verify_api_key)])
|
||
async def api_add_resource(request: AddResourceRequest):
|
||
"""REST API: 添加资源"""
|
||
ov_client = await get_openviking_client()
|
||
result = await ov_client.add_resource(
|
||
uri=request.uri,
|
||
content=request.content,
|
||
resource_type=request.resource_type,
|
||
)
|
||
return result
|
||
|
||
|
||
@app.post("/api/summary", dependencies=[Depends(verify_api_key)])
|
||
async def api_commit_summary(request: CommitSummaryRequest):
|
||
"""REST API: 通用内容 LLM 总结与记忆沉淀。"""
|
||
try:
|
||
return await commit_summary_to_openviking(request)
|
||
except LLMConfigurationError as exc:
|
||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||
except (LLMSummaryError, Exception) as exc:
|
||
if isinstance(exc, HTTPException):
|
||
raise
|
||
raise HTTPException(status_code=502, detail=f"LLM summary failed: {exc}") from exc
|
||
|
||
|
||
def _parse_tags(tags: str | None) -> list[str]:
|
||
if not tags:
|
||
return []
|
||
return [tag.strip() for tag in re.split(r"[,\n]", tags) if tag.strip()]
|
||
|
||
|
||
def _default_knowledge_uri(namespace: str, knowledge_type: str, title: str, content: str) -> str:
|
||
digest = hashlib.sha1(content.encode("utf-8")).hexdigest()[:12]
|
||
return f"viking://resources/{namespace.strip('/')}/knowledge/{knowledge_type.strip('/')}/{slugify(title, digest)}-{digest}.json"
|
||
|
||
|
||
@app.post("/api/knowledge/upload", dependencies=[Depends(verify_api_key)])
|
||
async def api_upload_knowledge(
|
||
file: UploadFile = File(...),
|
||
title: Optional[str] = Form(default=None),
|
||
namespace: str = Form(default="memory-gateway"),
|
||
knowledge_type: str = Form(default="knowledge"),
|
||
tags: str = Form(default=""),
|
||
source: Optional[str] = Form(default=None),
|
||
obsidian_dir: Optional[str] = Form(default=None),
|
||
resource_uri: Optional[str] = Form(default=None),
|
||
persist_as: str = Form(default="resource"),
|
||
max_summary_chars: int = Form(default=1000),
|
||
):
|
||
"""Upload a document, convert it to Markdown, save to Obsidian, summarize with LLM, and commit to OpenViking."""
|
||
if persist_as not in {"memory", "resource", "both", "none"}:
|
||
raise HTTPException(status_code=422, detail="persist_as must be one of memory/resource/both/none")
|
||
|
||
original_name = file.filename or "uploaded-document"
|
||
suffix = Path(original_name).suffix or ".bin"
|
||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||
tmp.write(await file.read())
|
||
tmp_path = Path(tmp.name)
|
||
|
||
try:
|
||
markdown = await asyncio.to_thread(convert_file_to_markdown, tmp_path)
|
||
except RuntimeError as exc:
|
||
tmp_path.unlink(missing_ok=True)
|
||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||
except Exception as exc: # noqa: BLE001
|
||
tmp_path.unlink(missing_ok=True)
|
||
raise HTTPException(status_code=500, detail=f"Document conversion failed: {exc}") from exc
|
||
finally:
|
||
tmp_path.unlink(missing_ok=True)
|
||
|
||
parsed_tags = _parse_tags(tags)
|
||
effective_title = title or Path(original_name).stem or "Uploaded knowledge"
|
||
request = CommitSummaryRequest(
|
||
content=markdown,
|
||
title=effective_title,
|
||
namespace=namespace,
|
||
memory_type=knowledge_type,
|
||
tags=parsed_tags,
|
||
source=source or original_name,
|
||
persist_as="none",
|
||
max_summary_chars=max_summary_chars,
|
||
purpose=f"knowledge upload: {knowledge_type}",
|
||
)
|
||
try:
|
||
artifact = await build_summary_artifact(request)
|
||
except LLMConfigurationError as exc:
|
||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||
except Exception as exc: # noqa: BLE001
|
||
raise HTTPException(status_code=502, detail=f"LLM summary failed: {exc}") from exc
|
||
|
||
config = get_config()
|
||
relative_dir = obsidian_dir or getattr(config.obsidian, "knowledge_dir", "01_Knowledge/Uploaded")
|
||
obsidian_path = save_markdown_to_obsidian(
|
||
vault_path=config.obsidian.vault_path,
|
||
relative_dir=relative_dir,
|
||
title=artifact["title"],
|
||
markdown=markdown,
|
||
source_filename=original_name,
|
||
tags=artifact.get("tags", []),
|
||
knowledge_type=knowledge_type,
|
||
summary=artifact.get("summary"),
|
||
)
|
||
|
||
artifact.update(
|
||
{
|
||
"schema_version": "memory-gateway.knowledge_upload.v1",
|
||
"knowledge_type": knowledge_type,
|
||
"source_filename": original_name,
|
||
"obsidian_path": str(obsidian_path),
|
||
"obsidian_relative_path": str(obsidian_path.relative_to(config.obsidian.vault_path)),
|
||
"markdown_content": markdown,
|
||
}
|
||
)
|
||
|
||
ov_client = await get_openviking_client()
|
||
memory_result: Optional[dict[str, Any]] = None
|
||
resource_result: Optional[dict[str, Any]] = None
|
||
if persist_as in {"memory", "both"}:
|
||
memory_result = await ov_client.add_memory(
|
||
content=_render_memory_text(artifact),
|
||
namespace=namespace,
|
||
memory_type=knowledge_type,
|
||
)
|
||
if persist_as in {"resource", "both"}:
|
||
final_uri = resource_uri or _default_knowledge_uri(namespace, knowledge_type, artifact["title"], markdown)
|
||
artifact["resource_uri"] = final_uri
|
||
resource_result = await ov_client.add_resource(
|
||
uri=final_uri,
|
||
content=json.dumps(artifact, ensure_ascii=False, indent=2),
|
||
resource_type="json",
|
||
)
|
||
|
||
return {
|
||
"status": "ok",
|
||
"artifact": artifact,
|
||
"markdown_chars": len(markdown),
|
||
"obsidian_path": str(obsidian_path),
|
||
"memory_result": memory_result,
|
||
"resource_result": resource_result,
|
||
}
|
||
|
||
|
||
def create_app(config: Optional[Config] = None) -> FastAPI:
|
||
"""创建 FastAPI 应用"""
|
||
if config:
|
||
set_config(config)
|
||
return app
|
||
|
||
|
||
# 入口点
|
||
def main():
|
||
"""主入口"""
|
||
import argparse
|
||
import uvicorn
|
||
|
||
parser = argparse.ArgumentParser(description="Memory Gateway MCP Server")
|
||
parser.add_argument("--config", default="config.yaml", help="配置文件路径")
|
||
parser.add_argument("--host", default=None, help="监听地址")
|
||
parser.add_argument("--port", type=int, default=None, help="监听端口")
|
||
args = parser.parse_args()
|
||
|
||
# 加载配置
|
||
from .config import load_config as load
|
||
config = load(args.config)
|
||
if args.host:
|
||
config.server.host = args.host
|
||
if args.port:
|
||
config.server.port = args.port
|
||
set_config(config)
|
||
|
||
# 启动服务
|
||
uvicorn.run(
|
||
app,
|
||
host=config.server.host,
|
||
port=config.server.port,
|
||
log_level=config.logging.level.lower(),
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|