feat: restore risk assessment workspace and improve page scrolling
This commit is contained in:
+81
-12
@@ -19,12 +19,17 @@ class AgentManager:
|
||||
self._base_dir: Path | None = None
|
||||
self._tools: list = []
|
||||
self._llm = None
|
||||
self._raw_logs_dir: Path | None = None
|
||||
|
||||
def initialize(self, base_dir: Path) -> None:
|
||||
"""Initialize LLM (DeepSeek) and tools. Called once at startup."""
|
||||
self._base_dir = base_dir
|
||||
self._tools = get_all_tools(base_dir)
|
||||
|
||||
# 初始化原始报文日志目录 (base_dir 已是 backend/ 目录)
|
||||
self._raw_logs_dir = base_dir / "raw_logs"
|
||||
self._raw_logs_dir.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
# Use langchain-deepseek for native DeepSeek support
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
|
||||
@@ -70,7 +75,7 @@ class AgentManager:
|
||||
return messages
|
||||
|
||||
async def astream(
|
||||
self, message: str, history: list[dict[str, Any]]
|
||||
self, message: str, history: list[dict[str, Any]], session_id: str = "default"
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
"""Stream agent response with token-level and node-level events.
|
||||
|
||||
@@ -80,6 +85,8 @@ class AgentManager:
|
||||
{"type": "tool_start", "tool": "...", "input": "..."}
|
||||
{"type": "tool_end", "tool": "...", "output": "..."}
|
||||
{"type": "done", "content": "..."}
|
||||
|
||||
Also persists complete message flow to {session}.msgs.jsonl for trajectory.
|
||||
"""
|
||||
# RAG retrieval: inject memory context if enabled
|
||||
rag_mode = get_rag_mode()
|
||||
@@ -101,6 +108,31 @@ class AgentManager:
|
||||
)
|
||||
rag_context = f"[记忆检索结果]\n{snippets}"
|
||||
|
||||
# 为当前 session 创建原始报文回调
|
||||
from graph.raw_trace import RawTraceCallback
|
||||
|
||||
raw_callback = RawTraceCallback(session_id, self._raw_logs_dir)
|
||||
|
||||
# 消息流日志文件(记录完整的 human/assistant/tool 消息序列)
|
||||
import json
|
||||
import time
|
||||
msgs_log_file = self._raw_logs_dir / f"{session_id}.msgs.jsonl"
|
||||
|
||||
# 记录 human 消息
|
||||
def _append_msg(role: str, content: str, **kwargs):
|
||||
"""追加一条消息到 msgs.jsonl"""
|
||||
msg_record = {
|
||||
"role": role,
|
||||
"content": content,
|
||||
"timestamp": time.time(),
|
||||
**kwargs
|
||||
}
|
||||
with open(msgs_log_file, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(msg_record, ensure_ascii=False) + "\n")
|
||||
|
||||
_append_msg("human", message)
|
||||
|
||||
# 构建 agent 时传入 callback
|
||||
agent = self._build_agent()
|
||||
|
||||
# Build messages with optional RAG context appended to history
|
||||
@@ -113,10 +145,13 @@ class AgentManager:
|
||||
|
||||
full_response = ""
|
||||
tools_just_finished = False
|
||||
current_assistant_content = "" # 当前 assistant 消息累积
|
||||
current_tool_calls = [] # 当前 assistant 的 tool_calls
|
||||
|
||||
async for event in agent.astream(
|
||||
{"messages": messages},
|
||||
stream_mode=["messages", "updates"],
|
||||
config={"callbacks": [raw_callback]},
|
||||
):
|
||||
# event is a tuple of (stream_mode, data) when using multiple modes
|
||||
if isinstance(event, tuple):
|
||||
@@ -136,31 +171,65 @@ class AgentManager:
|
||||
yield {"type": "new_response"}
|
||||
tools_just_finished = False
|
||||
full_response += msg.content
|
||||
current_assistant_content += msg.content
|
||||
yield {"type": "token", "content": msg.content}
|
||||
|
||||
elif mode == "updates":
|
||||
if isinstance(data, dict):
|
||||
for node_name, node_data in data.items():
|
||||
if node_name == "tools" and "messages" in node_data:
|
||||
for tool_msg in node_data["messages"]:
|
||||
if hasattr(tool_msg, "name"):
|
||||
yield {
|
||||
"type": "tool_end",
|
||||
"tool": tool_msg.name,
|
||||
"output": str(tool_msg.content)[:2000],
|
||||
}
|
||||
# After all tool results, mark that tools finished
|
||||
tools_just_finished = True
|
||||
elif node_name == "model" and "messages" in node_data:
|
||||
if node_name == "model" and "messages" in node_data:
|
||||
# 模型节点:记录 assistant 消息和 tool_calls
|
||||
for agent_msg in node_data["messages"]:
|
||||
if hasattr(agent_msg, "tool_calls") and agent_msg.tool_calls:
|
||||
# 记录 assistant 消息(带 tool_calls)
|
||||
tool_calls_data = []
|
||||
for tc in agent_msg.tool_calls:
|
||||
tool_call_info = {
|
||||
"name": tc["name"],
|
||||
"args": tc.get("args", {})
|
||||
}
|
||||
tool_calls_data.append(tool_call_info)
|
||||
current_tool_calls.append(tool_call_info)
|
||||
yield {
|
||||
"type": "tool_start",
|
||||
"tool": tc["name"],
|
||||
"input": str(tc.get("args", ""))[:1000],
|
||||
}
|
||||
|
||||
# 记录带 tool_calls 的 assistant 消息
|
||||
_append_msg(
|
||||
"assistant",
|
||||
agent_msg.content if hasattr(agent_msg, "content") else "",
|
||||
tool_calls=tool_calls_data
|
||||
)
|
||||
|
||||
elif node_name == "tools" and "messages" in node_data:
|
||||
# 工具节点:记录每个 ToolMessage 的返回内容
|
||||
for tool_msg in node_data["messages"]:
|
||||
if hasattr(tool_msg, "name"):
|
||||
tool_output = str(tool_msg.content)
|
||||
# 记录 tool 消息(完整 content)
|
||||
_append_msg(
|
||||
"tool",
|
||||
tool_output,
|
||||
name=tool_msg.name,
|
||||
tool_call_id=getattr(tool_msg, "tool_call_id", None)
|
||||
)
|
||||
yield {
|
||||
"type": "tool_end",
|
||||
"tool": tool_msg.name,
|
||||
"output": tool_output[:2000], # yield 时截断,但完整内容已记录
|
||||
}
|
||||
# After all tool results, mark that tools finished
|
||||
tools_just_finished = True
|
||||
|
||||
# 记录最终的 assistant 消息(纯回复,无 tool_calls)
|
||||
if current_assistant_content and not current_tool_calls:
|
||||
_append_msg("assistant", current_assistant_content)
|
||||
elif current_assistant_content and current_tool_calls:
|
||||
# 如果有 tool_calls,记录最后一轮的纯文本回复
|
||||
_append_msg("assistant", current_assistant_content)
|
||||
|
||||
yield {"type": "done", "content": full_response}
|
||||
|
||||
async def ainvoke(self, message: str, session_id: str) -> str:
|
||||
|
||||
@@ -65,6 +65,9 @@ class MemoryIndexer:
|
||||
# 使用 HuggingFace 镜像加速模型下载
|
||||
if "HF_ENDPOINT" not in os.environ:
|
||||
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
|
||||
# 离线加载已缓存的嵌入模型(避免启动时联网挂起阻塞)
|
||||
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
||||
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
|
||||
|
||||
Settings.embed_model = HuggingFaceEmbedding(
|
||||
model_name=os.getenv("EMBEDDING_MODEL", "BAAI/bge-small-zh"),
|
||||
@@ -112,6 +115,9 @@ class MemoryIndexer:
|
||||
# 使用 HuggingFace 镜像加速模型下载
|
||||
if "HF_ENDPOINT" not in os.environ:
|
||||
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
|
||||
# 离线加载已缓存的嵌入模型(避免联网挂起阻塞)
|
||||
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
||||
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
|
||||
|
||||
Settings.embed_model = HuggingFaceEmbedding(
|
||||
model_name=os.getenv("EMBEDDING_MODEL", "BAAI/bge-small-zh"),
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""原始报文录制 - LangChain 回调处理器,捕获每次 LLM 调用的完整请求/响应"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from langchain_core.callbacks import AsyncCallbackHandler
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langchain_core.outputs import LLMResult
|
||||
|
||||
|
||||
class RawTraceCallback(AsyncCallbackHandler):
|
||||
"""录制每次 LLM 调用的完整原始请求和响应到 JSONL 文件"""
|
||||
|
||||
def __init__(self, session_id: str, raw_logs_dir: Path):
|
||||
super().__init__()
|
||||
self.session_id = session_id
|
||||
self.raw_logs_dir = raw_logs_dir
|
||||
self.raw_logs_dir.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
# 当前调用的跟踪信息 (key: run_id, value: trace_data)
|
||||
self._traces: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def _serialize_message(self, msg: BaseMessage) -> Dict[str, Any]:
|
||||
"""将 LangChain 消息序列化为 JSON-safe dict"""
|
||||
try:
|
||||
result = {
|
||||
"type": msg.type,
|
||||
"content": msg.content if hasattr(msg, "content") else "",
|
||||
}
|
||||
# 提取额外字段
|
||||
if hasattr(msg, "name"):
|
||||
result["name"] = msg.name
|
||||
if hasattr(msg, "tool_calls") and msg.tool_calls:
|
||||
result["tool_calls"] = msg.tool_calls
|
||||
if hasattr(msg, "additional_kwargs") and msg.additional_kwargs:
|
||||
result["additional_kwargs"] = msg.additional_kwargs
|
||||
return result
|
||||
except Exception:
|
||||
return {"type": "unknown", "content": str(msg)}
|
||||
|
||||
async def on_llm_start(
|
||||
self,
|
||||
serialized: Dict[str, Any],
|
||||
prompts: List[str],
|
||||
*,
|
||||
run_id: uuid.UUID,
|
||||
parent_run_id: Optional[uuid.UUID] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""LLM 调用开始 - 记录请求信息"""
|
||||
try:
|
||||
# 提取 messages (如果是 chat model)
|
||||
messages_raw = kwargs.get("invocation_params", {}).get("messages", [])
|
||||
messages_serialized = []
|
||||
|
||||
if messages_raw:
|
||||
for msg in messages_raw:
|
||||
if isinstance(msg, BaseMessage):
|
||||
messages_serialized.append(self._serialize_message(msg))
|
||||
elif isinstance(msg, dict):
|
||||
messages_serialized.append(msg)
|
||||
else:
|
||||
messages_serialized.append({"content": str(msg)})
|
||||
elif prompts:
|
||||
# 如果没有 messages,使用 prompts
|
||||
messages_serialized = [{"type": "human", "content": p} for p in prompts]
|
||||
|
||||
# 提取模型参数
|
||||
invocation_params = kwargs.get("invocation_params", {})
|
||||
model_name = invocation_params.get("model", "unknown")
|
||||
temperature = invocation_params.get("temperature", None)
|
||||
|
||||
trace_data = {
|
||||
"trace_id": str(run_id),
|
||||
"session_id": self.session_id,
|
||||
"timestamp_start": time.time(),
|
||||
"request": {
|
||||
"model": model_name,
|
||||
"messages": messages_serialized,
|
||||
"temperature": temperature,
|
||||
"invocation_params": {
|
||||
k: v for k, v in invocation_params.items()
|
||||
if k not in ["messages", "api_key"] # 过滤敏感信息
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
self._traces[str(run_id)] = trace_data
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
async def on_llm_end(
|
||||
self,
|
||||
response: LLMResult,
|
||||
*,
|
||||
run_id: uuid.UUID,
|
||||
parent_run_id: Optional[uuid.UUID] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""LLM 调用结束 - 记录响应信息并落盘"""
|
||||
try:
|
||||
trace_id = str(run_id)
|
||||
if trace_id not in self._traces:
|
||||
return
|
||||
|
||||
trace_data = self._traces[trace_id]
|
||||
timestamp_end = time.time()
|
||||
duration_ms = int((timestamp_end - trace_data["timestamp_start"]) * 1000)
|
||||
|
||||
# 提取响应内容
|
||||
generations = response.generations[0] if response.generations else []
|
||||
response_messages = []
|
||||
|
||||
# 提取 finish_reason 和 usage (优先从 message.response_metadata,支持流式)
|
||||
finish_reason = "unknown"
|
||||
usage_metadata = None
|
||||
|
||||
for gen in generations:
|
||||
msg_dict = {
|
||||
"text": gen.text if hasattr(gen, "text") else "",
|
||||
}
|
||||
|
||||
# 提取 message (AIMessage)
|
||||
if hasattr(gen, "message") and gen.message:
|
||||
msg = gen.message
|
||||
msg_dict["message"] = self._serialize_message(msg)
|
||||
|
||||
# 优先从 message.response_metadata 提取 finish_reason (流式模式)
|
||||
if hasattr(msg, "response_metadata") and msg.response_metadata:
|
||||
if "finish_reason" in msg.response_metadata and finish_reason == "unknown":
|
||||
finish_reason = msg.response_metadata["finish_reason"]
|
||||
# 提取流式聚合后的 usage_metadata
|
||||
if "usage_metadata" in msg.response_metadata and not usage_metadata:
|
||||
usage_metadata = msg.response_metadata["usage_metadata"]
|
||||
|
||||
# 也从 message.usage_metadata 提取(LangChain 新版本)
|
||||
if hasattr(msg, "usage_metadata") and msg.usage_metadata and not usage_metadata:
|
||||
usage_metadata = msg.usage_metadata
|
||||
|
||||
# 其次从 generation_info 提取 finish_reason
|
||||
if finish_reason == "unknown" and hasattr(gen, "generation_info") and gen.generation_info:
|
||||
if "finish_reason" in gen.generation_info:
|
||||
finish_reason = gen.generation_info["finish_reason"]
|
||||
|
||||
response_messages.append(msg_dict)
|
||||
|
||||
# 提取 llm_output (包含 usage 等信息)
|
||||
llm_output = response.llm_output or {}
|
||||
|
||||
trace_data["timestamp_end"] = timestamp_end
|
||||
trace_data["duration_ms"] = duration_ms
|
||||
trace_data["response"] = {
|
||||
"generations": response_messages,
|
||||
"llm_output": llm_output,
|
||||
}
|
||||
|
||||
# 提取 token usage:优先使用 usage_metadata(流式),其次 llm_output
|
||||
if usage_metadata:
|
||||
# usage_metadata 通常是字典或对象,尝试兼容两种格式
|
||||
if isinstance(usage_metadata, dict):
|
||||
trace_data["usage"] = {
|
||||
"input_tokens": usage_metadata.get("input_tokens", 0),
|
||||
"output_tokens": usage_metadata.get("output_tokens", 0),
|
||||
"total_tokens": usage_metadata.get("total_tokens", 0),
|
||||
}
|
||||
else:
|
||||
# 对象格式(有属性访问)
|
||||
trace_data["usage"] = {
|
||||
"input_tokens": getattr(usage_metadata, "input_tokens", 0),
|
||||
"output_tokens": getattr(usage_metadata, "output_tokens", 0),
|
||||
"total_tokens": getattr(usage_metadata, "total_tokens", 0),
|
||||
}
|
||||
else:
|
||||
# 回退到 llm_output.token_usage(非流式)
|
||||
token_usage = llm_output.get("token_usage", {})
|
||||
trace_data["usage"] = {
|
||||
"input_tokens": token_usage.get("prompt_tokens", 0),
|
||||
"output_tokens": token_usage.get("completion_tokens", 0),
|
||||
"total_tokens": token_usage.get("total_tokens", 0),
|
||||
}
|
||||
|
||||
# 最后从 llm_output 兜底 finish_reason(非流式)
|
||||
if finish_reason == "unknown" and "finish_reason" in llm_output:
|
||||
finish_reason = llm_output["finish_reason"]
|
||||
|
||||
# 统一字段:finish_reason(向后兼容,保留 stop_reason)
|
||||
trace_data["finish_reason"] = finish_reason
|
||||
trace_data["stop_reason"] = finish_reason # 向后兼容旧前端代码
|
||||
|
||||
# 追加写入 JSONL 文件
|
||||
log_file = self.raw_logs_dir / f"{self.session_id}.jsonl"
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(trace_data, ensure_ascii=False) + "\n")
|
||||
|
||||
# 清理已完成的 trace
|
||||
del self._traces[trace_id]
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
async def on_llm_error(
|
||||
self,
|
||||
error: Exception,
|
||||
*,
|
||||
run_id: uuid.UUID,
|
||||
parent_run_id: Optional[uuid.UUID] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""LLM 调用出错 - 记录错误"""
|
||||
try:
|
||||
trace_id = str(run_id)
|
||||
if trace_id not in self._traces:
|
||||
return
|
||||
|
||||
trace_data = self._traces[trace_id]
|
||||
timestamp_end = time.time()
|
||||
duration_ms = int((timestamp_end - trace_data["timestamp_start"]) * 1000)
|
||||
|
||||
trace_data["timestamp_end"] = timestamp_end
|
||||
trace_data["duration_ms"] = duration_ms
|
||||
trace_data["error"] = {
|
||||
"type": type(error).__name__,
|
||||
"message": str(error),
|
||||
}
|
||||
|
||||
# 写入文件
|
||||
log_file = self.raw_logs_dir / f"{self.session_id}.jsonl"
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(trace_data, ensure_ascii=False) + "\n")
|
||||
|
||||
del self._traces[trace_id]
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def load_raw_traces(session_id: str, raw_logs_dir: Path, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""读取指定 session 的原始报文记录
|
||||
|
||||
Args:
|
||||
session_id: 会话 ID
|
||||
raw_logs_dir: 原始日志目录
|
||||
limit: 返回最近 N 条记录
|
||||
|
||||
Returns:
|
||||
原始报文列表,按时间倒序
|
||||
"""
|
||||
log_file = raw_logs_dir / f"{session_id}.jsonl"
|
||||
if not log_file.exists():
|
||||
return []
|
||||
|
||||
traces = []
|
||||
try:
|
||||
with open(log_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
traces.append(json.loads(line))
|
||||
|
||||
# 按时间倒序,返回最近 limit 条
|
||||
traces.sort(key=lambda x: x.get("timestamp_start", 0), reverse=True)
|
||||
return traces[:limit]
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return []
|
||||
Reference in New Issue
Block a user