Add brain memory services and APIs
Introduce the backend pieces for brain memory ingestion, routing, and system telemetry so the new knowledge workflows can project data into a brain view. The supporting tests lock in the new behavior and keep the expanded backend surface stable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,15 +6,60 @@ Jarvis Agent 服务层
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import AsyncGenerator
|
||||
from typing import Any, AsyncGenerator
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from langchain_core.messages import HumanMessage, AIMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
from langchain_ollama import ChatOllama
|
||||
import httpx
|
||||
|
||||
from app.database import async_session
|
||||
|
||||
from app.models.conversation import Conversation, Message
|
||||
from app.models.user import User
|
||||
from app.agents.graph import get_agent_graph
|
||||
from app.agents.context import set_current_user, clear_current_user
|
||||
from app.services import memory_service
|
||||
from app.services.brain_service import BrainService
|
||||
|
||||
|
||||
def _create_llm_from_config(config: dict):
|
||||
"""根据用户模型配置创建 LLM 实例"""
|
||||
provider = config.get("provider", "openai")
|
||||
model = config.get("model", "")
|
||||
api_key = config.get("api_key", "")
|
||||
base_url = config.get("base_url", "")
|
||||
|
||||
if provider == "openai" or provider == "deepseek" or provider == "custom":
|
||||
return ChatOpenAI(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
base_url=base_url or None,
|
||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
||||
)
|
||||
elif provider == "claude":
|
||||
return ChatAnthropic(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
||||
)
|
||||
elif provider == "ollama":
|
||||
return ChatOllama(
|
||||
base_url=base_url or "http://localhost:11434",
|
||||
model=model,
|
||||
timeout=httpx.Timeout(120.0, connect=10.0),
|
||||
)
|
||||
else:
|
||||
# 默认使用 OpenAI
|
||||
return ChatOpenAI(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
base_url=base_url or None,
|
||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
||||
)
|
||||
|
||||
|
||||
class AgentService:
|
||||
@@ -23,12 +68,70 @@ class AgentService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def _try_auto_summarize_background(self, user_id: str, conversation_id: str) -> None:
|
||||
async with async_session() as session:
|
||||
await memory_service.try_auto_summarize(session, user_id, conversation_id)
|
||||
|
||||
def _build_progress_event(
|
||||
self,
|
||||
stage: str,
|
||||
label: str,
|
||||
*,
|
||||
agent: str | None = None,
|
||||
tool_name: str | None = None,
|
||||
step: str | None = None,
|
||||
steps: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "progress",
|
||||
"stage": stage,
|
||||
"label": label,
|
||||
"agent": agent,
|
||||
"tool_name": tool_name,
|
||||
"step": step,
|
||||
"steps": steps or [],
|
||||
}
|
||||
|
||||
async def _get_user_llm_config(self, user_id: str, model_name: str | None = None) -> dict | None:
|
||||
"""获取用户的 LLM 模型配置"""
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not user.llm_config:
|
||||
return None
|
||||
|
||||
llm_config = user.llm_config
|
||||
|
||||
# 如果指定了模型名称,查找对应的配置
|
||||
if model_name:
|
||||
for model_type in ["chat", "vlm"]:
|
||||
models = llm_config.get(model_type, [])
|
||||
for m in models:
|
||||
if m.get("name") == model_name:
|
||||
return m
|
||||
# 没找到,返回 None 让调用方知道配置不存在
|
||||
return None
|
||||
|
||||
# 如果没指定模型名,返回默认启用的 chat 模型
|
||||
chat_models = llm_config.get("chat", [])
|
||||
for m in chat_models:
|
||||
if m.get("enabled"):
|
||||
return m
|
||||
|
||||
vlm_models = llm_config.get("vlm", [])
|
||||
for m in vlm_models:
|
||||
if m.get("enabled"):
|
||||
return m
|
||||
|
||||
return None
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
user_id: str,
|
||||
message: str,
|
||||
conversation_id: str | None = None,
|
||||
) -> tuple[str, str, AsyncGenerator[str, None]]:
|
||||
file_ids: list[str] | None = None,
|
||||
model_name: str | None = None,
|
||||
) -> tuple[str, str, AsyncGenerator[dict[str, Any], None]]:
|
||||
"""
|
||||
处理对话请求(流式)
|
||||
|
||||
@@ -53,22 +156,54 @@ class AgentService:
|
||||
else:
|
||||
conversation_id = conv.id
|
||||
|
||||
# 如果有文件,读取内容作为上下文
|
||||
file_context = ""
|
||||
if file_ids:
|
||||
from app.services.document_service import DocumentService
|
||||
doc_svc = DocumentService(self.db)
|
||||
for file_id in file_ids:
|
||||
content = await doc_svc.get_document_content(user_id, file_id)
|
||||
if content:
|
||||
file_context += f"\n\n[用户上传文件内容]\n{content}\n[/文件内容]"
|
||||
|
||||
full_message = f"{message}\n{file_context}" if file_context else message
|
||||
|
||||
# 存储用户消息
|
||||
user_msg = Message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=message,
|
||||
attachments=[{"file_ids": file_ids}] if file_ids else None,
|
||||
)
|
||||
self.db.add(user_msg)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user_msg)
|
||||
|
||||
brain_service = BrainService(self.db)
|
||||
await brain_service.create_event(
|
||||
user_id,
|
||||
source_type="conversation",
|
||||
source_id=conversation_id,
|
||||
event_type="message_created",
|
||||
title="User message",
|
||||
content_summary=message[:500],
|
||||
raw_excerpt=message[:2000],
|
||||
metadata_={"role": "user"},
|
||||
importance_signal=1.0,
|
||||
)
|
||||
await self.db.commit()
|
||||
|
||||
# 预创建助手消息(后续更新内容)
|
||||
user_llm_config = await self._get_user_llm_config(user_id, model_name)
|
||||
model_name_used = model_name
|
||||
if user_llm_config:
|
||||
model_name_used = user_llm_config.get("name", model_name)
|
||||
|
||||
assistant_msg = Message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content="",
|
||||
model="jarvis",
|
||||
model=model_name_used or "jarvis",
|
||||
)
|
||||
self.db.add(assistant_msg)
|
||||
await self.db.commit()
|
||||
@@ -85,7 +220,7 @@ class AgentService:
|
||||
try:
|
||||
graph = get_agent_graph()
|
||||
langgraph_state = {
|
||||
"messages": [HumanMessage(content=message)], # type: ignore[arg-type]
|
||||
"messages": [HumanMessage(content=full_message)], # type: ignore[arg-type]
|
||||
"user_id": user_id,
|
||||
"conversation_id": conversation_id,
|
||||
"current_agent": "master",
|
||||
@@ -102,33 +237,81 @@ class AgentService:
|
||||
"final_response": None,
|
||||
"should_respond": True,
|
||||
"memory_context": memory_ctx,
|
||||
"user_llm_config": user_llm_config,
|
||||
}
|
||||
|
||||
yield self._build_progress_event("thinking", "Jarvis 正在分析请求", agent="master", step="理解你的问题")
|
||||
|
||||
collected = ""
|
||||
async for event in graph.astream_events(langgraph_state, version="v2"):
|
||||
kind = event.get("event")
|
||||
if kind == "on_chat_model_end":
|
||||
content = event.get("data", {}).get("output", {})
|
||||
if isinstance(content, dict):
|
||||
content = content.get("content", "")
|
||||
if content:
|
||||
delta = content[len(collected):]
|
||||
if delta:
|
||||
collected += delta
|
||||
yield delta
|
||||
event_name = event.get("name", "")
|
||||
metadata = event.get("metadata", {})
|
||||
data = event.get("data", {})
|
||||
|
||||
if kind == "on_chain_start" and event_name in {"master", "planner", "executor", "librarian", "analyst"}:
|
||||
stage_map = {
|
||||
"master": ("thinking", "Jarvis 正在理解请求"),
|
||||
"planner": ("planning", "Jarvis 正在拆解步骤"),
|
||||
"executor": ("tool", "Jarvis 正在执行操作"),
|
||||
"librarian": ("tool", "Jarvis 正在检索知识"),
|
||||
"analyst": ("thinking", "Jarvis 正在分析信息"),
|
||||
}
|
||||
stage, label = stage_map[event_name]
|
||||
yield self._build_progress_event(stage, label, agent=event_name, step=label)
|
||||
elif kind == "on_tool_start":
|
||||
tool_input = data.get("input")
|
||||
step = None
|
||||
if isinstance(tool_input, dict) and tool_input:
|
||||
step = f"调用工具 {event_name}"
|
||||
yield self._build_progress_event("tool", f"Jarvis 正在调用工具 {event_name}", agent="executor", tool_name=event_name, step=step)
|
||||
elif kind == "on_tool_end":
|
||||
name = event.get("name", "")
|
||||
yield f"\n[工具执行: {name}]\n"
|
||||
yield self._build_progress_event("tool", f"工具 {event_name} 已完成", agent="executor", tool_name=event_name, step=f"已获得 {event_name} 结果")
|
||||
elif kind == "on_chain_end" and event_name == "planner":
|
||||
output = data.get("output") or {}
|
||||
plan_steps = output.get("plan_steps") or []
|
||||
steps = [item.get("description", "") for item in plan_steps if item.get("description")]
|
||||
yield self._build_progress_event("planning", "Jarvis 已生成处理步骤", agent="planner", step=steps[0] if steps else "正在整理计划", steps=steps[:4])
|
||||
elif kind == "on_chat_model_stream":
|
||||
chunk = data.get("chunk")
|
||||
content = getattr(chunk, "content", "") if chunk else ""
|
||||
if isinstance(content, list):
|
||||
text_parts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
text_parts.append(item.get("text", ""))
|
||||
else:
|
||||
text_parts.append(str(item))
|
||||
content = "".join(text_parts)
|
||||
if content:
|
||||
collected += content
|
||||
yield {"type": "chunk", "content": content}
|
||||
elif kind == "on_chat_model_end" and not collected:
|
||||
output = data.get("output")
|
||||
content = getattr(output, "content", "") if output else ""
|
||||
if isinstance(content, list):
|
||||
text_parts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
text_parts.append(item.get("text", ""))
|
||||
else:
|
||||
text_parts.append(str(item))
|
||||
content = "".join(text_parts)
|
||||
if content:
|
||||
collected = content
|
||||
yield {"type": "chunk", "content": content}
|
||||
elif kind == "on_chain_end" and event_name in {"executor", "librarian", "analyst"}:
|
||||
yield self._build_progress_event("responding", "Jarvis 正在整理最终回答", agent=event_name, step="生成回复")
|
||||
except Exception as e:
|
||||
yield f"\n执行出错: {str(e)}"
|
||||
fallback = f"抱歉,发生错误: {str(e)}"
|
||||
collected = fallback
|
||||
yield {"type": "error", "error": str(e)}
|
||||
yield {"type": "chunk", "content": fallback}
|
||||
finally:
|
||||
clear_current_user()
|
||||
# 异步触发自动摘要和记忆提取(不阻塞响应)
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(
|
||||
memory_service.try_auto_summarize(self.db, user_id, conversation_id)
|
||||
asyncio.get_running_loop().create_task(
|
||||
self._try_auto_summarize_background(user_id, conversation_id)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -143,6 +326,18 @@ class AgentService:
|
||||
if msg:
|
||||
msg.content = collected
|
||||
await self.db.commit()
|
||||
await brain_service.create_event(
|
||||
user_id,
|
||||
source_type="conversation",
|
||||
source_id=conversation_id,
|
||||
event_type="message_created",
|
||||
title="Assistant message",
|
||||
content_summary=collected[:500],
|
||||
raw_excerpt=collected[:2000],
|
||||
metadata_={"role": "assistant"},
|
||||
importance_signal=1.0,
|
||||
)
|
||||
await self.db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -154,12 +349,13 @@ class AgentService:
|
||||
message: str,
|
||||
conversation_id: str | None = None,
|
||||
file_ids: list[str] | None = None,
|
||||
) -> tuple[str, str, str]:
|
||||
model_name: str | None = None,
|
||||
) -> tuple[str, str, str, str | None]:
|
||||
"""
|
||||
简单同步版对话(无流式)
|
||||
|
||||
Returns:
|
||||
(conversation_id, message_id, response_content)
|
||||
(conversation_id, message_id, response_content, model_name_used)
|
||||
"""
|
||||
# 获取或创建对话
|
||||
if conversation_id:
|
||||
@@ -203,11 +399,31 @@ class AgentService:
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user_msg)
|
||||
|
||||
brain_service = BrainService(self.db)
|
||||
await brain_service.create_event(
|
||||
user_id,
|
||||
source_type="conversation",
|
||||
source_id=conversation_id,
|
||||
event_type="message_created",
|
||||
title="User message",
|
||||
content_summary=message[:500],
|
||||
raw_excerpt=message[:2000],
|
||||
metadata_={"role": "user"},
|
||||
importance_signal=1.0,
|
||||
)
|
||||
await self.db.commit()
|
||||
|
||||
# 加载记忆上下文
|
||||
memory_ctx = await memory_service.build_memory_context(
|
||||
self.db, user_id, conversation_id, message
|
||||
)
|
||||
|
||||
# 获取用户配置的 LLM
|
||||
user_llm_config = await self._get_user_llm_config(user_id, model_name)
|
||||
model_name_used = model_name
|
||||
if user_llm_config:
|
||||
model_name_used = user_llm_config.get("name", model_name)
|
||||
|
||||
# 调用 LangGraph Agent
|
||||
set_current_user(user_id)
|
||||
graph = get_agent_graph()
|
||||
@@ -229,6 +445,7 @@ class AgentService:
|
||||
"final_response": None,
|
||||
"should_respond": True,
|
||||
"memory_context": memory_ctx,
|
||||
"user_llm_config": user_llm_config, # 传递用户 LLM 配置
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -238,11 +455,9 @@ class AgentService:
|
||||
response_content = f"抱歉,发生错误: {str(e)}"
|
||||
finally:
|
||||
clear_current_user()
|
||||
# 异步触发自动摘要
|
||||
import asyncio
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(
|
||||
memory_service.try_auto_summarize(self.db, user_id, conversation_id)
|
||||
self._try_auto_summarize_background(user_id, conversation_id)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -252,10 +467,23 @@ class AgentService:
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=response_content,
|
||||
model="jarvis",
|
||||
model=model_name_used or "jarvis",
|
||||
)
|
||||
self.db.add(assistant_msg)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(assistant_msg)
|
||||
|
||||
return conversation_id, assistant_msg.id, response_content
|
||||
await brain_service.create_event(
|
||||
user_id,
|
||||
source_type="conversation",
|
||||
source_id=conversation_id,
|
||||
event_type="message_created",
|
||||
title="Assistant message",
|
||||
content_summary=response_content[:500],
|
||||
raw_excerpt=response_content[:2000],
|
||||
metadata_={"role": "assistant"},
|
||||
importance_signal=1.0,
|
||||
)
|
||||
await self.db.commit()
|
||||
|
||||
return conversation_id, assistant_msg.id, response_content, model_name_used
|
||||
|
||||
204
backend/app/services/brain_service.py
Normal file
204
backend/app/services/brain_service.py
Normal file
@@ -0,0 +1,204 @@
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.brain import BrainCandidate, BrainEvent, BrainMemory, BrainTag
|
||||
from app.services.graph_service import GraphService
|
||||
|
||||
|
||||
class BrainService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def create_event(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
source_type: str,
|
||||
source_id: str,
|
||||
event_type: str,
|
||||
title: str | None = None,
|
||||
content_summary: str | None = None,
|
||||
raw_excerpt: str | None = None,
|
||||
metadata_: dict | None = None,
|
||||
importance_signal: float = 0.0,
|
||||
) -> BrainEvent:
|
||||
event = BrainEvent(
|
||||
user_id=user_id,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
event_type=event_type,
|
||||
title=title,
|
||||
content_summary=content_summary,
|
||||
raw_excerpt=raw_excerpt,
|
||||
metadata_=metadata_,
|
||||
importance_signal=importance_signal,
|
||||
status="pending",
|
||||
)
|
||||
self.db.add(event)
|
||||
await self.db.flush()
|
||||
return event
|
||||
|
||||
async def recall_memories(self, user_id: str, current_query: str, top_k: int = 3) -> list[BrainMemory]:
|
||||
query_tokens = [token.strip().lower() for token in current_query.split() if token.strip()]
|
||||
statement = select(BrainMemory).where(
|
||||
BrainMemory.user_id == user_id,
|
||||
BrainMemory.status == "active",
|
||||
)
|
||||
if query_tokens:
|
||||
statement = statement.where(
|
||||
or_(
|
||||
*[
|
||||
or_(
|
||||
BrainMemory.title.ilike(f"%{token}%"),
|
||||
BrainMemory.content.ilike(f"%{token}%"),
|
||||
)
|
||||
for token in query_tokens
|
||||
]
|
||||
)
|
||||
)
|
||||
result = await self.db.execute(
|
||||
statement.order_by(BrainMemory.importance.desc(), BrainMemory.created_at.desc()).limit(top_k)
|
||||
)
|
||||
memories = list(result.scalars().all())
|
||||
if memories or query_tokens:
|
||||
return memories
|
||||
|
||||
fallback_result = await self.db.execute(
|
||||
select(BrainMemory)
|
||||
.where(BrainMemory.user_id == user_id, BrainMemory.status == "active")
|
||||
.order_by(BrainMemory.importance.desc(), BrainMemory.created_at.desc())
|
||||
.limit(top_k)
|
||||
)
|
||||
return list(fallback_result.scalars().all())
|
||||
|
||||
async def get_overview(self, user_id: str) -> dict:
|
||||
active_memory_count = (
|
||||
await self.db.execute(
|
||||
select(func.count()).select_from(BrainMemory).where(
|
||||
BrainMemory.user_id == user_id,
|
||||
BrainMemory.status == "active",
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
important_tag_count = (
|
||||
await self.db.execute(
|
||||
select(func.count()).select_from(BrainTag).where(
|
||||
BrainTag.user_id == user_id,
|
||||
BrainTag.priority == "important",
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
secondary_tag_count = (
|
||||
await self.db.execute(
|
||||
select(func.count()).select_from(BrainTag).where(
|
||||
BrainTag.user_id == user_id,
|
||||
BrainTag.priority == "secondary",
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
recent_memory_result = await self.db.execute(
|
||||
select(BrainMemory.title)
|
||||
.where(BrainMemory.user_id == user_id, BrainMemory.status == "active")
|
||||
.order_by(BrainMemory.importance.desc(), BrainMemory.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_memory_titles = list(recent_memory_result.scalars().all())
|
||||
|
||||
return {
|
||||
"active_memory_count": active_memory_count,
|
||||
"important_tag_count": important_tag_count,
|
||||
"secondary_tag_count": secondary_tag_count,
|
||||
"recent_memory_titles": recent_memory_titles,
|
||||
}
|
||||
|
||||
async def list_memories(self, user_id: str) -> list[BrainMemory]:
|
||||
result = await self.db.execute(
|
||||
select(BrainMemory)
|
||||
.where(BrainMemory.user_id == user_id, BrainMemory.status == "active")
|
||||
.order_by(BrainMemory.importance.desc(), BrainMemory.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_tags(self, user_id: str) -> dict:
|
||||
important_result = await self.db.execute(
|
||||
select(BrainTag)
|
||||
.where(BrainTag.user_id == user_id, BrainTag.priority == "important")
|
||||
.order_by(BrainTag.score.desc(), BrainTag.created_at.desc())
|
||||
)
|
||||
secondary_result = await self.db.execute(
|
||||
select(BrainTag)
|
||||
.where(BrainTag.user_id == user_id, BrainTag.priority == "secondary")
|
||||
.order_by(BrainTag.score.desc(), BrainTag.created_at.desc())
|
||||
)
|
||||
return {
|
||||
"important": list(important_result.scalars().all()),
|
||||
"secondary": list(secondary_result.scalars().all()),
|
||||
}
|
||||
|
||||
async def list_events(self, user_id: str) -> list[BrainEvent]:
|
||||
result = await self.db.execute(
|
||||
select(BrainEvent)
|
||||
.where(BrainEvent.user_id == user_id)
|
||||
.order_by(BrainEvent.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def run_learning(self, user_id: str) -> dict:
|
||||
pending_events_result = await self.db.execute(
|
||||
select(BrainEvent)
|
||||
.where(BrainEvent.user_id == user_id, BrainEvent.status == "pending")
|
||||
.order_by(BrainEvent.created_at.asc())
|
||||
)
|
||||
pending_events = list(pending_events_result.scalars().all())
|
||||
pending_count = len(pending_events)
|
||||
|
||||
candidates_created = 0
|
||||
memories_promoted = 0
|
||||
|
||||
if pending_events:
|
||||
candidate = BrainCandidate(
|
||||
user_id=user_id,
|
||||
candidate_type="daily_learning",
|
||||
title="Daily learning synthesis",
|
||||
summary=f"Processed {pending_count} pending brain events.",
|
||||
importance_score=float(pending_count),
|
||||
confidence_score=1.0,
|
||||
status="promoted",
|
||||
source_event_ids=[event.id for event in pending_events],
|
||||
)
|
||||
self.db.add(candidate)
|
||||
await self.db.flush()
|
||||
candidates_created = 1
|
||||
|
||||
memory = BrainMemory(
|
||||
user_id=user_id,
|
||||
memory_type="daily_learning",
|
||||
title="Daily learning synthesis",
|
||||
content=f"Processed {pending_count} pending brain events.",
|
||||
importance=max(pending_count, 1),
|
||||
confidence=1.0,
|
||||
status="active",
|
||||
origin_candidate_id=candidate.id,
|
||||
origin_source_types=sorted({event.source_type for event in pending_events}),
|
||||
)
|
||||
self.db.add(memory)
|
||||
memories_promoted = 1
|
||||
|
||||
for event in pending_events:
|
||||
event.status = "processed"
|
||||
event.processed_at = memory.created_at
|
||||
|
||||
await self.db.commit()
|
||||
else:
|
||||
await self.db.commit()
|
||||
|
||||
await GraphService(self.db).build_graph(user_id)
|
||||
|
||||
return {
|
||||
"events_considered": pending_count,
|
||||
"candidates_created": candidates_created,
|
||||
"memories_promoted": memories_promoted,
|
||||
}
|
||||
@@ -4,11 +4,8 @@
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.models.brain import BrainMemory, BrainTag
|
||||
from app.models.knowledge_graph import KGNode, KGEdge
|
||||
from app.models.document import Document, DocumentChunk
|
||||
from app.services.llm_service import get_llm
|
||||
from langchain_core.messages import HumanMessage
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -75,110 +72,93 @@ confidence: 0.0-1.0,表示推断置信度
|
||||
class GraphService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.llm = get_llm()
|
||||
|
||||
async def build_graph(self, user_id: str, document_ids: list[str] | None = None):
|
||||
"""
|
||||
从文档构建/更新知识图谱
|
||||
- 遍历所有 chunk
|
||||
- LLM 实体识别
|
||||
- LLM 关系抽取
|
||||
- 去重合并
|
||||
"""
|
||||
query = (
|
||||
select(DocumentChunk)
|
||||
.join(Document)
|
||||
.where(Document.user_id == user_id)
|
||||
.where(Document.is_indexed == True)
|
||||
"""从知识大脑投影图谱。"""
|
||||
existing_nodes_result = await self.db.execute(select(KGNode).where(KGNode.user_id == user_id))
|
||||
for node in existing_nodes_result.scalars().all():
|
||||
await self.db.delete(node)
|
||||
await self.db.flush()
|
||||
|
||||
memory_result = await self.db.execute(
|
||||
select(BrainMemory)
|
||||
.where(BrainMemory.user_id == user_id, BrainMemory.status == "active")
|
||||
.order_by(BrainMemory.importance.desc(), BrainMemory.created_at.desc())
|
||||
)
|
||||
if document_ids:
|
||||
query = query.where(DocumentChunk.document_id.in_(document_ids))
|
||||
memories = list(memory_result.scalars().all())
|
||||
|
||||
result = await self.db.execute(query)
|
||||
chunks = list(result.scalars().all())
|
||||
tag_result = await self.db.execute(
|
||||
select(BrainTag)
|
||||
.where(BrainTag.user_id == user_id)
|
||||
.order_by(BrainTag.score.desc(), BrainTag.created_at.desc())
|
||||
)
|
||||
tags = list(tag_result.scalars().all())
|
||||
|
||||
logger.info(f"[GraphService] 开始构建图谱,共 {len(chunks)} 个 chunks")
|
||||
logger.info(f"[GraphService] 开始从 brain 数据投影图谱,memories={len(memories)}, tags={len(tags)}")
|
||||
|
||||
for chunk in chunks:
|
||||
try:
|
||||
await self._process_chunk(chunk, user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"[GraphService] 处理 chunk {chunk.id} 失败: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"[GraphService] 图谱构建完成")
|
||||
|
||||
async def _process_chunk(self, chunk: DocumentChunk, user_id: str):
|
||||
"""处理单个 chunk,提取实体和关系"""
|
||||
prompt = ENTITY_EXTRACTION_PROMPT.format(text=chunk.content[:2000])
|
||||
response = await self.llm.invoke([HumanMessage(content=prompt)])
|
||||
|
||||
try:
|
||||
data = json.loads(response.content)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
|
||||
entities = data.get("entities", [])
|
||||
relations = data.get("relations", [])
|
||||
|
||||
if not entities:
|
||||
return
|
||||
|
||||
# 先查找已存在的节点
|
||||
existing_nodes = {}
|
||||
for entity_data in entities:
|
||||
name = entity_data["name"]
|
||||
result = await self.db.execute(
|
||||
select(KGNode)
|
||||
.where(KGNode.user_id == user_id)
|
||||
.where(KGNode.name == name)
|
||||
node_map: dict[str, KGNode] = {}
|
||||
for memory in memories:
|
||||
node = KGNode(
|
||||
user_id=user_id,
|
||||
name=memory.title,
|
||||
entity_type="memory",
|
||||
description=memory.content,
|
||||
properties_={
|
||||
"memory_type": memory.memory_type,
|
||||
"origin_source_types": memory.origin_source_types or [],
|
||||
},
|
||||
importance=min(max(memory.importance / 10, 0.1), 1.0),
|
||||
)
|
||||
node = result.scalar_one_or_none()
|
||||
if node:
|
||||
existing_nodes[name] = node
|
||||
self.db.add(node)
|
||||
await self.db.flush()
|
||||
node_map[f"memory:{memory.id}"] = node
|
||||
|
||||
# 插入新节点
|
||||
entity_map = {}
|
||||
for entity_data in entities:
|
||||
name = entity_data["name"]
|
||||
if name in existing_nodes:
|
||||
entity_map[name] = existing_nodes[name].id
|
||||
else:
|
||||
node = KGNode(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
entity_type=entity_data["type"],
|
||||
description=entity_data.get("description", ""),
|
||||
source_document_id=chunk.document_id,
|
||||
)
|
||||
self.db.add(node)
|
||||
await self.db.flush()
|
||||
entity_map[name] = node.id
|
||||
|
||||
# 插入关系(去重)
|
||||
for rel in relations:
|
||||
src, tgt = rel["source"], rel["target"]
|
||||
if src not in entity_map or tgt not in entity_map:
|
||||
continue
|
||||
|
||||
# 检查关系是否已存在
|
||||
result = await self.db.execute(
|
||||
select(KGEdge).where(
|
||||
KGEdge.source_id == entity_map[src],
|
||||
KGEdge.target_id == entity_map[tgt],
|
||||
KGEdge.relation_type == rel["relation_type"],
|
||||
)
|
||||
for tag in tags:
|
||||
node = KGNode(
|
||||
user_id=user_id,
|
||||
name=tag.name,
|
||||
entity_type="tag",
|
||||
description=f"{tag.category} / {tag.priority}",
|
||||
properties_={
|
||||
"category": tag.category,
|
||||
"priority": tag.priority,
|
||||
"score": tag.score,
|
||||
},
|
||||
importance=min(max(tag.score / 10, 0.1), 1.0),
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if not existing:
|
||||
edge = KGEdge(
|
||||
source_id=entity_map[src],
|
||||
target_id=entity_map[tgt],
|
||||
relation_type=rel["relation_type"],
|
||||
)
|
||||
self.db.add(edge)
|
||||
self.db.add(node)
|
||||
await self.db.flush()
|
||||
node_map[f"tag:{tag.id}"] = node
|
||||
|
||||
for memory in memories:
|
||||
memory_node = node_map.get(f"memory:{memory.id}")
|
||||
if not memory_node:
|
||||
continue
|
||||
memory_text = f"{memory.title} {memory.content}".lower()
|
||||
for tag in tags:
|
||||
if tag.name.lower() in memory_text:
|
||||
tag_node = node_map.get(f"tag:{tag.id}")
|
||||
if not tag_node:
|
||||
continue
|
||||
self.db.add(KGEdge(
|
||||
source_id=memory_node.id,
|
||||
target_id=tag_node.id,
|
||||
relation_type="tagged_with",
|
||||
weight=min(max(tag.score / 10, 0.1), 1.0),
|
||||
))
|
||||
|
||||
memory_nodes = [node_map[f"memory:{memory.id}"] for memory in memories if f"memory:{memory.id}" in node_map]
|
||||
for index, source_node in enumerate(memory_nodes):
|
||||
for target_node in memory_nodes[index + 1:]:
|
||||
self.db.add(KGEdge(
|
||||
source_id=source_node.id,
|
||||
target_id=target_node.id,
|
||||
relation_type="related_to",
|
||||
weight=0.5,
|
||||
))
|
||||
|
||||
await self.db.commit()
|
||||
logger.info("[GraphService] brain 图谱投影完成")
|
||||
|
||||
async def get_graph_summary(self, user_id: str) -> str:
|
||||
"""获取用户图谱的整体摘要"""
|
||||
|
||||
@@ -5,11 +5,14 @@ OpenAI / Claude / Ollama / DeepSeek / 任意 OpenAI 兼容接口
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import AsyncIterator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from langchain_core.messages import BaseMessage, AIMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
from langchain_ollama import ChatOllama
|
||||
from app.config import settings
|
||||
from app.models.user import User
|
||||
import httpx
|
||||
import os
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import copy
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import verify_password, get_password_hash
|
||||
from app.logging_utils import summarize_llm_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,9 +51,7 @@ async def update_user_profile(
|
||||
|
||||
async def update_llm_config(user_id: str, config: dict, db: AsyncSession) -> dict:
|
||||
"""更新 LLM 配置"""
|
||||
import copy
|
||||
logger.info(f"update_llm_config called with config keys: {list(config.keys())}")
|
||||
logger.info(f"chat config: {config.get('chat')}")
|
||||
logger.info("update_llm_config called", extra={"details": {"keys": list(config.keys())}})
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
@@ -59,7 +59,7 @@ async def update_llm_config(user_id: str, config: dict, db: AsyncSession) -> dic
|
||||
|
||||
# 创建深拷贝,避免 SQLAlchemy 变更检测问题
|
||||
current = copy.deepcopy(user.llm_config) or {}
|
||||
logger.info(f"current llm_config before update: {current}")
|
||||
logger.info("llm_config before update", extra={"details": summarize_llm_config(current)})
|
||||
# 合并配置 - 直接替换整个类型配置列表
|
||||
for key, value in config.items():
|
||||
if value is not None:
|
||||
@@ -74,11 +74,11 @@ async def update_llm_config(user_id: str, config: dict, db: AsyncSession) -> dic
|
||||
current[key] = value
|
||||
else:
|
||||
current[key] = value
|
||||
logger.info(f"current llm_config after update: {current}")
|
||||
logger.info("llm_config after update", extra={"details": summarize_llm_config(current)})
|
||||
user.llm_config = current
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
logger.info(f"user.llm_config after refresh: {user.llm_config}")
|
||||
logger.info("user.llm_config after refresh", extra={"details": summarize_llm_config(user.llm_config)})
|
||||
return current
|
||||
|
||||
|
||||
|
||||
27
backend/app/services/system_service.py
Normal file
27
backend/app/services/system_service.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime, UTC
|
||||
|
||||
try:
|
||||
import psutil
|
||||
except ModuleNotFoundError: # pragma: no cover - optional runtime dependency fallback
|
||||
psutil = None
|
||||
|
||||
|
||||
class SystemService:
|
||||
def get_status(self) -> dict:
|
||||
if psutil is None:
|
||||
return {
|
||||
'cpu_percent': 0.0,
|
||||
'memory_percent': 0.0,
|
||||
'disk_percent': 0.0,
|
||||
'timestamp': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
cpu_percent = psutil.cpu_percent(interval=None)
|
||||
memory = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage('/')
|
||||
return {
|
||||
'cpu_percent': round(cpu_percent, 1),
|
||||
'memory_percent': round(memory.percent, 1),
|
||||
'disk_percent': round(disk.percent, 1),
|
||||
'timestamp': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
Reference in New Issue
Block a user