# -*- 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) sources: 供前端展示的信息来源卡片 """ 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 = [], [] # ---- 第 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]}) 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 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 reply = llm.parse_content(resp) or "" if not reply or "" in reply or "search_" in reply: # 模型又输出工具调用文本 → 注入上下文再答一次 return _finalize(all_executed, user_msg, messages, sources, used_tools), sources, used_tools return reply, sources, used_tools 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(): """快捷问题(前端展示用)""" return [ "最近一场比赛结果", "湖人本赛季战绩怎么样", "库里本赛季场均数据", "2026年总决赛谁赢了", "SGA拿了什么荣誉", "NBA工资帽是什么", "介绍一下波波维奇", "今天有什么新闻", "西部排名", "雷霆和凯尔特人总决赛G6数据", ]