18d28be943
- 引入新的`docker-compose.yml`文件,便于后端和MQTT代理(EMQX)的部署。 - 更新多个组件中的API基本检索,使用函数进行动态解析。 - 加强了多个组件中API调用的错误处理和日志记录。 - 优化了AI聊天面板离线场景的回退响应。 - 更新DataScreen和MediaScreen组件中的数据显示和统计,以反映准确的指标。 - 重构MQTT连接逻辑,以支持动态凭证和客户端ID。
210 lines
7.5 KiB
Python
210 lines
7.5 KiB
Python
# -*- 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")
|
|
|
|
# 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):
|
|
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):
|
|
"""DashScope 不可用时回退本地规则引擎"""
|
|
from .ai_tools import run_chat as rule_run
|
|
|
|
result = rule_run(messages)
|
|
# 规则引擎产出的工具同样执行(MQTT 广播),并汇总发布结果
|
|
_, all_ok = _exec_all(result.get("tools", []))
|
|
result["model"] = "rule-engine"
|
|
result["mqtt_published"] = all_ok
|
|
return result
|
|
|
|
|
|
def _exec_all(tools):
|
|
"""执行工具列表,返回 (results, all_ok)"""
|
|
from .ai_tools import run_tools
|
|
|
|
results, all_ok = run_tools(tools)
|
|
return results, all_ok
|
|
|
|
|
|
def run_chat(messages):
|
|
"""入口:{reply, tools, model}
|
|
tools 已在后端执行(MQTT 广播),返回值供请求端本地同步执行"""
|
|
if not settings.DASHSCOPE_API_KEY:
|
|
log.warning("未配置 DASHSCOPE_API_KEY,使用本地规则引擎")
|
|
return _fallback(messages)
|
|
|
|
# 注入园区知识库摘要(标准资料口径),确保回答准确
|
|
system = SYSTEM_PROMPT
|
|
try:
|
|
from .rag import brief
|
|
kb = brief()
|
|
if kb:
|
|
system += f"\n\n【园区知识库】\n{kb}"
|
|
except Exception as e: # noqa: BLE001
|
|
log.warning("llm: 知识库注入失败(仅用基础提示词): %s", e)
|
|
|
|
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:
|
|
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 广播),并汇总发布结果
|
|
_, all_ok = _exec_all(executed)
|
|
# 二次调用:携带工具结果生成最终回复
|
|
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 = "已完成操作。您还可以让我切换页面、控制播放或展示园区卡片。"
|
|
|
|
return {"reply": reply, "tools": executed, "model": settings.LLM_MODEL, "mqtt_published": all_ok}
|
|
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)
|