# -*- coding: utf-8 -*- """AI 对话引擎 —— 阿里云 DashScope(通义千问)+ 函数调用 工具调用执行后通过 MQTT 广播,所有大屏同步响应 DashScope 不可用时自动回退本地规则引擎(离线兜底) """ import json import logging import ssl import urllib.error import urllib.request import certifi from .config import settings log = logging.getLogger("dpm.llm") def _trunc(s, n=400): """日志用截断:超长内容只留前 n 字并标注总长。""" s = str(s) return s if len(s) <= n else s[:n] + f"...(共{len(s)}字)" # macOS 系统 Python 无系统 CA,使用 certifi 提供的根证书 _SSL_CTX = ssl.create_default_context(cafile=certifi.where()) SYSTEM_PROMPT = ( "你是「昆明市大学生创业园 · OPC 智能园区」的 AI 智能助手,运行在大屏展播系统上。" "园区提供空间、孵化、融资、政策、资源、AI 赋能、综合服务七位一体服务。" "回答园区相关问题时,必须严格依据【园区知识库】中的标准资料口径回答," "不得使用资料外的数字;涉及运营数据时请区分口径(官方统计/省级绩效累计/AI平台指标)," "并在需要时调用 get_park_overview 工具获取实时准确数据。" "当可能需要「切换页面 / 控制播放 / 展示卡片 / 弹出通知」时,必须调用对应工具;" "其余园区相关问题用简洁、专业的中文回答,可适当使用列表。" "你必须基于知识库或者工具调用结果回答问题,不能编造数据或内容。" "如果有不确定的内容,请明确告知用户「我不确定,请咨询园区工作人员」。" "如果用户询问园区的企业,没有指定的情况下,必须介绍派音人工智能" "企业信息(企业名称、负责人、入驻分区、简介)一律以【园区知识库·检索命中】中企业名录原文为准;" "严禁自行编造或补充企业备案号、成立时间、注册资本、投资方、获奖、政府项目等知识库未记载的细节;" "若用户问到的企业细节在知识库中没有记载,请如实说明「该信息在园区资料中未记载」,不要编造。" ) # 暴露给大模型的工具定义(执行时经 MQTT 广播到前端) TOOLS = [ { "type": "function", "function": { "name": "navigate_page", "description": "切换大屏展示页面(数据大屏 / 数字孪生 / AI 助手 / 媒体轮播)", "parameters": { "type": "object", "properties": { "page": {"type": "string", "enum": ["/", "/twin", "/ai", "/screen"], "description": "目标页面路径"}, }, "required": ["page"], }, }, }, { "type": "function", "function": { "name": "media_control", "description": "控制媒体播放(播放/暂停/下一项/上一项)", "parameters": { "type": "object", "properties": { "action": {"type": "string", "enum": ["play", "pause", "next", "prev"]}, }, "required": ["action"], }, }, }, { "type": "function", "function": { "name": "show_card", "description": "在大屏上展示信息卡片(企业分布 / 分区介绍 / 园区总览 / 自定义内容)", "parameters": { "type": "object", "properties": { "card": {"type": "string", "enum": ["companies", "zones", "overview", "custom"]}, "title": {"type": "string"}, "content": {"type": "string"}, }, "required": ["card"], }, }, }, { "type": "function", "function": { "name": "send_alert", "description": "在大屏上弹出通知提示", "parameters": { "type": "object", "properties": { "title": {"type": "string"}, "content": {"type": "string"}, }, "required": ["title", "content"], }, }, }, ] _TOOL_MAP = { "navigate_page": "navigate", "media_control": "control", "show_card": "show_card", "send_alert": "alert", } def _chat_once(messages, with_tools=True): log.info("llm: 调用模型 %s(%d 条消息, 带工具=%s)", settings.LLM_MODEL, len(messages), with_tools) payload = { "model": settings.LLM_MODEL, "messages": messages, "temperature": 0.6, } if with_tools: payload["tools"] = TOOLS req = urllib.request.Request( f"{settings.LLM_BASE_URL}/chat/completions", data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), headers={ "Content-Type": "application/json", "Authorization": f"Bearer {settings.DASHSCOPE_API_KEY}", }, ) with urllib.request.urlopen(req, timeout=60, context=_SSL_CTX) as resp: return json.loads(resp.read().decode("utf-8")) def _fallback(messages, screen_id=""): """DashScope 不可用时回退本地规则引擎""" from .ai_tools import run_chat as rule_run result = rule_run(messages) # 规则引擎产出的工具同样执行(MQTT 广播,带发起屏幕),并汇总发布结果 _, all_ok = _exec_all(result.get("tools", []), screen_id) result["model"] = "rule-engine" result["mqtt_published"] = all_ok return result def _exec_all(tools, screen_id=""): """执行工具列表(带发起屏幕 screen_id,工具结果只发到该屏),返回 (results, all_ok)""" from .ai_tools import run_tools results, all_ok = run_tools(tools, screen_id) return results, all_ok def run_chat(messages, screen_id=""): """入口:{reply, tools, model} tools 已在后端执行(MQTT 广播,带发起屏幕 screen_id),返回值供请求端本地同步执行""" if not settings.DASHSCOPE_API_KEY: log.warning("未配置 DASHSCOPE_API_KEY,使用本地规则引擎") return _fallback(messages, screen_id) # 注入园区知识库(标准资料口径):按用户 query 检索命中段落动态注入, # 替代原 1200 字截断全量注入(避免尾部知识丢失)。无命中时回退 brief()。 system = SYSTEM_PROMPT # 园区端配置的智能体提示词优先(park_config.get_agent()) try: from .park_config import get_agent _agent = get_agent() if _agent.get("system_prompt"): system = _agent["system_prompt"] except Exception: # noqa: BLE001 pass kb = "" retrieved_count = 0 try: from .rag import brief, retrieve query = "" for m in reversed(messages or []): if m.get("role") in ("user", "me"): query = (m.get("content") or "").strip() break hits = retrieve(query, top_k=4) if query else [] if hits: retrieved_count = len(hits) kb = "\n\n".join(f"[{i + 1}] {c}" for i, c in enumerate(hits)) log.info("llm: 知识库检索命中 %d 段(query=%s)", len(hits), _trunc(query, 120)) else: kb = brief() if kb: system += f"\n\n【园区知识库 · 检索命中】\n{kb}" except Exception as e: # noqa: BLE001 log.warning("llm: 知识库注入失败(仅用基础提示词): %s", e) log.info("llm: ── 对话开始(%d 条输入消息)──", len(messages or [])) log.info("llm: 系统提示词=%s", _trunc(system, 700)) if kb: log.info("llm: 注入知识库内容=%s", _trunc(kb, 500)) for m in (messages or [])[-5:]: log.info("llm: 输入[%s] %s", m.get("role"), _trunc(m.get("content", ""), 300)) msgs = [{"role": "system", "content": system}] + [ {"role": m.get("role") == "me" and "user" or m.get("role", "user"), "content": m.get("content", "")} for m in (messages or []) ] try: data = _chat_once(msgs) choice = data["choices"][0]["message"] reply = choice.get("content") or "" tool_calls = choice.get("tool_calls") or [] executed = [] if tool_calls: log.info("llm: 模型请求调用 %d 个工具:", len(tool_calls)) for tc in tool_calls: fn = tc.get("function", {}) log.info("llm: 工具 %s args=%s", fn.get("name", ""), _trunc(fn.get("arguments", ""), 300)) for tc in tool_calls: fn = tc.get("function", {}) name = fn.get("name", "") try: args = json.loads(fn.get("arguments") or "{}") except json.JSONDecodeError: args = {} tool = {"type": _TOOL_MAP.get(name, name), "params": args} executed.append(tool) msgs.append({"role": "assistant", "content": None, "tool_calls": tool_calls}) msgs.append({ "role": "tool", "tool_call_id": tc.get("id", ""), "content": json.dumps({"ok": True}, ensure_ascii=False), }) # 工具执行(MQTT 广播),并汇总发布结果 results, all_ok = _exec_all(executed, screen_id) log.info("llm: 工具执行结果 %s", results) for t in executed: log.info("llm: 执行 %s %s", t["type"], json.dumps(t["params"], ensure_ascii=False)) # 二次调用:携带工具结果生成最终回复 try: data2 = _chat_once(msgs, with_tools=False) reply = data2["choices"][0]["message"].get("content") or reply except Exception as e: # noqa: BLE001 log.warning("LLM 二次调用失败(保留工具回复): %s", e) else: all_ok = True if not reply: reply = "已完成操作。您还可以让我切换页面、控制播放或展示园区卡片。" log.info("llm: 最终回复=%s", _trunc(reply, 600)) log.info("llm: ── 对话结束 ──") return { "reply": reply, "tools": executed, "model": settings.LLM_MODEL, "mqtt_published": all_ok, "retrieved": retrieved_count > 0, "retrieved_count": retrieved_count, "tool_names": [t.get("type") for t in executed if t.get("type")], } except urllib.error.HTTPError as e: log.warning("DashScope HTTP %s: %s", e.code, e.read()[:300]) except Exception as e: # noqa: BLE001 log.warning("DashScope 调用失败,回退规则引擎: %s", e) return _fallback(messages)