Files
yu-zhi-ran/platform/backend/app/api/assistant.py
T
Yuzhiran Dev 9b9ee4964b feat(assistant): 可执行操作改造,支持实时查询与任务触发
- 新增 assistant_actions.py 动作注册表(list_topics/get_system_stats/get_task_status/trigger_task/search_web 等)
- assistant.py 增加规则意图识别优先执行,失败则走 LLM ACTION 格式
- 格式化返回结果,前端无感知
- 支持查看系统统计、选题、任务日志、手动触发定时任务
2026-05-31 13:25:07 +08:00

313 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import re
import json
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
from ..core.nvidia_client import call_llm
from .auth import get_current_user
from .assistant_actions import ACTION_REGISTRY
router = APIRouter(prefix="/api/assistant", tags=["assistant"])
PAGE_CONTEXTS = {
"dashboard": "仪表盘页面:显示系统概览统计(选题总数、待处理、待审查、待发布、已发布、今日新增)、模块状态卡片(6个定时任务的运行状态)、近期计划列表",
"topics": "选题管理页面:创建和管理内容选题,支持筛选、排序、评分、编辑、预览等功能",
"metrics": "数据分析页面:展示各平台内容表现数据,包括浏览量、点赞、收藏、评论等指标的趋势图表",
"calendar": "内容日历页面:以日历形式展示内容发布计划,支持创建、编辑、拖拽调整日期",
"assets": "素材管理页面:管理图片、文件等媒体素材,支持上传、标签分类、关联选题",
"tasks": "任务管理页面:查看内容创作任务的执行状态,包括自动采集、同步、生成、优化等任务",
"platforms": "平台配置页面:管理各发布平台的配置,包括平台名称、API端点、发布格式等",
"admin": "系统管理页面:管理用户、LLM配置、系统配置、采集类别、信息源、组织、查看运行日志等",
}
# 可用操作清单(用于系统提示词)
from .assistant_actions import ACTION_REGISTRY
ACTION_DESCRIPTIONS = "\n".join([f"- {name}: {info['description']}" for name, info in ACTION_REGISTRY.items()])
class ChatRequest(BaseModel):
message: str
page: str = "dashboard"
history: List[dict] = []
def _execute_action(action_name: str, params: Dict[str, Any], current_user) -> Dict[str, Any]:
"""执行一个具体的操作"""
if action_name not in ACTION_REGISTRY:
return {"error": f"未知操作: {action_name}"}
func = ACTION_REGISTRY[action_name]["func"]
try:
result = func(**params)
return {"success": True, "result": result}
except Exception as e:
import traceback
return {"error": str(e), "traceback": traceback.format_exc()}
@router.post("/chat")
def chat(request: ChatRequest, current_user=Depends(get_current_user)):
user_message = request.message.strip()
# 1. 意图识别(规则优先):用户问题涉及系统数据,直接调用工具,避免 LLM 漏掉 ACTION
intent, params = _detect_intent(user_message)
if intent:
result = _execute_action(intent, params, current_user)
reply = _format_action_result(intent, params, result)
return {"reply": reply, "actions": [{"action": intent, "params": params, "result": result}]}
# 2. 走 LLM 对话(让 LLM 生成 ACTION
try:
from ..database import SessionLocal
from ..models import SystemConfig, User
db = SessionLocal()
sc = db.query(SystemConfig).filter(SystemConfig.key == "assistant_system_prompt").first()
base_prompt = sc.value if sc else None
user_rec = db.query(User).filter(User.id == getattr(current_user, 'id', None)).first()
username = user_rec.username if user_rec else (getattr(current_user, 'username', '用户'))
db.close()
except Exception:
base_prompt = None
username = getattr(current_user, 'username', '用户')
page_name = request.page
page_guide = PAGE_CONTEXTS.get(page_name, "未知页面")
system_prompt = base_prompt or f"""你是一个智能内容创作平台助手,能够通过调用工具与系统实时交互,为用户提供准确的数据和操作。
当前用户 {username} 正在查看:{page_guide}
**重要规则**:对于任何涉及系统数据(选题、任务、统计、配置等)的问题,你必须使用提供的工具查询最新数据,不要依赖你的训练数据或猜测。
可用工具:
{ACTION_DESCRIPTIONS}
**调用格式(必须严格遵守)**
当需要执行工具时,在你的回复中包含单独一行(不要在行内有其他文字):
**ACTION: action_name** {{"param1": "value1", ...}}
示例对话:
用户:系统现在有多少选题?
助手:稍等,我为你查询一下。
**ACTION: get_system_stats** {{}}
用户:我想看最近5条任务日志
助手:好的。
**ACTION: get_recent_task_logs** {{"limit":5}}
工具执行后,系统会返回结果,你再根据结果生成最终回复(如「当前选题总数是42个」)。
注意:
- 必须严格使用上述格式,工具名和参数必须匹配
- 参数必须是合法JSON,缺失必要参数时应询问用户
- 如果用户问题模糊,先澄清再调用工具
- 回复内容简洁友好,不超过200字
"""
messages = request.history[-20:]
conversation = "\n".join([f"{'用户' if m.get('role') == 'user' else '助手'}: {m.get('content', '')}" for m in messages])
prompt_text = f"{conversation}\n用户: {request.message}\n助手:"
try:
reply = call_llm(prompt_text, system_prompt=system_prompt, temperature=0.5, max_tokens=1500)
# 检测是否有操作指令(支持多行)
action_pattern = re.compile(r'\*\*ACTION:\s*(\w+)\*\*\s*(\{.*\})', re.DOTALL)
action_matches = list(action_pattern.finditer(reply))
action_results = []
processed_reply = reply
for match in action_matches:
action_name = match.group(1).strip()
args_str = match.group(2).strip()
try:
args = json.loads(args_str)
# 执行操作
exec_result = _execute_action(action_name, args, current_user)
action_results.append({
"action": action_name,
"params": args,
"result": exec_result
})
# 从回复中移除 ACTION 标记行
processed_reply = processed_reply.replace(match.group(0), '').strip()
except json.JSONDecodeError:
action_results.append({"action": action_name, "error": "参数JSON格式错误"})
# 如果有执行结果,追加简要说明到回复中
if action_results:
summary_lines = []
for ar in action_results:
if "error" in ar["result"]:
summary_lines.append(f"❌ 操作 {ar['action']} 失败: {ar['result']['error']}")
else:
summary_lines.append(f"✅ 操作 {ar['action']} 已完成。")
processed_reply = processed_reply + "\n\n" + "\n".join(summary_lines)
return {"reply": processed_reply.strip(), "actions": action_results}
except Exception as e:
return {"reply": f"抱歉,AI助手暂时无法响应,请稍后重试。"}
def _detect_intent(message: str) -> (str, Dict[str, Any]):
"""基于关键词的意图识别,返回 (action_name, params) 或 (None, {{}})"""
msg = message.lower()
# 查询系统统计(选题总数、各状态数量、案例数)
if any(k in msg for k in ['统计', '总数', '多少选题', '选题数量', '系统数据', '数据总览']):
return "get_system_stats", {}
# 查看选题列表
if any(k in msg for k in ['选题列表', '查看选题', '选题有哪些', '列出选题', '选题']):
# 默认返回第一页20条,可后续细化
page = 1
page_size = 10
# 检测是否指定状态
status = None
if any(k in msg for k in ['待处理', '待审核', '待审查', '待发布', '已发布']):
status_map = {'待处理': 'pending', '待审查': 'review', '待发布': 'ready', '已发布': 'published'}
for k, v in status_map.items():
if k in msg:
status = v
break
page_size = 20 if '全部' in msg else page_size
return "list_topics", {"page": page, "page_size": page_size, "status": status, "field": None}
# 查询特定选题
if any(k in msg for k in ['查看选题', '选题详情', '选题详情', '获取选题', '选题id']):
# 尝试提取ID(大写字母+数字 或 LIVING/TECH/M0604 等格式)
import re
match = re.search(r'[A-Z0-9\-]{4,}', msg)
if match:
topic_id = match.group(0)
return "get_topic", {"topic_id": topic_id}
return None, {}
# 最近任务日志
if any(k in msg for k in ['任务日志', '执行记录', '任务记录', '最近任务']):
limit = 5 if '最近' in msg or '查看' in msg else 10
# 检查是否指定模块
module_id = None
for mod in ['collect', 'generate', 'optimize', 'metrics', 'trends']:
if mod in msg:
module_id = f"scheduled_{mod}"
break
return "get_task_status", {"module_id": module_id} if module_id else {"module_id": None}
# 手动触发任务
if any(k in msg for k in ['触发', '运行', '执行', '采集', '生成', '优化', '指标']):
module_map = {
'collect': 'scheduled_collect',
'采集': 'scheduled_collect',
'generate': 'scheduled_generate',
'生成': 'scheduled_generate',
'optimize': 'scheduled_optimize',
'优化': 'scheduled_optimize',
'optimize_sources': 'scheduled_optimize_sources',
'source': 'scheduled_optimize_sources',
'metrics': 'scheduled_metrics_sync',
'指标': 'scheduled_metrics_sync',
'trend': 'scheduled_trends',
'趋势': 'scheduled_trends'
}
for key, val in module_map.items():
if key in msg:
return "trigger_task", {"module_id": val}
# 如果无法识别具体模块,不触发
return None, {}
# 搜索
if any(k in msg for k in ['搜索', '查询', '查找']):
# 提取搜索词(简单:把问题其余部分作为查询)
# 这里较难简单提取,暂不自动触发,留给 LLM
return None, {}
# 默认不触发
return None, {}
def _format_action_result(action_name: str, params: Dict[str, Any], result: Dict[str, Any]) -> str:
"""将工具执行结果格式化为自然语言回复"""
if "error" in result:
return f"⚠️ 执行 {action_name} 失败:{result['error']}"
res = result.get("result", result)
if action_name == "get_system_stats":
t = res.get("topics", {})
return (f"📊 系统统计:\n"
f"• 选题总数:{t.get('total', 0)}\n"
f"• 待处理:{t.get('pending', 0)}\n"
f"• 待审查:{t.get('review', 0)}\n"
f"• 草稿:{t.get('draft', 0)}\n"
f"• 待发布:{t.get('ready', 0)}\n"
f"• 已发布:{t.get('published', 0)}\n"
f"• 本周新增:{t.get('new_this_week', 0)}\n"
f"• 案例总数:{res.get('cases', 0)}")
if action_name == "list_topics":
items = res.get("items", [])
total = res.get("total", 0)
page = res.get("page", 1)
if not items:
return f"当前没有符合条件的选题(总计 {total} 条)。"
lines = [f"📋 选题列表(第 {page} 页,显示 {len(items)} 条,共 {total} 条):"]
for it in items:
lines.append(f"{it['id']}{it['title'][:30]}... [{it['status']}]")
return "\n".join(lines)
if action_name == "get_topic":
if "error" in res:
return f"{res['error']}"
t = res
return (f"📄 选题详情 [{t['id']}]\n"
f"标题:{t['title']}\n"
f"领域:{t['field']}\n"
f"状态:{t['status']}\n"
f"优先级:{t['priority']} (分:{t['priority_score']})")
if action_name == "get_recent_task_logs":
logs = res[:5] if isinstance(res, list) else []
if not logs:
return "暂无任务日志。"
lines = ["📝 最近任务记录:"]
for l in logs:
status_icon = {"success":"","failed":"","running":"🔄"}.get(l['status'],"")
time_str = l['started_at'][:10] if l.get('started_at') else "?"
lines.append(f" {status_icon} {l['module_id']} - {l['status']} ({time_str})")
return "\n".join(lines)
if action_name == "trigger_task":
if "error" in res:
return f"❌ 触发失败:{res['error']}"
return f"⏳ 已触发任务:{params.get('module_id')},正在后台执行。"
if action_name == "search_web":
if "error" in res:
return f"❌ 搜索失败:{res['error']}"
results = res.get("results", [])
count = res.get("count", len(results))
if not results:
return "未找到相关搜索结果。"
lines = [f"🔍 搜索到 {count} 条结果:"]
for i, r in enumerate(results[:3], 1):
lines.append(f" {i}. {r.get('title','无标题')}\n {r.get('url','')}")
if count > 3:
lines.append(f" ... 还有 {count-3}")
return "\n".join(lines)
if action_name == "get_task_status":
logs = res[:5] if isinstance(res, list) else []
if not logs:
return "未找到任务日志。"
lines = ["📋 任务状态:"]
for l in logs:
status_icon = {"success":"","failed":"","running":"🔄"}.get(l['status'],"")
time_str = l['started_at'][:10] if l.get('started_at') else "?"
msg = (l.get('message') or '')
msg_snippet = (msg[:30] + '...') if len(msg) > 30 else msg[:30]
lines.append(f" {status_icon} {l['module_id']} - {l['status']} ({time_str})")
if msg_snippet:
lines.append(f" {msg_snippet}")
return "\n".join(lines)
# 默认返回原始结果
return f"✅ 操作 {action_name} 完成。"