Files
server-core/app/park/llm.py
T
Pine 064b06ecf0 feat(park): 全量迁入园区重模块 llm/rag/asr/s2s/vision + 重依赖
迁入 app/park:llm(对话)、rag(双路向量知识库)、tools/ai_tools(智能体工具)、
asr(语音识别)、s2s_bridge(实时语音桥)、vision_yolo/vision_llm(人脸/多模态)、
knowledge/*.md、vendor/s2s-cloud(s2s 云化栈);routers 补 /api/ai|kb|asr|vision|
s2s|tools 端点。智能体提示词/企业名录改读 park_config 主数据源。pyproject 加重依赖
(dashscope/numpy/openai/torch/transformers/ultralytics/websockets/soundfile/scipy/
nltk/jinja2)。TestClient 冒烟:ai/chat(无 Key 走本地规则)、kb、s2s、tools、display 均 200。
2026-08-24 17:28:53 +08:00

270 lines
11 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.
# -*- 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)