325 lines
15 KiB
Python
325 lines
15 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
对话管线(RAG 混合检索 + 函数调用):
|
||
1. 系统提示词 + 用户问题 → DeepSeek(带 8 个工具)
|
||
2. 模型选择工具 → 执行(SQLite 结构化查询 + Chroma 向量检索)
|
||
3. 工具结果回填 → DeepSeek 生成最终答案(数据准确,标注来源)
|
||
4. 兜底:LLM 或工具异常时,用关键词预检索注入上下文后直接回答
|
||
"""
|
||
import json
|
||
import logging
|
||
import re
|
||
|
||
import llm
|
||
import tools as tools_mod
|
||
from db import query
|
||
|
||
log = logging.getLogger("chat")
|
||
|
||
SYSTEM_PROMPT = """你是「NBA球迷大全」智能助手,为球迷提供比赛、球员、球队、新闻、人物等准确信息。
|
||
|
||
工作准则:
|
||
1. 用户问到时事、数据、赛程类问题,必须先调用工具查询数据库,用工具返回的真实数据回答,严禁编造具体比分、数据、日期。
|
||
2. 工具结果就是权威数据源。回答时引用关键数据(比分、时间、数据),并标注来源(如「数据库」「新闻」)。
|
||
3. 多步问题可以连续调用多个工具(如先查球队,再查该队比赛)。比较多个球员/球队时,把每个名字拆开单独调用一次工具(例如“约基奇和字母哥谁强”应分别调用 search_players("约基奇") 与 search_players("字母哥"))。
|
||
4. 查不到时如实说"数据库暂未收录",可以基于常识补充介绍,但要明确区分"数据库数据"与"常识补充"。
|
||
5. 回答使用中文,简洁有条理,可适当使用小标题或列表;不要啰嗦。
|
||
6. 严禁在回答正文中输出 tool_calls、XML 或函数调用代码——需要数据时直接调用工具函数,或直接如实回答;调用了工具就用工具返回的数据作答。
|
||
7. 当前赛季为 2025-26 赛季,总决赛已于 2026年6月结束,雷霆 4-2 击败凯尔特人夺冠。"""
|
||
|
||
|
||
def _tool_result_to_text(name, result):
|
||
"""把工具结果压缩成给模型的文本(控制 token 量)"""
|
||
if not result or result.get("_source") == "error":
|
||
return f"[工具 {name}] 查询失败:{result.get('error','未知错误') if result else '无结果'}"
|
||
results = result.get("results", [])
|
||
if not results:
|
||
return f"[工具 {name}] 未找到相关记录。"
|
||
lines = [f"[工具 {name}] 查询到 {len(results)} 条记录:"]
|
||
for r in results[:8]:
|
||
lines.append(json.dumps(r, ensure_ascii=False)[:600])
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _grounding_context(user_msg):
|
||
"""兜底检索:用关键词在数据库里快速找相关记录,注入上下文"""
|
||
ctx = []
|
||
for name, fn in (("teams", tools_mod.search_teams), ("players", tools_mod.search_players),
|
||
("games", tools_mod.search_games), ("news", tools_mod.search_news),
|
||
("persons", tools_mod.search_persons)):
|
||
try:
|
||
r = fn(user_msg, limit=3)
|
||
if r.get("results"):
|
||
ctx.append(_tool_result_to_text(name, r))
|
||
except Exception:
|
||
continue
|
||
return "\n".join(ctx)
|
||
|
||
|
||
def chat_once(user_msg, history=None):
|
||
"""单轮对话。返回 (reply, sources, used_tools, news_refs)
|
||
sources : 供前端展示的信息来源卡片
|
||
news_refs : 本次回答用到的新闻/百科资讯列表(前端折叠展示为链接)
|
||
"""
|
||
return _chat_impl(user_msg, history)
|
||
|
||
|
||
def _chat_impl(user_msg, history=None):
|
||
history = history or []
|
||
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||
for h in history[-10:]:
|
||
messages.append({"role": "user", "content": h.get("user", "")})
|
||
if h.get("assistant"):
|
||
messages.append({"role": "assistant", "content": h["assistant"]})
|
||
messages.append({"role": "user", "content": user_msg})
|
||
|
||
used_tools, sources, news_refs = [], [], []
|
||
|
||
# ---- 第 1 轮:带工具
|
||
try:
|
||
resp = llm.chat(messages, tools=tools_mod.TOOLS)
|
||
except Exception as e:
|
||
log.warning("LLM 首轮失败(%s),走兜底管线", e)
|
||
ctx = _grounding_context(user_msg)
|
||
msgs = messages + ([{"role": "system", "content": f"以下是数据库检索到的可能相关信息(未直接命中时勿强行引用):\n{ctx}"}] if ctx else [])
|
||
try:
|
||
resp2 = llm.chat(msgs)
|
||
return llm.parse_content(resp2), _mk_sources(ctx), [t for t in ("grounding",) if ctx], []
|
||
except Exception as e2:
|
||
return (f"抱歉,大模型服务暂时不可用({e2})。你可以稍后再试,或直接浏览下方数据页面。", [], [], [])
|
||
|
||
# ---- 工具执行(单轮)+ 实体覆盖补全 → 最终无工具作答
|
||
all_executed = []
|
||
calls = llm.extract_tool_calls(resp)
|
||
if not calls:
|
||
calls = _parse_text_tool_calls(llm.parse_content(resp), user_msg)
|
||
if not calls:
|
||
return llm.parse_content(resp) or "(模型未返回内容)", [], [], []
|
||
|
||
executed = []
|
||
for c in calls:
|
||
if c["name"] in [e[0] for e in executed]:
|
||
continue
|
||
result = tools_mod.run_tool(c["name"], c["arguments"])
|
||
log.info("[tools] %s(%s) -> %d条", c["name"],
|
||
json.dumps(c["arguments"], ensure_ascii=False)[:120], len(result.get("results", [])))
|
||
executed.append((c["name"], c["id"], c["arguments"], result))
|
||
used_tools.append(c["name"])
|
||
if result.get("results"):
|
||
sources.append({"tool": c["name"], "items": result["results"][:3]})
|
||
if c["name"] == "search_news":
|
||
for it in result["results"][:5]:
|
||
if it.get("id"):
|
||
news_refs.append({"id": it["id"], "title": it.get("title", ""),
|
||
"source": it.get("source", ""),
|
||
"publish_time": it.get("publish_time", "")})
|
||
elif c["name"] == "get_game_detail" and isinstance(result, dict) and result.get("id"):
|
||
sources.append({"tool": "get_game_detail", "items": [result]})
|
||
all_executed += executed
|
||
|
||
# 覆盖补全:用户问题里提到的其他实体(多球员/多球队对比)自动补查,避免模型漏调
|
||
covered = set()
|
||
for _n, _cid, _args, result in executed:
|
||
for r in result.get("results", []):
|
||
covered.add(r.get("name") or r.get("team") or "")
|
||
for term in tools_mod._split_terms(user_msg)[:4]:
|
||
for fn, key in ((tools_mod.search_players, "name"), (tools_mod.search_teams, "name")):
|
||
try:
|
||
r = fn(term, limit=3)
|
||
except Exception:
|
||
continue
|
||
for row in r.get("results", []):
|
||
nm = row.get(key) or row.get("name") or ""
|
||
if nm and nm not in covered:
|
||
cid = f"cover_{key}_{len(all_executed)}"
|
||
executed.append(("search_players" if key == "name" else "search_teams", cid, {"query": term}, r))
|
||
covered.add(nm)
|
||
used_tools.append("search_players" if key == "name" else "search_teams")
|
||
if r.get("results"):
|
||
sources.append({"tool": "search_players" if key == "name" else "search_teams", "items": r["results"][:3]})
|
||
break
|
||
|
||
# 新闻引用去重(按 id)
|
||
seen_nid, news_refs_u = set(), []
|
||
for n in news_refs:
|
||
if n["id"] not in seen_nid:
|
||
seen_nid.add(n["id"])
|
||
news_refs_u.append(n)
|
||
news_refs = news_refs_u
|
||
|
||
messages.append({"role": "assistant", "content": None,
|
||
"reasoning_content": llm.extract_reasoning(resp),
|
||
"tool_calls": [
|
||
{"id": cid, "type": "function",
|
||
"function": {"name": name, "arguments": json.dumps(args, ensure_ascii=False)}}
|
||
for name, cid, args, _ in executed]})
|
||
for name, cid, _args, result in executed:
|
||
messages.append({"role": "tool", "tool_call_id": cid, "content": _tool_result_to_text(name, result)})
|
||
|
||
try:
|
||
resp = llm.chat(messages) # 最终轮:不带工具,让模型基于数据作答
|
||
except Exception as e:
|
||
log.warning("LLM 最终轮失败(%s),返回工具结果摘要", e)
|
||
return _finalize(all_executed, user_msg, messages, sources, used_tools), sources, used_tools, news_refs
|
||
|
||
reply = llm.parse_content(resp) or ""
|
||
if not reply or "<tool_calls>" in reply or "search_" in reply:
|
||
# 模型又输出工具调用文本 → 注入上下文再答一次
|
||
return _finalize(all_executed, user_msg, messages, sources, used_tools), sources, used_tools, news_refs
|
||
return reply, sources, used_tools, news_refs
|
||
|
||
|
||
def _finalize(executed, user_msg, messages, sources, used_tools):
|
||
"""工具循环结束后:把检索结果注入上下文,让模型再总结一次(无工具),失败则给原始摘要"""
|
||
if not executed:
|
||
return "抱歉,没有检索到相关信息。请换个问法,或直接浏览下方数据页面。"
|
||
ctx = "\n\n".join(_tool_result_to_text(name, result) for name, _cid, _args, result in executed)
|
||
msgs = [{"role": "system", "content": SYSTEM_PROMPT},
|
||
{"role": "user", "content": user_msg},
|
||
{"role": "assistant", "content": f"我已经查询了数据库,检索结果如下:\n{ctx}\n\n请基于以上数据回答用户的问题(不要提及'工具',直接给出答案;数据不足时如实说明)。"}]
|
||
try:
|
||
r = llm.chat(msgs)
|
||
return llm.parse_content(r) or _results_summary(executed)
|
||
except Exception:
|
||
return _results_summary(executed)
|
||
|
||
|
||
def _results_summary(executed):
|
||
"""把已执行工具的结果整理成给用户的摘要文本"""
|
||
parts = ["以下是数据库查到的相关信息:"]
|
||
for name, _cid, _args, result in executed:
|
||
parts.append(_tool_result_to_text(name, result))
|
||
return "\n".join(parts)
|
||
|
||
|
||
def _parse_text_tool_calls(content, user_msg=""):
|
||
"""解析模型以正文形式输出的工具调用(兜底),返回与 extract_tool_calls 同构的列表
|
||
支持:真实工具名 / 模型臆造的工具名(get_game_stats、search_player_stats 等)→ 映射到最接近的真实工具"""
|
||
if not content:
|
||
return []
|
||
names = re.findall(r"[a-z_]+_[a-z_]+", content)
|
||
real = [n for n in names if n in tools_mod.TOOL_HANDLERS]
|
||
game_id = None
|
||
m = re.search(r"game_id[\"':=>]+\s*(\d+)", content)
|
||
if m:
|
||
game_id = int(m.group(1))
|
||
out = []
|
||
if real:
|
||
for i, n in enumerate(dict.fromkeys(real)):
|
||
q = re.findall(r"[\"'“”]([^\"'“”]{1,80})[\"'“”]", content)
|
||
qv = q[i] if i < len(q) else user_msg
|
||
args = {"query": qv} if n != "get_game_detail" else {"game_id": game_id or 0}
|
||
out.append({"name": n, "arguments": args, "id": f"text_{i}"})
|
||
return out
|
||
# 模型臆造的工具名 → 智能映射
|
||
game_intent = bool(re.search(r"总决赛|比赛|技术统计|统计|G\d|第.场|比分|对位", user_msg or ""))
|
||
if game_id and any("game" in n or "stat" in n for n in names):
|
||
out.append({"name": "get_game_detail", "arguments": {"game_id": game_id}, "id": "text_g"})
|
||
elif game_intent and any("stat" in n or "score" in n or "game" in n for n in names):
|
||
out.append({"name": "search_games", "arguments": {"query": user_msg}, "id": "text_g2"})
|
||
elif any("player" in n or "stat" in n for n in names):
|
||
out.append({"name": "search_players", "arguments": {"query": user_msg}, "id": "text_p"})
|
||
elif any("game" in n or "match" in n for n in names):
|
||
out.append({"name": "search_games", "arguments": {"query": user_msg}, "id": "text_g2"})
|
||
elif any("news" in n for n in names):
|
||
out.append({"name": "search_news", "arguments": {"query": user_msg}, "id": "text_n"})
|
||
return out
|
||
|
||
|
||
def _mk_sources(ctx):
|
||
return [{"tool": "grounding", "items": [{"note": "关键词预检索上下文"}]}] if ctx else []
|
||
|
||
|
||
def suggest_questions():
|
||
"""快捷问题(前端展示用,从站点配置读取,管理后台可编辑)"""
|
||
try:
|
||
from admin import get_config
|
||
raw = get_config().get("suggestions") or ""
|
||
arr = json.loads(raw)
|
||
if isinstance(arr, list) and arr:
|
||
return [str(x).strip() for x in arr if str(x).strip()]
|
||
except Exception:
|
||
pass
|
||
return DEFAULT_SUGGESTIONS
|
||
|
||
|
||
DEFAULT_SUGGESTIONS = [
|
||
"最近一场比赛结果",
|
||
"湖人本赛季战绩怎么样",
|
||
"库里本赛季场均数据",
|
||
"2026年总决赛谁赢了",
|
||
"SGA拿了什么荣誉",
|
||
"NBA工资帽是什么",
|
||
"介绍一下波波维奇",
|
||
"今天有什么新闻",
|
||
"西部排名",
|
||
"雷霆和凯尔特人总决赛G6数据",
|
||
]
|
||
|
||
|
||
def boot_info():
|
||
"""对话界面启动信息:开场白 + 快捷问题(均可后台配置)"""
|
||
try:
|
||
from admin import get_config
|
||
cfg = get_config()
|
||
except Exception:
|
||
cfg = {}
|
||
return {
|
||
"site_name": cfg.get("site_name", "NBA球迷大全"),
|
||
"site_subtitle": cfg.get("site_subtitle", "比赛 · 球员 · 球队 · 资讯 · 人物 · 百科"),
|
||
"welcome_text": cfg.get("welcome_text", "你好,我是**NBA球迷大全**助手!可以问我任何关于比赛、球员、球队、新闻、人物的问题,我会基于数据库给你准确答案~"),
|
||
"welcome_hint": cfg.get("welcome_hint", "试试:"),
|
||
"suggestions": suggest_questions(),
|
||
"footer_text": cfg.get("footer_text", "NBA球迷大全 · 数据为模拟演示数据(2025-26 赛季)"),
|
||
}
|
||
|
||
|
||
def _parse_json_array(text):
|
||
"""从 LLM 输出中解析 JSON 数组(容错:直接 JSON / 提取中括号段)"""
|
||
if not text:
|
||
return []
|
||
text = text.strip()
|
||
try:
|
||
arr = json.loads(text)
|
||
if isinstance(arr, list):
|
||
return arr
|
||
except Exception:
|
||
pass
|
||
m = re.search(r"\[.*\]", text, re.S)
|
||
if m:
|
||
try:
|
||
arr = json.loads(m.group(0))
|
||
if isinstance(arr, list):
|
||
return arr
|
||
except Exception:
|
||
pass
|
||
return []
|
||
|
||
|
||
def predict_suggestions(history=None, n=3):
|
||
"""基于对话历史,让大模型预测用户接下来最可能追问的 n 个问题(底部快捷语句)。
|
||
每个问题不超过 30 字;LLM 异常时回退到默认快捷问题。"""
|
||
n = max(1, min(int(n or 3), 6))
|
||
history = history or []
|
||
msgs = [{"role": "system", "content": (
|
||
f"你是「NBA球迷大全」智能助手。根据对话历史,站在用户角度预测他接下来最可能追问的{n}个问题。\n"
|
||
"要求:\n"
|
||
"1. 每个问题不超过30个汉字,简洁口语化\n"
|
||
"2. 必须是用户会直接发送的提问,不要编号、不要引号、不要解释\n"
|
||
"3. 只输出JSON数组,例如:[\"库里今天拿了几分\",\"湖人下一场什么时候\"],不要输出任何其他内容")}]
|
||
for h in history[-6:]:
|
||
msgs.append({"role": "user", "content": h.get("user", "")})
|
||
if h.get("assistant"):
|
||
msgs.append({"role": "assistant", "content": str(h["assistant"])[:600]})
|
||
if len(msgs) == 1:
|
||
return DEFAULT_SUGGESTIONS[:n]
|
||
try:
|
||
resp = llm.chat(msgs, temperature=0.9, max_tokens=200)
|
||
arr = _parse_json_array(llm.parse_content(resp))
|
||
out = [str(x).strip()[:30] for x in arr if str(x).strip()][:n]
|
||
if out:
|
||
return out
|
||
except Exception as e:
|
||
log.warning("快捷问题预测失败(%s),回退默认", e)
|
||
return DEFAULT_SUGGESTIONS[:n]
|