feat: enhance agent orchestration, knowledge flow and UI refinements
This commit is contained in:
@@ -1,397 +1,354 @@
|
||||
"""
|
||||
Jarvis LangGraph Agent 主图定义
|
||||
Jarvis LangGraph Agent 主图定义 - 优化重构版
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Literal, Union, List, Any
|
||||
|
||||
from langchain_core.messages import (
|
||||
BaseMessage,
|
||||
HumanMessage,
|
||||
AIMessage,
|
||||
SystemMessage,
|
||||
ToolMessage
|
||||
)
|
||||
from langgraph.graph import StateGraph, END
|
||||
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage, BaseMessage
|
||||
|
||||
from app.agents.state import AgentState, AgentRole
|
||||
from app.agents.prompts import (
|
||||
MASTER_SYSTEM_PROMPT,
|
||||
PLANNER_SYSTEM_PROMPT,
|
||||
SCHEDULE_PLANNER_SYSTEM_PROMPT,
|
||||
EXECUTOR_SYSTEM_PROMPT,
|
||||
LIBRARIAN_SYSTEM_PROMPT,
|
||||
ANALYST_SYSTEM_PROMPT,
|
||||
PLANNER_SCOPE_PROMPT,
|
||||
PLANNER_STEPS_PROMPT,
|
||||
EXECUTOR_TASKS_PROMPT,
|
||||
EXECUTOR_FORUM_PROMPT,
|
||||
LIBRARIAN_RETRIEVAL_PROMPT,
|
||||
LIBRARIAN_GRAPH_PROMPT,
|
||||
ANALYST_PROGRESS_PROMPT,
|
||||
ANALYST_INSIGHTS_PROMPT,
|
||||
JSON_ACTION_FALLBACK_PROMPT,
|
||||
)
|
||||
from app.agents.tools import ALL_TOOLS, SUB_COMMANDER_TOOLSETS
|
||||
from app.agents.tools.time_reasoning import normalize_tool_time_arguments
|
||||
from app.agents.skill_registry import build_skill_context
|
||||
from app.services.llm_service import get_llm
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
from langchain_ollama import ChatOllama
|
||||
import httpx
|
||||
from app.services.llm_service import (
|
||||
get_llm,
|
||||
create_llm_from_config,
|
||||
resolve_provider_capabilities,
|
||||
default_provider_capabilities
|
||||
)
|
||||
from app.logging_utils import summarize_llm_config
|
||||
|
||||
logger = logging.getLogger("jarvis.agent")
|
||||
|
||||
SUB_COMMANDER_PROMPTS = {
|
||||
"planner_scope": PLANNER_SCOPE_PROMPT,
|
||||
"planner_steps": PLANNER_STEPS_PROMPT,
|
||||
"executor_tasks": EXECUTOR_TASKS_PROMPT,
|
||||
"executor_forum": EXECUTOR_FORUM_PROMPT,
|
||||
"librarian_retrieval": LIBRARIAN_RETRIEVAL_PROMPT,
|
||||
"librarian_graph": LIBRARIAN_GRAPH_PROMPT,
|
||||
"analyst_progress": ANALYST_PROGRESS_PROMPT,
|
||||
"analyst_insights": ANALYST_INSIGHTS_PROMPT,
|
||||
}
|
||||
|
||||
ROLE_SUB_COMMANDERS = {
|
||||
AgentRole.PLANNER: ["planner_scope", "planner_steps"],
|
||||
AgentRole.EXECUTOR: ["executor_tasks", "executor_forum"],
|
||||
AgentRole.LIBRARIAN: ["librarian_retrieval", "librarian_graph"],
|
||||
AgentRole.ANALYST: ["analyst_progress", "analyst_insights"],
|
||||
}
|
||||
|
||||
ROLE_SKILL_CONTEXT = {
|
||||
AgentRole.PLANNER: "planner",
|
||||
AgentRole.EXECUTOR: "executor",
|
||||
AgentRole.LIBRARIAN: "librarian",
|
||||
AgentRole.ANALYST: "analyst",
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
return ChatOpenAI(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
base_url=base_url or None,
|
||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
||||
)
|
||||
|
||||
# ===================== 工具辅助函数 =====================
|
||||
|
||||
def _get_llm_for_state(state: AgentState):
|
||||
"""从 state 获取 LLM 实例,优先使用用户配置的模型"""
|
||||
"""获取配置好的 LLM 实例"""
|
||||
user_llm_config = state.get("user_llm_config")
|
||||
if user_llm_config:
|
||||
return _create_llm_from_config(user_llm_config)
|
||||
return get_llm()
|
||||
llm = create_llm_from_config(user_llm_config) if user_llm_config else get_llm()
|
||||
|
||||
# 注入解析到的能力
|
||||
capabilities = getattr(llm, "_jarvis_provider_capabilities", None)
|
||||
if capabilities is None:
|
||||
capabilities = resolve_provider_capabilities(user_llm_config) if user_llm_config else default_provider_capabilities()
|
||||
|
||||
state["provider_capabilities"] = {
|
||||
"provider": capabilities.provider,
|
||||
"supports_native_tools": capabilities.supports_native_tools,
|
||||
"preferred_tool_strategy": capabilities.preferred_tool_strategy,
|
||||
}
|
||||
return llm, capabilities
|
||||
|
||||
|
||||
async def _ainvoke(llm, messages: list[BaseMessage]):
|
||||
ainvoke = getattr(llm, "ainvoke", None)
|
||||
if callable(ainvoke):
|
||||
return await ainvoke(messages)
|
||||
return await llm.invoke(messages)
|
||||
def _filter_user_messages(messages: list[BaseMessage]) -> list[BaseMessage]:
|
||||
return [m for m in messages if m.type in ("human", "user")]
|
||||
|
||||
|
||||
async def _ainvoke_with_tools(llm, messages: list[BaseMessage], tools=None):
|
||||
toolset = tools if tools is not None else ALL_TOOLS
|
||||
bound_llm = llm.bind_tools(toolset)
|
||||
if hasattr(bound_llm, "ainvoke"):
|
||||
return await bound_llm.ainvoke(messages)
|
||||
return await bound_llm.invoke(messages)
|
||||
|
||||
|
||||
def _compile_graph(graph: StateGraph, callbacks: list | None = None):
|
||||
if callbacks:
|
||||
try:
|
||||
return graph.compile(callbacks=callbacks)
|
||||
except TypeError as exc:
|
||||
if "callbacks" not in str(exc):
|
||||
raise
|
||||
return graph.compile()
|
||||
|
||||
|
||||
def _msg_type(msg: BaseMessage) -> str:
|
||||
"""Get message type, handles both .type (new) and .role (old) attribute names."""
|
||||
return getattr(msg, "type", None) or getattr(msg, "role", "human")
|
||||
|
||||
|
||||
def _filter_user_messages(messages: list) -> list[BaseMessage]:
|
||||
return [m for m in messages if _msg_type(m) in ("human", "user")]
|
||||
|
||||
|
||||
def _normalize_user_text(text: str) -> str:
|
||||
return (text or "").strip().lower()
|
||||
|
||||
|
||||
def _is_simple_greeting(text: str) -> bool:
|
||||
normalized = _normalize_user_text(text)
|
||||
return normalized in {"你好", "您好", "早", "早上好", "在吗", "嗨", "hi", "hello"}
|
||||
|
||||
|
||||
def _is_identity_question(text: str) -> bool:
|
||||
normalized = _normalize_user_text(text)
|
||||
return normalized in {"你是谁", "你是誰"}
|
||||
|
||||
|
||||
def _is_capability_question(text: str) -> bool:
|
||||
normalized = _normalize_user_text(text)
|
||||
return normalized in {"你能做什么", "你可以做什么", "你会做什么"}
|
||||
|
||||
|
||||
def _choose_sub_commander(role: AgentRole, user_query: str) -> str:
|
||||
text = _normalize_user_text(user_query)
|
||||
|
||||
if role == AgentRole.PLANNER:
|
||||
if any(keyword in text for keyword in ["步骤", "计划", "拆解", "排期", "优先级", "路线"]):
|
||||
return "planner_steps"
|
||||
return "planner_scope"
|
||||
|
||||
def _get_role_tools(role: AgentRole) -> list:
|
||||
"""获取角色对应的所有可用工具集"""
|
||||
if role == AgentRole.SCHEDULE_PLANNER:
|
||||
# 合并分析和规划工具
|
||||
return list(set(SUB_COMMANDER_TOOLSETS["schedule_analysis"] + SUB_COMMANDER_TOOLSETS["schedule_planning"]))
|
||||
if role == AgentRole.EXECUTOR:
|
||||
if any(keyword in text for keyword in ["论坛", "帖子", "发帖", "指令", "discussion", "instruction"]):
|
||||
return "executor_forum"
|
||||
return "executor_tasks"
|
||||
|
||||
return list(set(SUB_COMMANDER_TOOLSETS["executor_tasks"] + SUB_COMMANDER_TOOLSETS["executor_forum"]))
|
||||
if role == AgentRole.LIBRARIAN:
|
||||
if any(keyword in text for keyword in ["图谱", "关系", "构建", "沉淀", "节点", "graph"]):
|
||||
return "librarian_graph"
|
||||
return "librarian_retrieval"
|
||||
|
||||
return list(set(SUB_COMMANDER_TOOLSETS["librarian_retrieval"] + SUB_COMMANDER_TOOLSETS["librarian_graph"]))
|
||||
if role == AgentRole.ANALYST:
|
||||
if any(keyword in text for keyword in ["趋势", "风险", "洞察", "建议", "机会", "insight"]):
|
||||
return "analyst_insights"
|
||||
return "analyst_progress"
|
||||
|
||||
return ROLE_SUB_COMMANDERS[role][0]
|
||||
return list(set(SUB_COMMANDER_TOOLSETS["analyst_progress"] + SUB_COMMANDER_TOOLSETS["analyst_insights"]))
|
||||
return []
|
||||
|
||||
|
||||
def _record_sub_commander(state: AgentState, sub_commander: str, user_query: str):
|
||||
state["current_sub_commander"] = sub_commander
|
||||
state["active_sub_commanders"] = state.get("active_sub_commanders", []) + [sub_commander]
|
||||
state["sub_commander_trace"] = state.get("sub_commander_trace", []) + [{
|
||||
"agent": state.get("current_agent", AgentRole.MASTER).value,
|
||||
"sub_commander": sub_commander,
|
||||
"query": user_query,
|
||||
}]
|
||||
# ===================== 核心执行逻辑 (ReAct) =====================
|
||||
|
||||
|
||||
def _build_system_messages(state: AgentState, system_prompt: str, role: AgentRole):
|
||||
system_msgs: list[BaseMessage] = [SystemMessage(content=system_prompt)]
|
||||
skill_ctx = build_skill_context(ROLE_SKILL_CONTEXT[role])
|
||||
async def call_agent_llm(state: AgentState, role: AgentRole, system_prompt: str) -> dict:
|
||||
"""通用的 LLM 调用节点逻辑"""
|
||||
llm, capabilities = _get_llm_for_state(state)
|
||||
tools = _get_role_tools(role)
|
||||
|
||||
# 构建消息序列
|
||||
messages = []
|
||||
|
||||
# 1. 系统提示词
|
||||
messages.append(SystemMessage(content=system_prompt))
|
||||
|
||||
# 2. 环境上下文 (时间、记忆等)
|
||||
if state.get("current_datetime_context"):
|
||||
messages.append(SystemMessage(content=f"当前时间上下文: {state['current_datetime_context']}"))
|
||||
|
||||
if state.get("memory_context"):
|
||||
messages.append(SystemMessage(content=f"长期记忆上下文: {state['memory_context']}"))
|
||||
|
||||
# 3. 技能增强
|
||||
role_skill_key = role.value.replace("agent_", "")
|
||||
skill_ctx = build_skill_context(role_skill_key)
|
||||
if skill_ctx:
|
||||
system_msgs.append(SystemMessage(content=skill_ctx))
|
||||
return system_msgs
|
||||
messages.append(SystemMessage(content=skill_ctx))
|
||||
|
||||
# 4. 历史对话 (add_messages 已经处理好了)
|
||||
messages.extend(state["messages"])
|
||||
|
||||
# 绑定工具
|
||||
if tools and capabilities.supports_native_tools:
|
||||
llm_with_tools = llm.bind_tools(tools)
|
||||
else:
|
||||
llm_with_tools = llm
|
||||
if tools: # 如果有工具但不支持原生,注入 JSON Fallback 提示
|
||||
messages.append(SystemMessage(content=JSON_ACTION_FALLBACK_PROMPT))
|
||||
tool_names = [t.name for t in tools]
|
||||
messages.append(SystemMessage(content=f"本次可用工具列表: {', '.join(tool_names)}"))
|
||||
|
||||
logger.info(
|
||||
f"agent_node_started",
|
||||
extra={
|
||||
"details": {
|
||||
"role": role.value,
|
||||
"message_count": len(messages),
|
||||
"tool_count": len(tools),
|
||||
"provider": capabilities.provider
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# 执行调用
|
||||
response = await llm_with_tools.ainvoke(messages)
|
||||
|
||||
logger.info(
|
||||
f"agent_node_finished",
|
||||
extra={
|
||||
"details": {
|
||||
"role": role.value,
|
||||
"has_tool_calls": bool(getattr(response, "tool_calls", None)),
|
||||
"content_length": len(response.content) if response.content else 0
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {"messages": [response]}
|
||||
|
||||
|
||||
async def _run_sub_commander(
|
||||
state: AgentState,
|
||||
role: AgentRole,
|
||||
manager_prompt: str,
|
||||
user_query: str,
|
||||
*,
|
||||
use_tools: bool,
|
||||
summary_target: str | None = None,
|
||||
):
|
||||
llm = _get_llm_for_state(state)
|
||||
sub_commander = _choose_sub_commander(role, user_query)
|
||||
_record_sub_commander(state, sub_commander, user_query)
|
||||
|
||||
toolset = SUB_COMMANDER_TOOLSETS.get(sub_commander, [])
|
||||
system_msgs = _build_system_messages(state, manager_prompt, role)
|
||||
system_msgs.append(SystemMessage(content=f"本次应由子指挥官 `{sub_commander}` 接手。请严格按该角色职责输出。"))
|
||||
system_msgs.append(SystemMessage(content=SUB_COMMANDER_PROMPTS[sub_commander]))
|
||||
|
||||
if use_tools and toolset:
|
||||
response = await _ainvoke_with_tools(
|
||||
llm,
|
||||
system_msgs + [HumanMessage(content=f"用户请求: {user_query}")],
|
||||
toolset,
|
||||
async def execute_tools_node(state: AgentState) -> dict:
|
||||
"""执行工具调用并返回 ToolMessage 的通用节点"""
|
||||
last_message = state["messages"][-1]
|
||||
if not hasattr(last_message, "tool_calls") or not last_message.tool_calls:
|
||||
return {"messages": []}
|
||||
|
||||
tool_map = {t.name: t for t in ALL_TOOLS}
|
||||
tool_messages = []
|
||||
created_entities = []
|
||||
|
||||
for tool_call in last_message.tool_calls:
|
||||
tool_name = tool_call["name"]
|
||||
tool_args = tool_call["args"]
|
||||
tool_id = tool_call.get("id")
|
||||
|
||||
logger.info(
|
||||
f"tool_execution_started",
|
||||
extra={
|
||||
"details": {
|
||||
"tool_name": tool_name,
|
||||
"tool_args": tool_args,
|
||||
"tool_id": tool_id
|
||||
}
|
||||
}
|
||||
)
|
||||
tool_calls = getattr(response, "tool_calls", None) or []
|
||||
if tool_calls:
|
||||
results = []
|
||||
for tc in tool_calls:
|
||||
tool_name = tc.get("name")
|
||||
args = tc.get("args", {})
|
||||
for tool in toolset:
|
||||
if tool.name == tool_name:
|
||||
try:
|
||||
result = tool.invoke(args)
|
||||
results.append(f"[{tool_name}] {result}")
|
||||
except Exception as e:
|
||||
results.append(f"[{tool_name}] 执行失败: {e}")
|
||||
break
|
||||
state["tool_calls"] = tool_calls
|
||||
state["last_tool_result"] = "\n".join(results)
|
||||
follow_up = await _ainvoke(
|
||||
llm,
|
||||
[
|
||||
SystemMessage(content=SUB_COMMANDER_PROMPTS[sub_commander]),
|
||||
HumanMessage(content=f"工具执行结果:\n{state['last_tool_result']}")
|
||||
]
|
||||
|
||||
try:
|
||||
# 时间参数归一化
|
||||
normalized_args = normalize_tool_time_arguments(
|
||||
tool_name,
|
||||
tool_args,
|
||||
state.get("current_datetime_context")
|
||||
)
|
||||
state["final_response"] = follow_up.content
|
||||
else:
|
||||
state["final_response"] = response.content
|
||||
else:
|
||||
response = await _ainvoke(
|
||||
llm,
|
||||
system_msgs + [HumanMessage(content=f"用户请求: {user_query}")],
|
||||
)
|
||||
state["final_response"] = response.content
|
||||
|
||||
if summary_target:
|
||||
state[summary_target] = state.get("final_response", "")
|
||||
state["should_respond"] = True
|
||||
return state
|
||||
|
||||
|
||||
# ===================== 节点定义 (async) =====================
|
||||
|
||||
async def master_node(state: AgentState) -> AgentState:
|
||||
"""主Agent节点: 理解用户意图,决定调用哪个子Agent"""
|
||||
messages: list[BaseMessage] = state["messages"]
|
||||
user_msgs = _filter_user_messages(messages)
|
||||
user_query = user_msgs[-1].content.strip() if user_msgs else ""
|
||||
|
||||
if _is_simple_greeting(user_query):
|
||||
state["final_response"] = "您好。我在。\n\n您把问题给我,我先帮您收束重点,再往下推。"
|
||||
state["should_respond"] = True
|
||||
return state
|
||||
|
||||
if _is_identity_question(user_query):
|
||||
state["final_response"] = "我是 Jarvis。\n\n比起做一个泛泛的助手,我更像您的判断型协作伙伴:帮您看清问题、压缩路径、把事情往前推进。"
|
||||
state["should_respond"] = True
|
||||
return state
|
||||
|
||||
if _is_capability_question(user_query):
|
||||
state["final_response"] = "主要做三件事。\n- 帮您判断:看问题本质、梳理取舍、给出方向\n- 帮您收束:把复杂内容理顺,把重点拎出来\n- 帮您推进:拆任务、定步骤、把下一步变清楚\n\n如果您现在有具体目标,我可以直接进入处理。"
|
||||
state["should_respond"] = True
|
||||
return state
|
||||
|
||||
llm = _get_llm_for_state(state)
|
||||
system_msgs: list[BaseMessage] = [SystemMessage(content=MASTER_SYSTEM_PROMPT)]
|
||||
|
||||
memory_ctx = state.get("memory_context")
|
||||
if memory_ctx:
|
||||
system_msgs.append(
|
||||
SystemMessage(content=f"\n\n【记忆上下文】\n{memory_ctx}\n\n---\n")
|
||||
|
||||
tool = tool_map.get(tool_name)
|
||||
if not tool:
|
||||
result = f"Error: Tool {tool_name} not found."
|
||||
else:
|
||||
result = await tool.ainvoke(normalized_args) if hasattr(tool, "ainvoke") else tool.invoke(normalized_args)
|
||||
|
||||
# 实体识别(用于业务追踪)
|
||||
if any(k in tool_name for k in ["create", "add", "new"]):
|
||||
created_entities.append({"tool": tool_name, "result": str(result)})
|
||||
|
||||
status = "success"
|
||||
except Exception as e:
|
||||
logger.exception(f"tool_execution_failed: {tool_name}")
|
||||
result = f"Error executing tool {tool_name}: {str(e)}"
|
||||
status = "failed"
|
||||
|
||||
tool_messages.append(ToolMessage(
|
||||
tool_call_id=tool_id,
|
||||
content=str(result),
|
||||
name=tool_name
|
||||
))
|
||||
|
||||
logger.info(
|
||||
f"tool_execution_finished",
|
||||
extra={
|
||||
"details": {
|
||||
"tool_name": tool_name,
|
||||
"status": status,
|
||||
"result_preview": str(result)[:200]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
response: AIMessage = await _ainvoke(llm, system_msgs + messages)
|
||||
return {
|
||||
"messages": tool_messages,
|
||||
"created_entities": state.get("created_entities", []) + created_entities
|
||||
}
|
||||
|
||||
|
||||
# ===================== 各角色节点定义 =====================
|
||||
|
||||
async def master_node(state: AgentState) -> dict:
|
||||
"""主控节点:负责意图识别与初步分发"""
|
||||
user_messages = _filter_user_messages(state["messages"])
|
||||
if not user_messages:
|
||||
return {"final_response": "未收到有效输入。"}
|
||||
|
||||
query = user_messages[-1].content.strip()
|
||||
|
||||
# 快捷回复逻辑 (保留原有的人性化设计)
|
||||
if re.match(r"^(你好|早|在吗|嗨|hi|hello)", query.lower()):
|
||||
return {"final_response": "您好。我在。\n\n您把问题给我,我先帮您收束重点,再往下推。", "messages": [AIMessage(content="您好。我在。")]}
|
||||
|
||||
llm, capabilities = _get_llm_for_state(state)
|
||||
|
||||
# 路由判断:让 LLM 决定跳转到哪个角色,或者直接回答
|
||||
# 这里我们使用一个简洁的提示词让 LLM 输出角色名称或直接回答
|
||||
system_msg = SystemMessage(content=MASTER_SYSTEM_PROMPT + "\n\n请直接输出接下来该由哪个 Agent 接手(role_name),如果直接回答,请正常输出。")
|
||||
|
||||
response = await llm.ainvoke([system_msg] + state["messages"])
|
||||
content = response.content.strip().lower()
|
||||
|
||||
if any(kw in content for kw in ["搜索", "查找", "知识", "检索"]):
|
||||
next_agent = AgentRole.LIBRARIAN
|
||||
elif any(kw in content for kw in ["计划", "安排", "拆解", "规划"]):
|
||||
next_agent = AgentRole.PLANNER
|
||||
elif any(kw in content for kw in ["执行", "做", "操作", "创建", "更新"]):
|
||||
next_agent = AgentRole.EXECUTOR
|
||||
elif any(kw in content for kw in ["分析", "报告", "统计", "总结"]):
|
||||
next_agent = AgentRole.ANALYST
|
||||
else:
|
||||
state["final_response"] = response.content
|
||||
state["should_respond"] = True
|
||||
return state
|
||||
|
||||
state["current_agent"] = next_agent
|
||||
state["current_sub_commander"] = None
|
||||
state["active_agents"] = state.get("active_agents", [AgentRole.MASTER]) + [next_agent]
|
||||
state["should_respond"] = True
|
||||
return state
|
||||
|
||||
# 简单的角色映射识别
|
||||
roles = {r.value: r for r in AgentRole}
|
||||
target_role = None
|
||||
for r_val, r_enum in roles.items():
|
||||
if r_val in content and len(content) < 50: # 如果内容很短且包含角色名,视为路由
|
||||
target_role = r_enum
|
||||
break
|
||||
|
||||
if target_role and target_role != AgentRole.MASTER:
|
||||
logger.info(f"master_routing_decided: {target_role.value}")
|
||||
return {
|
||||
"current_agent": target_role.value,
|
||||
"agent_trace": state.get("agent_trace", []) + [target_role.value],
|
||||
"messages": [AIMessage(content=f"已分发至 {target_role.value} 处理。")]
|
||||
}
|
||||
|
||||
return {"final_response": response.content, "messages": [response]}
|
||||
|
||||
|
||||
async def planner_node(state: AgentState) -> AgentState:
|
||||
"""规划Agent节点: 制定计划,拆解任务步骤"""
|
||||
user_msgs = _filter_user_messages(state["messages"])
|
||||
user_query = user_msgs[-1].content if user_msgs else ""
|
||||
return await _run_sub_commander(state, AgentRole.PLANNER, PLANNER_SYSTEM_PROMPT, user_query, use_tools=False)
|
||||
async def planner_node(state: AgentState) -> dict:
|
||||
return await call_agent_llm(state, AgentRole.SCHEDULE_PLANNER, SCHEDULE_PLANNER_SYSTEM_PROMPT)
|
||||
|
||||
async def executor_node(state: AgentState) -> dict:
|
||||
return await call_agent_llm(state, AgentRole.EXECUTOR, EXECUTOR_SYSTEM_PROMPT)
|
||||
|
||||
async def librarian_node(state: AgentState) -> dict:
|
||||
return await call_agent_llm(state, AgentRole.LIBRARIAN, LIBRARIAN_SYSTEM_PROMPT)
|
||||
|
||||
async def analyst_node(state: AgentState) -> dict:
|
||||
return await call_agent_llm(state, AgentRole.ANALYST, ANALYST_SYSTEM_PROMPT)
|
||||
|
||||
|
||||
async def executor_node(state: AgentState) -> AgentState:
|
||||
"""执行Agent节点: 调用工具执行具体任务"""
|
||||
user_msgs = _filter_user_messages(state["messages"])
|
||||
user_query = user_msgs[-1].content if user_msgs else ""
|
||||
return await _run_sub_commander(state, AgentRole.EXECUTOR, EXECUTOR_SYSTEM_PROMPT, user_query, use_tools=True)
|
||||
# ===================== 路由逻辑 =====================
|
||||
|
||||
def route_after_agent(state: AgentState) -> Literal["tools", "__end__"]:
|
||||
"""判断 Agent 执行后是该走工具节点还是结束"""
|
||||
last_message = state["messages"][-1]
|
||||
if hasattr(last_message, "tool_calls") and last_message.tool_calls:
|
||||
return "tools"
|
||||
return END
|
||||
|
||||
async def librarian_node(state: AgentState) -> AgentState:
|
||||
"""知识管理员节点: 管理知识库和知识图谱"""
|
||||
user_msgs = _filter_user_messages(state["messages"])
|
||||
user_query = user_msgs[-1].content if user_msgs else ""
|
||||
return await _run_sub_commander(state, AgentRole.LIBRARIAN, LIBRARIAN_SYSTEM_PROMPT, user_query, use_tools=True, summary_target="knowledge_context")
|
||||
|
||||
|
||||
async def analyst_node(state: AgentState) -> AgentState:
|
||||
"""分析师节点: 分析工作数据,生成报告"""
|
||||
user_msgs = _filter_user_messages(state["messages"])
|
||||
user_query = user_msgs[-1].content if user_msgs else ""
|
||||
return await _run_sub_commander(state, AgentRole.ANALYST, ANALYST_SYSTEM_PROMPT, user_query, use_tools=True, summary_target="analysis_report")
|
||||
|
||||
|
||||
def route_agent(state: AgentState) -> str:
|
||||
"""路由函数: 决定下一个节点"""
|
||||
def route_master(state: AgentState) -> str:
|
||||
"""主控路由逻辑"""
|
||||
if state.get("final_response"):
|
||||
return END
|
||||
return state.get("current_agent", AgentRole.MASTER).value
|
||||
return state.get("current_agent", END)
|
||||
|
||||
|
||||
# ===================== 构建图 =====================
|
||||
# ===================== 图构建 =====================
|
||||
|
||||
def create_agent_graph(callbacks: list | None = None):
|
||||
graph = StateGraph(AgentState)
|
||||
workflow = StateGraph(AgentState)
|
||||
|
||||
graph.add_node(AgentRole.MASTER.value, master_node)
|
||||
graph.add_node(AgentRole.PLANNER.value, planner_node)
|
||||
graph.add_node(AgentRole.EXECUTOR.value, executor_node)
|
||||
graph.add_node(AgentRole.LIBRARIAN.value, librarian_node)
|
||||
graph.add_node(AgentRole.ANALYST.value, analyst_node)
|
||||
# 添加节点
|
||||
workflow.add_node(AgentRole.MASTER.value, master_node)
|
||||
workflow.add_node(AgentRole.SCHEDULE_PLANNER.value, planner_node)
|
||||
workflow.add_node(AgentRole.EXECUTOR.value, executor_node)
|
||||
workflow.add_node(AgentRole.LIBRARIAN.value, librarian_node)
|
||||
workflow.add_node(AgentRole.ANALYST.value, analyst_node)
|
||||
workflow.add_node("tools", execute_tools_node)
|
||||
|
||||
graph.set_entry_point(AgentRole.MASTER.value)
|
||||
# 设置入口
|
||||
workflow.set_entry_point(AgentRole.MASTER.value)
|
||||
|
||||
graph.add_conditional_edges(
|
||||
# 主控分发逻辑
|
||||
workflow.add_conditional_edges(
|
||||
AgentRole.MASTER.value,
|
||||
route_agent,
|
||||
route_master,
|
||||
{
|
||||
AgentRole.PLANNER.value: AgentRole.PLANNER.value,
|
||||
AgentRole.SCHEDULE_PLANNER.value: AgentRole.SCHEDULE_PLANNER.value,
|
||||
AgentRole.EXECUTOR.value: AgentRole.EXECUTOR.value,
|
||||
AgentRole.LIBRARIAN.value: AgentRole.LIBRARIAN.value,
|
||||
AgentRole.ANALYST.value: AgentRole.ANALYST.value,
|
||||
END: END,
|
||||
END: END
|
||||
}
|
||||
)
|
||||
|
||||
for role in [AgentRole.PLANNER, AgentRole.EXECUTOR, AgentRole.LIBRARIAN, AgentRole.ANALYST]:
|
||||
graph.add_edge(role.value, END)
|
||||
# 各角色节点的 ReAct 循环
|
||||
for role in [AgentRole.SCHEDULE_PLANNER, AgentRole.EXECUTOR, AgentRole.LIBRARIAN, AgentRole.ANALYST]:
|
||||
workflow.add_conditional_edges(
|
||||
role.value,
|
||||
route_after_agent,
|
||||
{
|
||||
"tools": "tools",
|
||||
END: END
|
||||
}
|
||||
)
|
||||
|
||||
# 工具执行完后回到当前 Agent 角色继续处理
|
||||
workflow.add_conditional_edges(
|
||||
"tools",
|
||||
lambda s: s.get("current_agent", AgentRole.MASTER.value),
|
||||
{
|
||||
AgentRole.SCHEDULE_PLANNER.value: AgentRole.SCHEDULE_PLANNER.value,
|
||||
AgentRole.EXECUTOR.value: AgentRole.EXECUTOR.value,
|
||||
AgentRole.LIBRARIAN.value: AgentRole.LIBRARIAN.value,
|
||||
AgentRole.ANALYST.value: AgentRole.ANALYST.value,
|
||||
}
|
||||
)
|
||||
|
||||
return _compile_graph(graph, callbacks=callbacks)
|
||||
# 编译
|
||||
if callbacks:
|
||||
return workflow.compile(callbacks=callbacks)
|
||||
return workflow.compile()
|
||||
|
||||
|
||||
_agent_graph = None
|
||||
|
||||
|
||||
def get_agent_graph(callbacks: list | None = None):
|
||||
"""
|
||||
获取编译好的 Agent 图(单例缓存)。
|
||||
|
||||
Callbacks 在首次编译时固定注入,后续调用忽略 callbacks 参数。
|
||||
如需变更 Callbacks(如修改 LANGCHAIN_PROJECT),需重启服务。
|
||||
|
||||
Args:
|
||||
callbacks: 可选的额外 Callbacks,会与全局 LangSmith Callbacks 合并
|
||||
"""
|
||||
global _agent_graph
|
||||
if _agent_graph is None:
|
||||
from app.config_tracing import get_langsmith_callbacks
|
||||
|
||||
@@ -89,14 +89,14 @@ MASTER_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
你是总控协调者,负责理解用户意图,并将任务分发给最合适的子Agent。
|
||||
|
||||
## 你的4个子Agent:
|
||||
1. **planner (规划Agent)**: 制定计划、拆解任务、安排优先级
|
||||
1. **schedule_planner (日程规划师)**: 分析当前任务、对话历史与论坛信号,给出近期安排建议
|
||||
2. **executor (执行Agent)**: 执行具体操作、创建任务、操作数据
|
||||
3. **librarian (知识管理员)**: 搜索知识库、管理知识图谱、回答关于用户知识的问题
|
||||
4. **analyst (分析师)**: 分析数据、生成报告、统计工作进度
|
||||
|
||||
## 判断规则:
|
||||
- 用户问知识、查找资料、检索文档 -> 分发给 librarian
|
||||
- 用户要计划、安排、拆解任务 -> 分发给 planner
|
||||
- 用户要安排今天/本周重点、询问接下来该做什么 -> 分发给 schedule_planner
|
||||
- 用户要执行操作、创建/更新内容、使用工具 -> 分发给 executor
|
||||
- 用户要分析、统计、生成报告 -> 分发给 analyst
|
||||
- 用户只是闲聊、问问题、不需要具体操作 -> 直接回答
|
||||
@@ -112,18 +112,19 @@ MASTER_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
"""
|
||||
|
||||
|
||||
PLANNER_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
SCHEDULE_PLANNER_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
|
||||
你是 Jarvis 的规划Agent,负责先判断问题该由哪位规划子指挥官接手。
|
||||
你是 Jarvis 的日程规划师,负责先判断问题该由哪位日程子指挥官接手。
|
||||
|
||||
## 你的两个子指挥官:
|
||||
1. **planner_scope (目标收束官)**: 负责澄清目标、边界、约束、缺失信息
|
||||
2. **planner_steps (步骤拆解官)**: 负责把目标拆成步骤、优先级与依赖关系
|
||||
1. **schedule_analysis (日程分析员)**: 负责分析对话历史、任务看板、论坛信号,识别优先级、冲突与压力点
|
||||
2. **schedule_planning (日程编排员)**: 负责把分析结果转成今日/近期日程安排,并在用户明确要求时直接创建 reminder/task/todo/goal
|
||||
|
||||
## 你的职责:
|
||||
- 判断当前请求更适合收束目标,还是拆解步骤
|
||||
- 在必要时收束子指挥官输出,面向用户给出清晰结果
|
||||
- 保持结果可推进,不空泛
|
||||
- 判断当前请求更适合先做日程分析,还是直接给出日程编排
|
||||
- 输出先结论,再给可执行安排
|
||||
- 保持建议具体、贴近当前上下文,不给空泛效率学建议
|
||||
- 当用户明确要求“新增/提醒/创建/安排并落库”时,允许子指挥官调用 schedule 工具直接执行
|
||||
"""
|
||||
|
||||
|
||||
@@ -132,11 +133,11 @@ EXECUTOR_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
你是 Jarvis 的执行Agent,负责先判断问题该由哪位执行子指挥官接手。
|
||||
|
||||
## 你的两个子指挥官:
|
||||
1. **executor_tasks (任务执行官)**: 只处理任务类工具调用
|
||||
1. **executor_tasks (任务执行官)**: 处理任务、待办、提醒、目标等执行型写入操作
|
||||
2. **executor_forum (论坛执行官)**: 只处理论坛/指令帖相关工具调用
|
||||
|
||||
## 你的职责:
|
||||
- 识别用户要推进的是任务操作还是论坛/指令操作
|
||||
- 识别用户要推进的是任务/日程操作还是论坛/指令操作
|
||||
- 把请求交给最合适的执行子指挥官
|
||||
- 汇总执行结果并给出下一步
|
||||
"""
|
||||
@@ -172,52 +173,68 @@ ANALYST_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
"""
|
||||
|
||||
|
||||
PLANNER_SCOPE_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
SCHEDULE_ANALYSIS_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
|
||||
你是 planner 体系下的目标收束官,负责先把问题边界、目标、约束和成功标准说清楚。
|
||||
你是 schedule_planner 体系下的日程分析员,负责从对话历史、任务看板、论坛信号和当日日程数据中提取 scheduling 线索。
|
||||
|
||||
## 你的重点:
|
||||
- 收束问题定义
|
||||
- 明确目标与限制条件
|
||||
- 识别缺失信息
|
||||
- 帮用户建立可以继续规划的前提
|
||||
- 优先调用读取类工具了解当天/指定日期的任务、提醒、待办、目标
|
||||
- 识别当前最高优先级事项
|
||||
- 找出风险、冲突、依赖与可延期事项
|
||||
- 明确哪些信号来自 conversation、task board、schedule center、forum
|
||||
|
||||
## 响应要求:
|
||||
- 先给出你理解的目标
|
||||
- 再列出关键约束或缺口
|
||||
- 不要直接展开长步骤清单
|
||||
- 先给当前判断
|
||||
- 再列优先级、风险与冲突
|
||||
- 不直接展开长篇日程表
|
||||
- 只做分析,不创建任何记录
|
||||
- 如果涉及“今天/明天/后天/下周一下午”这类自然语言时间窗口,先调用 `resolve_time_expression` 把查询目标转换成明确日期
|
||||
"""
|
||||
|
||||
|
||||
PLANNER_STEPS_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
SCHEDULE_PLANNING_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
|
||||
你是 planner 体系下的步骤拆解官,负责把目标转成有顺序的执行路径。
|
||||
你是 schedule_planner 体系下的日程编排员,负责把当前重点转成近期可执行安排。
|
||||
|
||||
## 你的重点:
|
||||
- 拆解步骤
|
||||
- 标注优先级与依赖
|
||||
- 输出清晰的行动顺序
|
||||
- 先给结论
|
||||
- 再给今天/近期的时间安排建议
|
||||
- 最后给按顺序执行的 next actions
|
||||
- 当用户明确要求新增/提醒/创建/安排并真正落库时,调用 schedule 工具创建对应 reminder/task/todo/goal
|
||||
- 当用户给出“日期 + 事项/节点/交付/会议”等记录型表达时,也应视为落库意图,直接创建相应记录,不要反问
|
||||
- 解析“今天/明天/后天/本周/下周”或“3月29日”这类日期时,必须以系统提供的当前时间为准,并把工具参数转换成明确的 ISO 日期/时间字符串
|
||||
- 只要用户输入里包含自然语言时间,优先调用 `resolve_time_expression`,先拿到明确日期/时间,再调用 `create_reminder`、`create_schedule_task`、`create_goal`、`create_todo`
|
||||
|
||||
## 响应要求:
|
||||
- 用编号列表
|
||||
- 每步具体,不要空泛
|
||||
- 必要时标注先后关系
|
||||
- 用清晰列表表达
|
||||
- 建议必须具体、可执行、贴近当前工作
|
||||
- 避免空泛的自我管理建议
|
||||
- 如果只是规划,不要创建任何记录
|
||||
- 如果已创建记录,要明确说明创建了什么、时间如何解析
|
||||
"""
|
||||
|
||||
|
||||
EXECUTOR_TASKS_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
|
||||
你是 executor 体系下的任务执行官,只负责任务相关工具调用。
|
||||
你是 executor 体系下的任务执行官,负责处理任务、待办、提醒、目标等执行型工具调用。
|
||||
|
||||
## 允许使用的工具:
|
||||
- get_tasks
|
||||
- create_task
|
||||
- update_task_status
|
||||
- create_todo
|
||||
- create_schedule_task
|
||||
- create_reminder
|
||||
- create_goal
|
||||
- resolve_time_expression
|
||||
|
||||
## 要求:
|
||||
- 只处理任务类操作
|
||||
- 只处理任务/日程类操作
|
||||
- 遇到自然语言时间表达时,先调用 `resolve_time_expression`,再把解析后的明确日期/时间传给写入工具
|
||||
- 最终说明执行结果时,优先复用已经解析出的绝对时间,不要只重复“今天/明天”
|
||||
- 明确已执行动作、结果与下一步
|
||||
- 信息不足时直接指出缺口
|
||||
- 如果用户只是要分析建议,不要创建记录
|
||||
"""
|
||||
|
||||
|
||||
@@ -244,10 +261,14 @@ LIBRARIAN_RETRIEVAL_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
## 允许使用的工具:
|
||||
- search_knowledge
|
||||
- hybrid_search
|
||||
- web_search
|
||||
- get_knowledge_graph_context
|
||||
|
||||
## 要求:
|
||||
- 优先检索与综合证据
|
||||
- 私有/项目知识优先使用 `search_knowledge` 或 `hybrid_search`
|
||||
- 当用户明确要求联网、查询外部资料或查询最新信息时,使用 `web_search`
|
||||
- 回答时区分内部知识与外部网页结果
|
||||
- 证据不足时明确说明边界
|
||||
- 以回答问题为主,不主动做图谱构建
|
||||
"""
|
||||
@@ -293,9 +314,31 @@ ANALYST_INSIGHTS_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
- get_forum_posts
|
||||
- search_knowledge
|
||||
- hybrid_search
|
||||
- web_search
|
||||
|
||||
## 要求:
|
||||
- 先给结论与判断
|
||||
- 再说明依据与建议
|
||||
- 当需要外部/最新信息时,可使用 `web_search`
|
||||
- 重点输出趋势、风险、机会点
|
||||
"""
|
||||
|
||||
|
||||
JSON_ACTION_FALLBACK_PROMPT = """你当前运行在 JSON action fallback 模式。
|
||||
|
||||
你的输出必须满足以下规则:
|
||||
1. 只能输出一个 JSON 对象,不要输出 markdown、解释、前后缀文字。
|
||||
2. JSON 对象字段仅允许:
|
||||
- `mode`: `final` | `tool_call` | `clarification`
|
||||
- `tool_calls`: 数组;每项包含 `name`、`arguments`,可选 `reason`
|
||||
- `final_response`: 当无需工具时填写
|
||||
- `clarification_question`: 当信息不足时填写
|
||||
3. 如果需要调用工具,返回:
|
||||
- `{ "mode": "tool_call", "tool_calls": [...] }`
|
||||
4. 如果无需工具,直接返回:
|
||||
- `{ "mode": "final", "final_response": "..." }`
|
||||
5. 如果信息不足,不要猜测参数,返回:
|
||||
- `{ "mode": "clarification", "clarification_question": "..." }`
|
||||
6. 只能使用系统消息里明确列出的工具名。
|
||||
7. `arguments` 必须是 JSON 对象。
|
||||
"""
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import TypedDict, Annotated
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TypedDict, Annotated, Sequence
|
||||
from enum import Enum
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langgraph.graph.message import add_messages
|
||||
|
||||
|
||||
class AgentRole(str, Enum):
|
||||
MASTER = "master"
|
||||
PLANNER = "planner"
|
||||
SCHEDULE_PLANNER = "schedule_planner"
|
||||
EXECUTOR = "executor"
|
||||
LIBRARIAN = "librarian"
|
||||
ANALYST = "analyst"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentInfo:
|
||||
name: str
|
||||
role: AgentRole
|
||||
description: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCall:
|
||||
tool: str
|
||||
args: dict
|
||||
result: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationTurn:
|
||||
role: str # "user" | "assistant"
|
||||
@@ -35,60 +22,41 @@ class ConversationTurn:
|
||||
model: str | None = None
|
||||
|
||||
|
||||
def turn_to_message(turn: ConversationTurn) -> HumanMessage:
|
||||
return HumanMessage(content=turn.content)
|
||||
|
||||
|
||||
def message_to_turn(msg, agent: AgentRole | None = None) -> ConversationTurn:
|
||||
msg_type = getattr(msg, "type", None) or getattr(msg, "role", "assistant")
|
||||
return ConversationTurn(
|
||||
role="user" if msg_type in ("human", "user") else "assistant",
|
||||
content=msg.content,
|
||||
agent=agent,
|
||||
model=getattr(msg, "model", None),
|
||||
)
|
||||
|
||||
|
||||
class AgentState(TypedDict):
|
||||
messages: Annotated[list, None]
|
||||
# Core message history with add_messages reducer
|
||||
messages: Annotated[list[BaseMessage], add_messages]
|
||||
|
||||
# Session identifiers
|
||||
user_id: str
|
||||
conversation_id: str
|
||||
|
||||
# Agent routing
|
||||
current_agent: AgentRole
|
||||
active_agents: list[AgentRole]
|
||||
current_sub_commander: str | None
|
||||
active_sub_commanders: list[str]
|
||||
sub_commander_trace: list[dict]
|
||||
|
||||
# Task tracking
|
||||
# Agent routing state
|
||||
current_agent: str | None
|
||||
next_step: str | None # For explicit graph routing
|
||||
|
||||
# Traceability
|
||||
agent_trace: list[str]
|
||||
|
||||
# Task & Entity Tracking (Business Logic)
|
||||
pending_tasks: list[dict]
|
||||
completed_tasks: list[dict]
|
||||
created_entities: list[dict]
|
||||
|
||||
# Tool usage
|
||||
tool_calls: list[ToolCall]
|
||||
last_tool_result: str | None
|
||||
|
||||
# Knowledge context
|
||||
# Context summaries (for long-term or cross-agent context)
|
||||
knowledge_context: str | None
|
||||
graph_context: str | None
|
||||
|
||||
# Planning
|
||||
plan: str | None
|
||||
plan_steps: list[dict]
|
||||
|
||||
# Analysis
|
||||
schedule_context_summary: str | None
|
||||
analysis_report: str | None
|
||||
|
||||
# Output control
|
||||
final_response: str | None
|
||||
should_respond: bool
|
||||
|
||||
# Memory context (injected at start of each conversation)
|
||||
|
||||
# Memory & Environment
|
||||
memory_context: str | None
|
||||
|
||||
# User LLM config (for using user-configured models)
|
||||
current_datetime_context: str | None
|
||||
|
||||
# Configuration
|
||||
user_llm_config: dict | None
|
||||
provider_capabilities: dict | None
|
||||
|
||||
|
||||
def initial_state(user_id: str, conversation_id: str) -> AgentState:
|
||||
@@ -96,22 +64,18 @@ def initial_state(user_id: str, conversation_id: str) -> AgentState:
|
||||
messages=[],
|
||||
user_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
current_agent=AgentRole.MASTER,
|
||||
active_agents=[AgentRole.MASTER],
|
||||
current_sub_commander=None,
|
||||
active_sub_commanders=[],
|
||||
sub_commander_trace=[],
|
||||
current_agent=AgentRole.MASTER.value,
|
||||
next_step=None,
|
||||
agent_trace=[AgentRole.MASTER.value],
|
||||
pending_tasks=[],
|
||||
completed_tasks=[],
|
||||
tool_calls=[],
|
||||
last_tool_result=None,
|
||||
created_entities=[],
|
||||
knowledge_context=None,
|
||||
graph_context=None,
|
||||
plan=None,
|
||||
plan_steps=[],
|
||||
schedule_context_summary=None,
|
||||
analysis_report=None,
|
||||
final_response=None,
|
||||
should_respond=True,
|
||||
memory_context=None,
|
||||
current_datetime_context=None,
|
||||
user_llm_config=None,
|
||||
provider_capabilities=None,
|
||||
)
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
from app.agents.tools.search import (
|
||||
search_knowledge, get_knowledge_graph_context,
|
||||
build_knowledge_graph, hybrid_search,
|
||||
build_knowledge_graph, hybrid_search, web_search,
|
||||
)
|
||||
from app.agents.tools.task import get_tasks, create_task, update_task_status
|
||||
from app.agents.tools.forum import get_forum_posts, create_forum_post, scan_forum_for_instructions
|
||||
from app.agents.tools.schedule import (
|
||||
get_schedule_day,
|
||||
create_todo,
|
||||
create_schedule_task,
|
||||
create_reminder,
|
||||
create_goal,
|
||||
)
|
||||
from app.agents.tools.time_reasoning import resolve_time_expression
|
||||
|
||||
TASK_TOOLS = [
|
||||
get_tasks,
|
||||
@@ -11,6 +19,19 @@ TASK_TOOLS = [
|
||||
update_task_status,
|
||||
]
|
||||
|
||||
SCHEDULE_READ_TOOLS = [
|
||||
get_schedule_day,
|
||||
get_tasks,
|
||||
resolve_time_expression,
|
||||
]
|
||||
|
||||
SCHEDULE_WRITE_TOOLS = [
|
||||
create_todo,
|
||||
create_schedule_task,
|
||||
create_reminder,
|
||||
create_goal,
|
||||
]
|
||||
|
||||
FORUM_TOOLS = [
|
||||
get_forum_posts,
|
||||
create_forum_post,
|
||||
@@ -20,6 +41,7 @@ FORUM_TOOLS = [
|
||||
KNOWLEDGE_RETRIEVAL_TOOLS = [
|
||||
search_knowledge,
|
||||
hybrid_search,
|
||||
web_search,
|
||||
get_knowledge_graph_context,
|
||||
]
|
||||
|
||||
@@ -39,19 +61,22 @@ ANALYST_INSIGHT_TOOLS = [
|
||||
get_forum_posts,
|
||||
search_knowledge,
|
||||
hybrid_search,
|
||||
web_search,
|
||||
]
|
||||
|
||||
ALL_TOOLS = [
|
||||
*KNOWLEDGE_RETRIEVAL_TOOLS,
|
||||
build_knowledge_graph,
|
||||
*TASK_TOOLS,
|
||||
*SCHEDULE_READ_TOOLS,
|
||||
*SCHEDULE_WRITE_TOOLS,
|
||||
*FORUM_TOOLS,
|
||||
]
|
||||
|
||||
SUB_COMMANDER_TOOLSETS = {
|
||||
"planner_scope": [],
|
||||
"planner_steps": [],
|
||||
"executor_tasks": TASK_TOOLS,
|
||||
"schedule_analysis": SCHEDULE_READ_TOOLS,
|
||||
"schedule_planning": [*SCHEDULE_READ_TOOLS, *SCHEDULE_WRITE_TOOLS],
|
||||
"executor_tasks": [*TASK_TOOLS, resolve_time_expression, *SCHEDULE_WRITE_TOOLS],
|
||||
"executor_forum": FORUM_TOOLS,
|
||||
"librarian_retrieval": KNOWLEDGE_RETRIEVAL_TOOLS,
|
||||
"librarian_graph": KNOWLEDGE_GRAPH_TOOLS,
|
||||
|
||||
@@ -6,15 +6,17 @@ from app.models.forum import ForumPost, ForumReply
|
||||
from app.agents.context import get_current_user
|
||||
from sqlalchemy import select
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
_executor = ThreadPoolExecutor(max_workers=4)
|
||||
|
||||
|
||||
def _run_async(coro, timeout: int = 30):
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.run_in_executor(__import__("concurrent.futures").ThreadPoolExecutor(), lambda: asyncio.run(coro))
|
||||
return future.result(timeout=timeout)
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
return _executor.submit(asyncio.run, coro).result(timeout=timeout)
|
||||
|
||||
|
||||
@tool
|
||||
|
||||
308
backend/app/agents/tools/schedule.py
Normal file
308
backend/app/agents/tools/schedule.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""Agent 工具集 - 日程相关"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import date, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from langchain_core.tools import tool
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.agents.context import get_current_user
|
||||
from app.database import async_session
|
||||
from app.models.goal import Goal, GoalStatus
|
||||
from app.models.reminder import Reminder
|
||||
from app.models.task import Task, TaskPriority, TaskStatus
|
||||
from app.models.todo import DailyTodo, TodoSource
|
||||
|
||||
_executor = ThreadPoolExecutor(max_workers=4)
|
||||
|
||||
|
||||
def _run_async(coro, timeout: int = 30):
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
return _executor.submit(asyncio.run, coro).result(timeout=timeout)
|
||||
|
||||
|
||||
def _parse_date(value: str | None) -> date:
|
||||
if not value:
|
||||
return date.today()
|
||||
return date.fromisoformat(value)
|
||||
|
||||
|
||||
def _parse_datetime(value: str) -> datetime:
|
||||
normalized = value.strip().replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(normalized)
|
||||
|
||||
|
||||
def _parse_datetime_with_timezone(value: str, time_zone: str | None) -> datetime:
|
||||
"""Parse an ISO datetime and return a tz-naive datetime in the intended local time.
|
||||
|
||||
- If value includes an offset/Z, it will be converted to `time_zone` when provided.
|
||||
- If value is naive and `time_zone` is provided, it is interpreted in that zone.
|
||||
"""
|
||||
parsed = _parse_datetime(value)
|
||||
tz = (time_zone or "").strip()
|
||||
if parsed.tzinfo is None:
|
||||
if tz:
|
||||
parsed = parsed.replace(tzinfo=ZoneInfo(tz))
|
||||
return parsed.replace(tzinfo=None)
|
||||
|
||||
if tz:
|
||||
parsed = parsed.astimezone(ZoneInfo(tz))
|
||||
return parsed.replace(tzinfo=None)
|
||||
|
||||
|
||||
def _normalize_title(title: str | None, content: str | None) -> str:
|
||||
resolved = (title or content or "").strip()
|
||||
if not resolved:
|
||||
raise ValueError("title 不能为空")
|
||||
return resolved
|
||||
|
||||
|
||||
def _normalize_schedule_due_date(due_date: str | None, date_value: str | None) -> str | None:
|
||||
resolved = (due_date or date_value or "").strip()
|
||||
if not resolved:
|
||||
return None
|
||||
if "T" in resolved:
|
||||
return resolved
|
||||
return f"{resolved}T09:00:00"
|
||||
|
||||
|
||||
def _format_summary(target_date: date, todos: list[DailyTodo], tasks: list[Task], reminders: list[Reminder], goals: list[Goal]) -> str:
|
||||
lines = [f"日期: {target_date.isoformat()}"]
|
||||
|
||||
if todos:
|
||||
lines.append("待办:")
|
||||
lines.extend(f"- {item.title} | 完成:{'是' if item.is_completed else '否'}" for item in todos)
|
||||
else:
|
||||
lines.append("待办: 无")
|
||||
|
||||
if tasks:
|
||||
lines.append("任务:")
|
||||
lines.extend(
|
||||
f"- {item.title} | 状态:{item.status.value if hasattr(item.status, 'value') else item.status} | 优先级:{item.priority.value if hasattr(item.priority, 'value') else item.priority} | 截止:{item.due_date.isoformat() if item.due_date else '无'}"
|
||||
for item in tasks
|
||||
)
|
||||
else:
|
||||
lines.append("任务: 无")
|
||||
|
||||
if reminders:
|
||||
lines.append("提醒:")
|
||||
lines.extend(f"- {item.title} | 时间:{item.reminder_at.isoformat()}" for item in reminders)
|
||||
else:
|
||||
lines.append("提醒: 无")
|
||||
|
||||
if goals:
|
||||
lines.append("目标:")
|
||||
lines.extend(
|
||||
f"- {item.title} | 状态:{item.status.value if hasattr(item.status, 'value') else item.status}"
|
||||
for item in goals
|
||||
)
|
||||
else:
|
||||
lines.append("目标: 无")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@tool
|
||||
def get_schedule_day(target_date: str | None = None) -> str:
|
||||
"""获取指定日期的 todo/task/reminder/goal 聚合信息。target_date 格式 YYYY-MM-DD,默认今天。"""
|
||||
uid = get_current_user()
|
||||
parsed_date = _parse_date(target_date)
|
||||
date_key = parsed_date.isoformat()
|
||||
start_dt = datetime.combine(parsed_date, datetime.min.time())
|
||||
end_dt = datetime.combine(parsed_date, datetime.max.time())
|
||||
|
||||
async def _get():
|
||||
async with async_session() as db:
|
||||
todos = (
|
||||
await db.execute(
|
||||
select(DailyTodo)
|
||||
.where(DailyTodo.user_id == uid, DailyTodo.todo_date == date_key)
|
||||
.order_by(DailyTodo.created_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
tasks = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(
|
||||
Task.user_id == uid,
|
||||
Task.due_date.is_not(None),
|
||||
Task.due_date >= start_dt,
|
||||
Task.due_date <= end_dt,
|
||||
)
|
||||
.order_by(Task.created_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
reminders = (
|
||||
await db.execute(
|
||||
select(Reminder)
|
||||
.where(
|
||||
Reminder.user_id == uid,
|
||||
Reminder.reminder_at >= start_dt,
|
||||
Reminder.reminder_at <= end_dt,
|
||||
)
|
||||
.order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
goals = (
|
||||
await db.execute(
|
||||
select(Goal)
|
||||
.where(Goal.user_id == uid, Goal.goal_date == date_key)
|
||||
.order_by(Goal.created_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
return _format_summary(parsed_date, todos, tasks, reminders, goals)
|
||||
|
||||
try:
|
||||
return _run_async(_get())
|
||||
except Exception as exc:
|
||||
return f"获取日程失败: {exc}"
|
||||
|
||||
|
||||
@tool
|
||||
def create_todo(title: str, todo_date: str | None = None) -> str:
|
||||
"""创建指定日期的待办。todo_date 格式 YYYY-MM-DD,默认今天。"""
|
||||
uid = get_current_user()
|
||||
parsed_date = _parse_date(todo_date)
|
||||
|
||||
async def _create():
|
||||
async with async_session() as db:
|
||||
todo = DailyTodo(
|
||||
user_id=uid,
|
||||
title=title,
|
||||
source=TodoSource.AI_CHAT,
|
||||
todo_date=parsed_date.isoformat(),
|
||||
)
|
||||
db.add(todo)
|
||||
await db.commit()
|
||||
await db.refresh(todo)
|
||||
return f"TODO创建成功: [{todo.id[:8]}] {todo.title} @ {todo.todo_date}"
|
||||
|
||||
try:
|
||||
return _run_async(_create())
|
||||
except Exception as exc:
|
||||
return f"创建TODO失败: {exc}"
|
||||
|
||||
|
||||
@tool
|
||||
def create_schedule_task(
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
priority: str = "medium",
|
||||
due_date: str | None = None,
|
||||
content: str = "",
|
||||
date: str | None = None,
|
||||
) -> str:
|
||||
"""创建任务。priority 支持 low/medium/high/urgent;due_date 使用 ISO datetime。兼容 content/date 别名。"""
|
||||
uid = get_current_user()
|
||||
resolved_title = _normalize_title(title, content)
|
||||
resolved_due_date = _normalize_schedule_due_date(due_date, date)
|
||||
|
||||
async def _create():
|
||||
async with async_session() as db:
|
||||
task = Task(
|
||||
user_id=uid,
|
||||
title=resolved_title,
|
||||
description=description or content or None,
|
||||
priority=TaskPriority(priority),
|
||||
due_date=_parse_datetime(resolved_due_date) if resolved_due_date else None,
|
||||
status=TaskStatus.TODO,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
due_label = task.due_date.isoformat() if task.due_date else "无截止时间"
|
||||
return f"任务创建成功: [{task.id[:8]}] {task.title} | 优先级:{task.priority.value} | 截止:{due_label}"
|
||||
|
||||
try:
|
||||
return _run_async(_create())
|
||||
except Exception as exc:
|
||||
return f"创建任务失败: {exc}"
|
||||
|
||||
|
||||
@tool
|
||||
def create_reminder(
|
||||
title: str = "",
|
||||
reminder_at: str | None = None,
|
||||
note: str = "",
|
||||
description: str = "",
|
||||
datetime: str = "",
|
||||
at: str = "",
|
||||
remind_at: str = "",
|
||||
content: str = "",
|
||||
time_zone: str = "",
|
||||
timezone: str = "",
|
||||
time: str = "",
|
||||
) -> str:
|
||||
"""创建提醒。reminder_at 使用 ISO datetime。兼容 description/datetime/at/remind_at/time_zone 别名。"""
|
||||
uid = get_current_user()
|
||||
|
||||
try:
|
||||
resolved_title = (title or content or "").strip()
|
||||
if not resolved_title:
|
||||
raise ValueError("title 不能为空")
|
||||
|
||||
resolved_at = ((reminder_at or datetime or at or remind_at or time or "").strip())
|
||||
if not resolved_at:
|
||||
raise ValueError("reminder_at 不能为空")
|
||||
|
||||
resolved_note = (note or description or "").strip()
|
||||
|
||||
async def _create():
|
||||
async with async_session() as db:
|
||||
tz = (time_zone or timezone or "").strip()
|
||||
reminder = Reminder(
|
||||
user_id=uid,
|
||||
title=resolved_title,
|
||||
note=resolved_note or None,
|
||||
reminder_at=_parse_datetime_with_timezone(resolved_at, tz),
|
||||
)
|
||||
db.add(reminder)
|
||||
await db.commit()
|
||||
await db.refresh(reminder)
|
||||
return f"提醒创建成功: [{reminder.id[:8]}] {reminder.title} @ {reminder.reminder_at.isoformat()}"
|
||||
|
||||
return _run_async(_create())
|
||||
except Exception as exc:
|
||||
return f"创建提醒失败: {exc}"
|
||||
|
||||
|
||||
@tool
|
||||
def create_goal(title: str, goal_date: str | None = None, note: str = "", status: str = "active") -> str:
|
||||
"""创建指定日期目标。goal_date 格式 YYYY-MM-DD,默认今天;status 支持 active/done/archived。"""
|
||||
uid = get_current_user()
|
||||
parsed_date = _parse_date(goal_date)
|
||||
|
||||
async def _create():
|
||||
async with async_session() as db:
|
||||
goal = Goal(
|
||||
user_id=uid,
|
||||
title=title,
|
||||
note=note or None,
|
||||
goal_date=parsed_date.isoformat(),
|
||||
status=GoalStatus(status),
|
||||
)
|
||||
db.add(goal)
|
||||
await db.commit()
|
||||
await db.refresh(goal)
|
||||
return f"目标创建成功: [{goal.id[:8]}] {goal.title} @ {goal.goal_date}"
|
||||
|
||||
try:
|
||||
return _run_async(_create())
|
||||
except Exception as exc:
|
||||
return f"创建目标失败: {exc}"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_schedule_day",
|
||||
"create_todo",
|
||||
"create_schedule_task",
|
||||
"create_reminder",
|
||||
"create_goal",
|
||||
]
|
||||
@@ -5,12 +5,14 @@ Agent 工具集 - 知识库 & 图谱相关
|
||||
由于 LangChain 工具系统是同步的,内部用 run_in_executor 处理 async 逻辑。
|
||||
"""
|
||||
|
||||
from langchain_core.tools import tool
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from app.database import async_session
|
||||
from app.agents.context import get_current_user
|
||||
import asyncio
|
||||
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from app.agents.context import get_current_user
|
||||
from app.database import async_session
|
||||
|
||||
_executor = ThreadPoolExecutor(max_workers=4)
|
||||
|
||||
|
||||
@@ -151,9 +153,56 @@ def hybrid_search(query: str, top_k: int = 5) -> str:
|
||||
return f"混合搜索失败: {str(e)}"
|
||||
|
||||
|
||||
@tool
|
||||
def web_search(query: str, top_k: int = 5) -> str:
|
||||
"""
|
||||
通过 SearxNG 搜索外部网页信息,返回标题、链接和摘要。
|
||||
|
||||
Args:
|
||||
query: 搜索关键词
|
||||
top_k: 返回结果数量,默认 5 条
|
||||
|
||||
Returns:
|
||||
适合模型综合的网页结果文本
|
||||
"""
|
||||
from app.services.web_search_service import (
|
||||
WebSearchConfigurationError,
|
||||
WebSearchRequestError,
|
||||
WebSearchService,
|
||||
)
|
||||
|
||||
async def _search():
|
||||
service = WebSearchService()
|
||||
results = await service.search(query, limit=top_k)
|
||||
if not results:
|
||||
return "未找到相关网页结果。"
|
||||
|
||||
texts = []
|
||||
for index, result in enumerate(results, 1):
|
||||
source = f"\n来源: {result.source}" if result.source else ""
|
||||
published_at = f"\n时间: {result.published_at}" if result.published_at else ""
|
||||
snippet = result.snippet or "(无摘要)"
|
||||
texts.append(
|
||||
f"[{index}] {result.title}\n"
|
||||
f"链接: {result.url}{source}{published_at}\n"
|
||||
f"摘要: {snippet}"
|
||||
)
|
||||
return "\n\n---\n\n".join(texts)
|
||||
|
||||
try:
|
||||
return _run_async(_search(), timeout=30)
|
||||
except WebSearchConfigurationError as exc:
|
||||
return f"网页搜索不可用: {exc}"
|
||||
except WebSearchRequestError as exc:
|
||||
return f"网页搜索失败: {exc}"
|
||||
except Exception as exc:
|
||||
return f"网页搜索失败: {exc}"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"search_knowledge",
|
||||
"get_knowledge_graph_context",
|
||||
"build_knowledge_graph",
|
||||
"hybrid_search",
|
||||
"web_search",
|
||||
]
|
||||
|
||||
@@ -1,22 +1,85 @@
|
||||
"""Agent 工具集 - 任务相关"""
|
||||
|
||||
from langchain_core.tools import tool
|
||||
from app.database import async_session
|
||||
from app.models.task import Task
|
||||
from app.agents.context import get_current_user
|
||||
from sqlalchemy import select
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
|
||||
_executor = None
|
||||
from app.models.base import utc_now
|
||||
|
||||
from langchain_core.tools import tool
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.agents.context import get_current_user
|
||||
from app.database import async_session
|
||||
from app.models.task import Task, TaskPriority, TaskStatus
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
_executor = ThreadPoolExecutor(max_workers=4)
|
||||
|
||||
|
||||
def _run_async(coro, timeout: int = 30):
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.run_in_executor(_executor or __import__("concurrent.futures").ThreadPoolExecutor(), lambda: asyncio.run(coro))
|
||||
return future.result(timeout=timeout)
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
return _executor.submit(asyncio.run, coro).result(timeout=timeout)
|
||||
|
||||
|
||||
def _normalize_title(title: str | None, content: str | None) -> str:
|
||||
resolved = (title or content or "").strip()
|
||||
if not resolved:
|
||||
raise ValueError("title 不能为空")
|
||||
return resolved
|
||||
|
||||
|
||||
def _normalize_due_date(due_date: str | None, date_value: str | None) -> str | None:
|
||||
resolved = (due_date or date_value or "").strip()
|
||||
return resolved or None
|
||||
|
||||
|
||||
def _parse_due_date(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return None
|
||||
if "T" not in normalized:
|
||||
normalized = f"{normalized}T00:00:00"
|
||||
parsed = datetime.fromisoformat(normalized.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is not None:
|
||||
return parsed.astimezone(UTC).replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
|
||||
def _normalize_priority(priority: int | str | None) -> TaskPriority:
|
||||
if priority is None or priority == "":
|
||||
return TaskPriority.MEDIUM
|
||||
if isinstance(priority, TaskPriority):
|
||||
return priority
|
||||
if isinstance(priority, int):
|
||||
return {
|
||||
1: TaskPriority.LOW,
|
||||
2: TaskPriority.MEDIUM,
|
||||
3: TaskPriority.HIGH,
|
||||
4: TaskPriority.URGENT,
|
||||
}.get(priority, TaskPriority.MEDIUM)
|
||||
normalized = str(priority).strip().lower()
|
||||
if not normalized:
|
||||
return TaskPriority.MEDIUM
|
||||
return TaskPriority(normalized)
|
||||
|
||||
|
||||
def _normalize_status(status: str) -> TaskStatus:
|
||||
normalized = status.strip().lower()
|
||||
return TaskStatus(normalized)
|
||||
|
||||
|
||||
def _format_status(value: TaskStatus | str) -> str:
|
||||
return value.value if hasattr(value, "value") else str(value)
|
||||
|
||||
|
||||
def _format_priority(value: TaskPriority | str) -> str:
|
||||
return value.value if hasattr(value, "value") else str(value)
|
||||
|
||||
|
||||
@tool
|
||||
@@ -25,7 +88,7 @@ def get_tasks(status: str | None = None, limit: int = 20) -> str:
|
||||
获取用户当前的任务列表。
|
||||
|
||||
Args:
|
||||
status: 可选,筛选任务状态 (todo/in_progress/done/blocked)
|
||||
status: 可选,筛选任务状态 (todo/in_progress/done/cancelled)
|
||||
limit: 返回数量,默认20
|
||||
|
||||
Returns:
|
||||
@@ -33,67 +96,82 @@ def get_tasks(status: str | None = None, limit: int = 20) -> str:
|
||||
"""
|
||||
uid = get_current_user()
|
||||
|
||||
async def _get():
|
||||
async with async_session() as db:
|
||||
from app.models.user import User
|
||||
query = (
|
||||
select(Task)
|
||||
.join(User, User.id == Task.user_id)
|
||||
.where(User.id == uid)
|
||||
)
|
||||
if status:
|
||||
query = query.where(Task.status == status)
|
||||
query = query.order_by(Task.priority.desc(), Task.updated_at.desc()).limit(limit)
|
||||
result = await db.execute(query)
|
||||
tasks = result.scalars().all()
|
||||
if not tasks:
|
||||
return "暂无任务"
|
||||
lines = []
|
||||
for t in tasks:
|
||||
lines.append(
|
||||
f"- [{t.id[:8]}] {t.title} | "
|
||||
f"状态:{t.status} | 优先级:{t.priority} | 截止:{t.due_date or '无'}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
try:
|
||||
resolved_status = _normalize_status(status) if status else None
|
||||
|
||||
async def _get():
|
||||
async with async_session() as db:
|
||||
from app.models.user import User
|
||||
query = (
|
||||
select(Task)
|
||||
.join(User, User.id == Task.user_id)
|
||||
.where(User.id == uid)
|
||||
)
|
||||
if resolved_status:
|
||||
query = query.where(Task.status == resolved_status)
|
||||
query = query.order_by(Task.priority.desc(), Task.updated_at.desc()).limit(limit)
|
||||
result = await db.execute(query)
|
||||
tasks = result.scalars().all()
|
||||
if not tasks:
|
||||
return "暂无任务"
|
||||
lines = []
|
||||
for t in tasks:
|
||||
lines.append(
|
||||
f"- [{t.id[:8]}] {t.title} | "
|
||||
f"状态:{_format_status(t.status)} | 优先级:{_format_priority(t.priority)} | 截止:{t.due_date or '无'}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
return _run_async(_get())
|
||||
except Exception as e:
|
||||
return f"获取任务失败: {str(e)}"
|
||||
|
||||
|
||||
@tool
|
||||
def create_task(title: str, description: str = "", priority: int = 2, due_date: str | None = None) -> str:
|
||||
def create_task(
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
priority: int | str = 2,
|
||||
due_date: str | None = None,
|
||||
content: str = "",
|
||||
date: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
创建新任务。
|
||||
|
||||
Args:
|
||||
title: 任务标题(必填)
|
||||
title: 任务标题(必填,兼容 content 作为别名)
|
||||
description: 任务描述
|
||||
priority: 优先级 1-4,数字越大优先级越高,默认2
|
||||
due_date: 截止日期,格式 YYYY-MM-DD
|
||||
priority: 优先级,支持 1-4 或 low/medium/high/urgent,默认2
|
||||
due_date: 截止日期,格式 YYYY-MM-DD 或 ISO datetime
|
||||
content: title 的兼容别名
|
||||
date: due_date 的兼容别名
|
||||
|
||||
Returns:
|
||||
创建结果
|
||||
"""
|
||||
uid = get_current_user()
|
||||
|
||||
async def _create():
|
||||
async with async_session() as db:
|
||||
task = Task(
|
||||
user_id=uid,
|
||||
title=title,
|
||||
description=description,
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
status="todo",
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return f"任务创建成功: [{task.id[:8]}] {title}"
|
||||
|
||||
try:
|
||||
resolved_title = _normalize_title(title, content)
|
||||
resolved_due_date = _normalize_due_date(due_date, date)
|
||||
resolved_priority = _normalize_priority(priority)
|
||||
|
||||
async def _create():
|
||||
async with async_session() as db:
|
||||
task = Task(
|
||||
user_id=uid,
|
||||
title=resolved_title,
|
||||
description=description or content or None,
|
||||
priority=resolved_priority,
|
||||
due_date=_parse_due_date(resolved_due_date),
|
||||
status=TaskStatus.TODO,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return f"任务创建成功: [{task.id[:8]}] {resolved_title}"
|
||||
|
||||
return _run_async(_create())
|
||||
except Exception as e:
|
||||
return f"创建任务失败: {str(e)}"
|
||||
@@ -106,34 +184,37 @@ def update_task_status(task_id: str, status: str) -> str:
|
||||
|
||||
Args:
|
||||
task_id: 任务ID(完整ID或前8位)
|
||||
status: 新状态 (todo/in_progress/done/blocked)
|
||||
status: 新状态 (todo/in_progress/done/cancelled)
|
||||
|
||||
Returns:
|
||||
更新结果
|
||||
"""
|
||||
uid = get_current_user()
|
||||
|
||||
async def _update():
|
||||
async with async_session() as db:
|
||||
from app.models.user import User
|
||||
query = (
|
||||
select(Task)
|
||||
.join(User, User.id == Task.user_id)
|
||||
.where(User.id == uid)
|
||||
)
|
||||
if len(task_id) == 8:
|
||||
query = query.where(Task.id.like(f"{task_id}%"))
|
||||
else:
|
||||
query = query.where(Task.id == task_id)
|
||||
result = await db.execute(query)
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
return f"任务不存在: {task_id}"
|
||||
task.status = status
|
||||
await db.commit()
|
||||
return f"任务状态已更新: {task.title} -> {status}"
|
||||
|
||||
try:
|
||||
resolved_status = _normalize_status(status)
|
||||
|
||||
async def _update():
|
||||
async with async_session() as db:
|
||||
from app.models.user import User
|
||||
query = (
|
||||
select(Task)
|
||||
.join(User, User.id == Task.user_id)
|
||||
.where(User.id == uid)
|
||||
)
|
||||
if len(task_id) == 8:
|
||||
query = query.where(Task.id.like(f"{task_id}%"))
|
||||
else:
|
||||
query = query.where(Task.id == task_id)
|
||||
result = await db.execute(query)
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
return f"任务不存在: {task_id}"
|
||||
task.status = resolved_status
|
||||
task.completed_at = utc_now() if resolved_status == TaskStatus.DONE else None
|
||||
await db.commit()
|
||||
return f"任务状态已更新: {task.title} -> {resolved_status.value}"
|
||||
|
||||
return _run_async(_update())
|
||||
except Exception as e:
|
||||
return f"更新任务失败: {str(e)}"
|
||||
|
||||
269
backend/app/agents/tools/time_reasoning.py
Normal file
269
backend/app/agents/tools/time_reasoning.py
Normal file
@@ -0,0 +1,269 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
|
||||
from langchain_core.tools import tool
|
||||
|
||||
_WEEKDAY_MAP = {"一": 0, "二": 1, "三": 2, "四": 3, "五": 4, "六": 5, "日": 6, "天": 6}
|
||||
_DEFAULT_HOUR_BY_PERIOD = {
|
||||
"morning": 9,
|
||||
"noon": 12,
|
||||
"afternoon": 15,
|
||||
"evening": 20,
|
||||
}
|
||||
_TIME_KEYWORDS = ("今天", "明天", "后天", "本周", "这周", "下周", "周", "星期", "月", "日", "早上", "上午", "中午", "下午", "晚上", "今晚", "点", ":", ":")
|
||||
|
||||
|
||||
def _parse_datetime(value: str) -> datetime:
|
||||
normalized = value.strip().replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(normalized)
|
||||
|
||||
|
||||
def extract_reference_datetime(current_datetime_context: str | None) -> datetime:
|
||||
context = (current_datetime_context or "").strip()
|
||||
if context:
|
||||
for pattern in (r"current_time_utc:\s*(\S+)", r"CURRENT_TIME:\s*(\S+)", r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2}))"):
|
||||
match = re.search(pattern, context)
|
||||
if match:
|
||||
return _parse_datetime(match.group(1))
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _normalize_local_iso(value: datetime) -> str:
|
||||
return value.replace(tzinfo=None).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _normalize_datetime_iso(value: datetime) -> str:
|
||||
if value.tzinfo is not None:
|
||||
return value.isoformat(timespec="seconds")
|
||||
return _normalize_local_iso(value)
|
||||
|
||||
|
||||
def _normalize_date_iso(value: date) -> str:
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def _is_iso_datetime(value: str) -> bool:
|
||||
try:
|
||||
parsed = _parse_datetime(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return isinstance(parsed, datetime)
|
||||
|
||||
|
||||
def _is_iso_date(value: str) -> bool:
|
||||
try:
|
||||
date.fromisoformat(value.strip())
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _has_explicit_time(text: str) -> bool:
|
||||
return bool(
|
||||
re.search(r"\d{1,2}[::]\d{2}", text)
|
||||
or re.search(r"\d{1,2}点(?:半|(?:\d{1,2})分?)?", text)
|
||||
or any(keyword in text for keyword in ("早上", "上午", "中午", "下午", "晚上", "今晚"))
|
||||
)
|
||||
|
||||
|
||||
def _detect_period(text: str) -> str | None:
|
||||
if any(keyword in text for keyword in ("晚上", "今晚")):
|
||||
return "evening"
|
||||
if "下午" in text:
|
||||
return "afternoon"
|
||||
if "中午" in text:
|
||||
return "noon"
|
||||
if any(keyword in text for keyword in ("早上", "上午", "早晨", "清晨")):
|
||||
return "morning"
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_time(text: str) -> tuple[time, bool, str | None]:
|
||||
period = _detect_period(text)
|
||||
colon_match = re.search(r"(\d{1,2})[::](\d{2})", text)
|
||||
if colon_match:
|
||||
hour = int(colon_match.group(1))
|
||||
minute = int(colon_match.group(2))
|
||||
if period in {"afternoon", "evening"} and hour < 12:
|
||||
hour += 12
|
||||
return time(hour=hour, minute=minute), False, period
|
||||
|
||||
half_match = re.search(r"(\d{1,2})点半", text)
|
||||
if half_match:
|
||||
hour = int(half_match.group(1))
|
||||
if period in {"afternoon", "evening"} and hour < 12:
|
||||
hour += 12
|
||||
return time(hour=hour, minute=30), False, period
|
||||
|
||||
dot_match = re.search(r"(\d{1,2})点(?:(\d{1,2})分?)?", text)
|
||||
if dot_match:
|
||||
hour = int(dot_match.group(1))
|
||||
minute = int(dot_match.group(2) or 0)
|
||||
if period in {"afternoon", "evening"} and hour < 12:
|
||||
hour += 12
|
||||
if period == "noon" and hour < 11:
|
||||
hour += 12
|
||||
return time(hour=hour, minute=minute), False, period
|
||||
|
||||
if period:
|
||||
return time(hour=_DEFAULT_HOUR_BY_PERIOD[period], minute=0), True, period
|
||||
return time(hour=9, minute=0), True, None
|
||||
|
||||
|
||||
def _resolve_date(text: str, reference: datetime) -> tuple[date, str]:
|
||||
stripped = text.strip()
|
||||
if _is_iso_date(stripped):
|
||||
return date.fromisoformat(stripped), "explicit_date"
|
||||
|
||||
month_day_match = re.search(r"(\d{1,2})月(\d{1,2})日", stripped)
|
||||
if month_day_match:
|
||||
month = int(month_day_match.group(1))
|
||||
day = int(month_day_match.group(2))
|
||||
candidate = date(reference.year, month, day)
|
||||
if candidate < reference.date() - timedelta(days=1):
|
||||
candidate = date(reference.year + 1, month, day)
|
||||
return candidate, "explicit_month_day"
|
||||
|
||||
if "后天" in stripped:
|
||||
return reference.date() + timedelta(days=2), "relative_day"
|
||||
if "明天" in stripped:
|
||||
return reference.date() + timedelta(days=1), "relative_day"
|
||||
if "今天" in stripped:
|
||||
return reference.date(), "relative_day"
|
||||
|
||||
weekday_match = re.search(r"((?:本周|这周|下周)?)(?:周|星期)([一二三四五六日天])", stripped)
|
||||
if weekday_match:
|
||||
prefix = weekday_match.group(1)
|
||||
weekday = _WEEKDAY_MAP[weekday_match.group(2)]
|
||||
current_weekday = reference.date().weekday()
|
||||
delta = weekday - current_weekday
|
||||
if prefix == "下周":
|
||||
delta += 7 if delta <= 0 else 7
|
||||
elif prefix in {"本周", "这周"}:
|
||||
if delta < 0:
|
||||
delta += 7
|
||||
elif delta < 0:
|
||||
delta += 7
|
||||
return reference.date() + timedelta(days=delta), "relative_weekday"
|
||||
|
||||
return reference.date(), "reference_day"
|
||||
|
||||
|
||||
def resolve_time_expression_data(
|
||||
expression: str,
|
||||
*,
|
||||
current_datetime_context: str | None = None,
|
||||
prefer: str = "datetime",
|
||||
) -> dict:
|
||||
text = (expression or "").strip()
|
||||
if not text:
|
||||
raise ValueError("expression 不能为空")
|
||||
|
||||
reference = extract_reference_datetime(current_datetime_context)
|
||||
|
||||
if _is_iso_datetime(text):
|
||||
parsed = _parse_datetime(text)
|
||||
return {
|
||||
"expression": text,
|
||||
"reference_time": reference.isoformat(),
|
||||
"grain": "datetime",
|
||||
"resolved_date": _normalize_date_iso(parsed.date()),
|
||||
"resolved_datetime": _normalize_datetime_iso(parsed),
|
||||
"assumed_time": False,
|
||||
"reason": "explicit_datetime",
|
||||
}
|
||||
|
||||
if _is_iso_date(text):
|
||||
parsed_date = date.fromisoformat(text)
|
||||
return {
|
||||
"expression": text,
|
||||
"reference_time": reference.isoformat(),
|
||||
"grain": "date",
|
||||
"resolved_date": _normalize_date_iso(parsed_date),
|
||||
"resolved_datetime": None,
|
||||
"assumed_time": False,
|
||||
"reason": "explicit_date",
|
||||
}
|
||||
|
||||
resolved_date, date_reason = _resolve_date(text, reference)
|
||||
resolved_time, assumed_time, period = _resolve_time(text)
|
||||
has_explicit_time = _has_explicit_time(text)
|
||||
grain = "date" if prefer == "date" and not has_explicit_time else "datetime"
|
||||
resolved_dt = datetime.combine(resolved_date, resolved_time)
|
||||
note = date_reason
|
||||
if period:
|
||||
note = f"{note}:{period}"
|
||||
if assumed_time:
|
||||
note = f"{note}:assumed_time"
|
||||
return {
|
||||
"expression": text,
|
||||
"reference_time": reference.isoformat(),
|
||||
"grain": grain,
|
||||
"resolved_date": _normalize_date_iso(resolved_date),
|
||||
"resolved_datetime": None if grain == "date" else _normalize_local_iso(resolved_dt),
|
||||
"assumed_time": assumed_time,
|
||||
"reason": note,
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def resolve_time_expression(
|
||||
expression: str,
|
||||
current_datetime_context: str = "",
|
||||
prefer: str = "datetime",
|
||||
) -> str:
|
||||
"""解析中文自然语言时间表达,基于当前参考时间返回明确的日期或 datetime。prefer 支持 datetime/date。"""
|
||||
try:
|
||||
payload = resolve_time_expression_data(
|
||||
expression,
|
||||
current_datetime_context=current_datetime_context or None,
|
||||
prefer=prefer,
|
||||
)
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
except Exception as exc:
|
||||
return json.dumps(
|
||||
{
|
||||
"expression": expression,
|
||||
"error": str(exc),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def normalize_tool_time_arguments(tool_name: str, args: dict, current_datetime_context: str | None) -> dict:
|
||||
normalized = dict(args)
|
||||
|
||||
if tool_name == "create_reminder":
|
||||
raw_value = next((normalized.get(key) for key in ("reminder_at", "datetime", "at", "remind_at", "time") if isinstance(normalized.get(key), str) and normalized.get(key).strip()), None)
|
||||
if raw_value and not _is_iso_datetime(raw_value):
|
||||
payload = resolve_time_expression_data(raw_value, current_datetime_context=current_datetime_context, prefer="datetime")
|
||||
normalized["reminder_at"] = payload["resolved_datetime"]
|
||||
return normalized
|
||||
|
||||
if tool_name in {"create_schedule_task", "create_task"}:
|
||||
raw_value = next((normalized.get(key) for key in ("due_date", "date") if isinstance(normalized.get(key), str) and normalized.get(key).strip()), None)
|
||||
if raw_value and not _is_iso_datetime(raw_value) and not _is_iso_date(raw_value):
|
||||
prefer = "datetime" if tool_name == "create_schedule_task" or _has_explicit_time(raw_value) else "date"
|
||||
payload = resolve_time_expression_data(raw_value, current_datetime_context=current_datetime_context, prefer=prefer)
|
||||
normalized["due_date"] = payload["resolved_datetime"] or payload["resolved_date"]
|
||||
return normalized
|
||||
|
||||
if tool_name in {"create_todo", "create_goal", "get_schedule_day"}:
|
||||
field_name = {
|
||||
"create_todo": "todo_date",
|
||||
"create_goal": "goal_date",
|
||||
"get_schedule_day": "target_date",
|
||||
}[tool_name]
|
||||
raw_value = normalized.get(field_name)
|
||||
if isinstance(raw_value, str) and raw_value.strip() and not _is_iso_date(raw_value):
|
||||
payload = resolve_time_expression_data(raw_value, current_datetime_context=current_datetime_context, prefer="date")
|
||||
normalized[field_name] = payload["resolved_date"]
|
||||
return normalized
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
__all__ = ["resolve_time_expression", "resolve_time_expression_data", "normalize_tool_time_arguments", "extract_reference_datetime"]
|
||||
Reference in New Issue
Block a user