feat: Refactor DpmOverlays to use ShowCard for rendering cards and add ToolStatusToast for operation status notifications

- Moved card rendering logic from DpmOverlays to a new ShowCard component for better reusability.
- Introduced ToolStatusToast to display real-time operation statuses in the top right corner.
- Updated PageHeader to conditionally render credits based on the current path.
- Modified PromptPanel to change tool names and update prompt titles.
- Enhanced ScreenLayout to include ToolStatusToast.
- Updated styles for new components and adjusted existing styles for consistency.
- Implemented statusBus utility for dispatching tool status events.
- Updated useMqttControl to integrate tool status notifications during navigation and card display actions.
This commit is contained in:
Pine
2026-08-19 12:28:35 +08:00
parent 81a888f5ca
commit 107727ea5e
29 changed files with 2076 additions and 247 deletions
+9 -2
View File
@@ -5,8 +5,13 @@
工具:navigate(切页) / control(媒体控制) / alert(通知) / show_card(展示卡片)
"""
import json
import logging
from .mqtt import hub
log = logging.getLogger("dpm.ai_tools")
# ---------------- 工具执行 ----------------
def execute_tool(tool):
@@ -35,6 +40,8 @@ def run_tools(tools):
all_ok = True
for tool in tools or []:
ok = bool(execute_tool(tool))
log.info("ai_tools: 执行工具 %s params=%s -> %s", tool.get("type"),
json.dumps(tool.get("params") or {}, ensure_ascii=False), ok)
results.append({"type": tool.get("type"), "ok": ok})
all_ok = all_ok and ok
return results, all_ok
@@ -82,12 +89,12 @@ RULES = [
},
{
"match": ["导师", "培训", "课程", "辅导", "孵化服务", "孵化器"],
"reply": "园区组建创业指导专家团队 50 人(省级绩效材料口径),提供全周期孵化服务:\n\n· 一对一辅导:创业导师定期坐诊,覆盖商业模式、法律财税、市场营销\n· 培训课程:创业训练营、行业专题沙龙、政策解读会\n· 资源对接:技术、供应链、渠道等产业资源链接\n· 公共服务:工商注册、知识产权、法律咨询一站式代办",
"reply": "园区组建创业指导专家团队 50 人,提供全周期孵化服务:\n\n· 一对一辅导:创业导师定期坐诊,覆盖商业模式、法律财税、市场营销\n· 培训课程:创业训练营、行业专题沙龙、政策解读会\n· 资源对接:技术、供应链、渠道等产业资源链接\n· 公共服务:工商注册、知识产权、法律咨询一站式代办",
"tools": [],
},
{
"match": ["AI", "模型", "算力", "tokens", "token", "工具", "智能", "赋能", "大模型"],
"reply": "园区 AI 赋能服务是「七位一体」的核心特色:\n\n· 大模型推理:接入 DeepSeek-V3、通义千问、智谱 GLM-4、豆包、讯飞星火等多模型\n· 工具调用:文档生成、数据查询、图像创作、代码执行、语音合成等智能工具\n· 算力与成本:实时提供 token 消耗统计与速率监控\n\n今日园区 AI 推理任务持续运行,服务节点 12/12 在线。",
"reply": "园区 AI 赋能服务是「七位一体」的核心特色:\n\n· 大模型推理:接入 DeepSeek、通义千问、智谱 GLM-4、豆包、讯飞星火等多模型\n· 工具调用:文档生成、数据查询、图像创作、代码执行、语音合成等智能工具\n· 算力与成本:实时提供 token 消耗统计与速率监控\n\n今日园区 AI 推理任务持续运行,服务节点 12/39 在线。",
"tools": [],
},
{
+5 -3
View File
@@ -75,6 +75,7 @@ class Settings:
S2S_HOST = _env("DPM_S2S_HOST", "0.0.0.0")
S2S_PORT = int(_env("DPM_S2S_PORT", "8765"))
S2S_NUM_PIPELINES = int(_env("DPM_S2S_NUM_PIPELINES", "1"))
VISION_LLM_MODEL = _env("DPM_VISION_LLM_MODEL", "qwen3-vl-flash") # 对话开场画面识别(多模态)
S2S_STT_MODEL = _env("DPM_S2S_STT_MODEL", "qwen3-asr-flash-realtime")
S2S_LLM_MODEL = _env("DPM_S2S_LLM_MODEL", "qwen-plus")
S2S_TTS_MODEL = _env("DPM_S2S_TTS_MODEL", "qwen3-tts-flash-realtime")
@@ -83,9 +84,10 @@ class Settings:
S2S_BUILD_RETRY_DELAY = float(_env("DPM_S2S_BUILD_RETRY_DELAY", "10"))
S2S_LOG_LEVEL = _env("DPM_S2S_LOG_LEVEL", "INFO").upper()
# 对外可访问的 s2s 实时语音 WS 地址(供 /api/config 下发;DPM_VOICE_WS 可显式覆盖)
S2S_WS_URL = _env(
"DPM_VOICE_WS",
f"ws://{_env('MQTT_PUBLIC_HOST', _env('MQTT_BROKER_HOST', 'localhost'))}:8765/v1/realtime",
VOICE_WS = _env("DPM_VOICE_WS", "") # 显式指定时优先使用(s2s 独立进程场景)
S2S_WS_URL = (
VOICE_WS
or f"ws://{_env('MQTT_PUBLIC_HOST', _env('MQTT_BROKER_HOST', 'localhost'))}:{S2S_PORT}/v1/realtime"
)
# 语音助手基础提示词(.env 设 DPM_S2S_INSTRUCTIONS 可覆盖;知识库内容由 rag 自动拼接)
S2S_INSTRUCTIONS = _env(
+59 -7
View File
@@ -16,6 +16,13 @@ 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())
@@ -25,8 +32,14 @@ SYSTEM_PROMPT = (
"回答园区相关问题时,必须严格依据【园区知识库】中的标准资料口径回答,"
"不得使用资料外的数字;涉及运营数据时请区分口径(官方统计/省级绩效累计/AI平台指标),"
"并在需要时调用 get_park_overview 工具获取实时准确数据。"
"当用户要求「切换页面 / 控制播放 / 展示卡片 / 弹出通知」时,必须调用对应工具;"
"当可能需要「切换页面 / 控制播放 / 展示卡片 / 弹出通知」时,必须调用对应工具;"
"其余园区相关问题用简洁、专业的中文回答,可适当使用列表。"
"你必须基于知识库或者工具调用结果回答问题,不能编造数据或内容。"
"如果有不确定的内容,请明确告知用户「我不确定,请咨询园区工作人员」。"
"如果用户询问园区的企业,没有指定的情况下,必须介绍派音人工智能"
"企业信息(企业名称、负责人、入驻分区、简介)一律以【园区知识库·检索命中】中企业名录原文为准;"
"严禁自行编造或补充企业备案号、成立时间、注册资本、投资方、获奖、政府项目等知识库未记载的细节;"
"若用户问到的企业细节在知识库中没有记载,请如实说明「该信息在园区资料中未记载」,不要编造。"
)
# 暴露给大模型的工具定义(执行时经 MQTT 广播到前端)
@@ -102,6 +115,7 @@ _TOOL_MAP = {
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,
@@ -148,16 +162,37 @@ def run_chat(messages):
log.warning("未配置 DASHSCOPE_API_KEY,使用本地规则引擎")
return _fallback(messages)
# 注入园区知识库摘要(标准资料口径),确保回答准确
# 注入园区知识库(标准资料口径):按用户 query 检索命中段落动态注入,
# 替代原 1200 字截断全量注入(避免尾部知识丢失)。无命中时回退 brief()。
system = SYSTEM_PROMPT
kb = ""
retrieved_count = 0
try:
from .rag import brief
kb = brief()
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}"
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 [])
@@ -171,6 +206,10 @@ def run_chat(messages):
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", "")
@@ -187,7 +226,10 @@ def run_chat(messages):
"content": json.dumps({"ok": True}, ensure_ascii=False),
})
# 工具执行(MQTT 广播),并汇总发布结果
_, all_ok = _exec_all(executed)
results, all_ok = _exec_all(executed)
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)
@@ -200,7 +242,17 @@ def run_chat(messages):
if not reply:
reply = "已完成操作。您还可以让我切换页面、控制播放或展示园区卡片。"
return {"reply": reply, "tools": executed, "model": settings.LLM_MODEL, "mqtt_published": all_ok}
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
+19
View File
@@ -101,6 +101,25 @@ app.add_api_route("/admin/login", admin_login, methods=["POST"], include_in_sche
app.add_api_route("/admin/logout", admin_logout, methods=["GET"], include_in_schema=False)
# ---------- 客户端安装包下载(必须在 SPA 回退之前注册) ----------
_DOWNLOAD_ROOT = Path(__file__).resolve().parent.parent # backend/
@app.get("/download", include_in_schema=False)
@app.get("/11", include_in_schema=False)
async def download_installer():
"""下载 Windows 客户端安装包(backend/ 下 *.setup.exe,如 云超服..._x64-setup.exe)。"""
for f in sorted(_DOWNLOAD_ROOT.glob("*setup.exe")):
if f.is_file():
return FileResponse(
path=str(f),
filename=f.name,
media_type="application/octet-stream",
)
return JSONResponse({"detail": "安装包不存在"}, status_code=404)
# ---------- 前端静态托管 + SPA 回退(浏览器直接访问 :8000 即可) ----------
@app.get("/{path:path}", include_in_schema=False)
+396 -51
View File
@@ -1,96 +1,430 @@
# -*- coding: utf-8 -*-
"""园区知识库 —— 轻量本地 RAG(embedding + numpy 余弦检索)
"""园区知识库 —— 轻量本地 RAG(embedding + numpy 余弦检索,双路索引)
- 知识源:backend/knowledge/park.md(按标题/段落分块)
- 向量化:dashscope text-embedding-v3(云端 API,索引构建一次后缓存)
- 检索:numpy 余弦 top-k(资料量小,暴力检索足够)
升级为「细粒度分块 + LLM 生成问答对 + 双路向量索引(原句 + 问题)+ 余弦检索返回原文」:
- 知识源:backend/knowledge/park.md(按 H2 节 → 原子事实两级分块)
- 向量化:dashscope text-embedding-v4(云端 API,索引构建一次后缓存,构建时 L2 归一化)
- 问答:复用 llm._chat_once 对每块生成 3-5 个口语化问句,缓存到 kb_qa.json(无 key 自动单路降级)
- 检索:query 同时比对原句向量与问题向量,按块去重,返回 top_k 个原文块
- 提供:
- retrieve(query, top_k) → 相关段落(供对话动态注入)
- brief() → 固定知识摘要(供前端拼进 instructions,静态知识一次注入)
- retrieve(query, top_k) → 检索命中的原文块(对话动态注入)
- full_kb() → 整库原文(小库全量注入,供语音 instructions)
- brief() → 前 N 字摘要(兼容 /api/kb/brief 与检索无命中回退)
"""
import hashlib
import json
import logging
import os
import re
from pathlib import Path
import numpy as np
log = logging.getLogger("dpm.rag")
KB_FILE = Path(__file__).resolve().parent.parent / "knowledge" / "park.md"
INDEX_FILE = Path(__file__).resolve().parent.parent / "knowledge" / "kb_index.json"
EMBED_MODEL = "text-embedding-v3"
_index = None # 缓存 {chunks, embeddings}
def _trunc(s, n=300):
"""日志用截断:超长内容只留前 n 字并标注总长。"""
s = str(s)
return s if len(s) <= n else s[:n] + f"...(共{len(s)}字)"
KB_DIR = Path(__file__).resolve().parent.parent / "knowledge"
KB_GLOB = "*.md" # 知识库 = knowledge/ 下全部 markdown(park.md + opc.md 等)
META_FILE = KB_DIR / "kb_meta.json" # 元数据(JSON,可读):model/hash/chunks/questions/embed_texts/embed_owner
VECTOR_FILE = KB_DIR / "kb_vectors.npy" # 向量矩阵(二进制 numpy float32,快载小体积)
LEGACY_INDEX_FILE = KB_DIR / "kb_index.json" # 旧版单文件索引(迁移后删除)
QA_FILE = KB_DIR / "kb_qa.json"
EMBED_MODEL = "text-embedding-v4"
EMBED_BATCH = 10 # DashScope embedding 单批上限
QA_BATCH = 5 # 每批几块生成问答
CHUNK_MAX = 240 # 单块超过此长度按句拆
_index = None # 缓存 {chunks, questions, embed_texts, embed_owner, embeddings(归一化 np.ndarray)}
def _kb_text() -> str:
"""拼接 knowledge/ 下全部 markdown 内容(按文件名排序,稳定顺序)。"""
parts = []
for f in sorted(KB_DIR.glob(KB_GLOB)):
try:
parts.append(f.read_text("utf-8"))
except OSError as e: # noqa: BLE001
log.warning("rag: 跳过知识文件 %s: %s", f.name, e)
return "\n\n".join(parts)
def _data_hash() -> str:
"""全部知识文件内容签名,用于索引/问答缓存失效判断。"""
return hashlib.sha1(_kb_text().encode("utf-8")).hexdigest()
def _split_long(text: str, limit: int = CHUNK_MAX) -> list[str]:
"""超长块按句号/分号/顿号等边界拆成多块,保留语义完整。"""
if len(text) <= limit:
return [text]
parts: list[str] = []
cur = ""
for seg in re.split(r"(?<=[。!?;、])", text):
if len(cur) + len(seg) > limit and cur:
parts.append(cur.strip())
cur = ""
cur += seg
if cur.strip():
parts.append(cur.strip())
return [p for p in parts if len(p) > 8]
def _chunk_md(text: str) -> list[str]:
"""按标题/段落分块"""
"""两级切分:H2 节标题作上下文前缀 + 节内原子事实。
企业条目(加粗公司名行 `**...|公司名**` + 负责人/简介列表)合并为单个块,
保证「企业名称 + 企业介绍」作为一个整体可被按名召回。
"""
chunks: list[str] = []
cur: list[str] = []
section = ""
para: list[str] = []
company: list[str] | None = None # 正在累积的单个企业块
def flush_para():
nonlocal para
if para:
t = "\n".join(para).strip()
if len(t) > 8:
chunks.extend(_split_long(t))
para = []
def flush_company():
nonlocal company
if company:
t = "\n".join(company).strip()
if len(t) > 8:
chunks.append(t) # 企业块不拆,保证名称+介绍同块
company = None
def add_fact(content: str):
full = f"{section}\n{content}" if section else content
if len(full) > 8:
chunks.extend(_split_long(full))
for line in text.splitlines():
if line.startswith("#"):
if cur:
chunks.append("\n".join(cur).strip())
cur = [line]
else:
cur.append(line)
if cur:
chunks.append("\n".join(cur).strip())
return [c for c in chunks if len(c) > 10]
s = line.rstrip()
if s.startswith("# "): # H1:不产块
flush_para(); flush_company()
continue
if s.startswith("## "): # H2 小节:切换上下文前缀
flush_para(); flush_company()
section = s[3:].strip()
continue
if s.startswith("###"): # H3 子节:上下文(如分区),flush 企业
flush_para(); flush_company()
para.append(s.strip())
continue
stripped = s.strip()
if not stripped:
continue
# 企业条目:加粗公司名行作为单个企业块的起点
if stripped.startswith("**") and "|" in stripped:
flush_para(); flush_company()
company = [stripped]
continue
if company is not None:
# 企业块的跟随行(负责人/简介等列表项)
if stripped.startswith("- ") or stripped.startswith("问:") or re.match(r"^\d+[\.、]\s", stripped):
company.append(stripped)
else:
flush_company()
para.append(stripped)
continue
if stripped.startswith("- "): # 列表项 = 原子事实
flush_para(); add_fact(stripped)
elif re.match(r"^\d+[\.、]\s", stripped): # 编号步
flush_para(); add_fact(stripped)
elif stripped.startswith("问:"): # 单条问答
flush_para(); add_fact(stripped)
else: # 普通段落:累积后整段成块(超长再拆)
para.append(stripped)
flush_para()
flush_company()
return chunks
def _company_name(chunk: str) -> str:
"""从企业块提取企业名称:`**加速1|云南派音人工智能科技有限公司**` → `云南派音人工智能科技有限公司`。"""
m = re.match(r"^\*\*[^|\n]*|([^*]+)\*\*", chunk) or re.match(r"^\*\*([^*]+)\*\*", chunk)
return m.group(1).strip() if m else ""
def _embed(texts: list[str]) -> list[list[float]]:
"""dashscope text-embedding-v3 批量向量化"""
"""dashscope text-embedding-v4 批量向量化(每批 ≤10 条,按 text_index 排序拼接)。"""
import dashscope
api_key = os.environ.get("DASHSCOPE_API_KEY") or ""
resp = dashscope.TextEmbedding.call(
model=EMBED_MODEL,
input=texts,
api_key=api_key,
)
if resp.status_code != 200:
raise RuntimeError(f"embedding API {resp.status_code}: {resp.code} {resp.message}")
out = [e["embedding"] for e in resp.output["embeddings"]]
out: list[list[float]] = []
for i in range(0, len(texts), EMBED_BATCH):
batch = texts[i:i + EMBED_BATCH]
resp = dashscope.TextEmbedding.call(
model=EMBED_MODEL,
input=batch,
api_key=api_key,
)
if resp.status_code != 200:
raise RuntimeError(
f"embedding API {resp.status_code}: {resp.code} {resp.message}"
)
embs = sorted(
resp.output["embeddings"],
key=lambda e: e.get("text_index", e.get("index", 0)),
)
out.extend(e["embedding"] for e in embs)
log.info("rag: embedding 完成 %d 条(%s,每批 %d)", len(texts), EMBED_MODEL, EMBED_BATCH)
return out
def _normalize(vectors: list[list[float]]) -> np.ndarray:
"""转为 float32 np.ndarray 并做 L2 行归一化(点积即余弦相似度)。"""
arr = np.asarray(vectors, dtype=np.float32)
norms = np.linalg.norm(arr, axis=1, keepdims=True)
norms[norms == 0] = 1.0
return arr / norms
# ── LLM 问答生成 ────────────────────────────────────────────────
def _parse_json_array(text: str) -> list:
"""解析模型返回的 JSON 数组:剥 markdown 围栏 → json.loads → 截取首尾方括号兜底。"""
text = (text or "").strip()
if text.startswith("```"):
lines = [l for l in text.splitlines() if not l.strip().startswith("```")]
text = "\n".join(lines).strip()
for candidate in (text,):
try:
return json.loads(candidate)
except Exception:
pass
i = text.find("[")
j = text.rfind("]")
if i != -1 and j > i:
try:
return json.loads(text[i:j + 1])
except Exception:
pass
return []
def _qa_for_batch(chunks: list[str]) -> list[list[str]]:
"""对一批分块,用 LLM 生成每块的 3-5 个口语化问句(输出 JSON 数组,与块一一对应)。"""
from .llm import _chat_once
numbered = "\n\n".join(f"[{i}]\n{c}" for i, c in enumerate(chunks))
def build_prompt():
return (
"下面是园区知识库的若干分块,每块标有 [编号]。"
"请为每一块生成 3-5 个用户可能会问的自然口语化中文问句,"
"覆盖该块全部信息点与常见同义问法。"
f"严格只输出一个 JSON 数组,数组长度必须为 {len(chunks)},"
"第 i 个元素是第 i 块的问题字符串数组。不要输出任何其他文字或解释。\n\n"
+ numbered
)
for attempt in range(2):
try:
data = _chat_once([{"role": "user", "content": build_prompt()}], with_tools=False)
content = data["choices"][0]["message"].get("content") or ""
arr = _parse_json_array(content)
if isinstance(arr, list) and arr:
out: list[list[str]] = []
for i in range(len(chunks)):
qs = arr[i] if i < len(arr) and isinstance(arr[i], list) else []
out.append([str(q).strip() for q in qs if str(q).strip()][:5])
return out
except Exception as e: # noqa: BLE001
log.warning("rag: QA 批生成失败(尝试 %d/%d): %s", attempt + 1, 2, e)
return [[] for _ in chunks]
def _first_line(chunk: str) -> str:
"""取块首行(去标题),用作无问句时的兜底检索问题。"""
for ln in chunk.splitlines():
ln = ln.strip()
if ln and not ln.startswith("#"):
return ln
return chunk[:60]
def _ensure_questions(chunks: list[str], questions) -> list[list[str]]:
"""确保每块都有至少一个检索问题:未生成问句的块,用块首句兜底(保证全量覆盖)。"""
qs: list[list[str]] = [list(q or []) for q in (questions or [])]
if len(qs) < len(chunks):
qs.extend([] for _ in range(len(chunks) - len(qs)))
for i, c in enumerate(chunks):
if not qs[i]:
qs[i] = [_first_line(c)]
return qs
def _load_questions(chunks: list[str]) -> list[list[str]]:
"""加载/生成问答缓存;返回与 chunks 对齐的问题数组(保证每块 ≥1 个问题)。无 key 时单路回退。"""
from .config import settings
if not settings.DASHSCOPE_API_KEY:
log.warning("rag: 无 DASHSCOPE_API_KEY,跳过问答生成(单路检索)")
return _ensure_questions(chunks, [[] for _ in chunks])
h = _data_hash()
if QA_FILE.exists():
try:
data = json.loads(QA_FILE.read_text("utf-8"))
if data.get("data_hash") == h and data.get("chunks") == chunks:
log.info("rag: 问答缓存已加载(%d 块)", len(chunks))
return _ensure_questions(chunks, data["questions"])
except Exception: # noqa: BLE001
log.warning("rag: 问答缓存损坏,重建")
questions: list[list[str]] = []
total = len(chunks)
for i in range(0, total, QA_BATCH):
questions.extend(_qa_for_batch(chunks[i:i + QA_BATCH]))
log.info("rag: 问答生成进度 %d/%d", min(i + QA_BATCH, total), total)
questions = _ensure_questions(chunks, questions)
QA_FILE.write_text(
json.dumps({"data_hash": h, "chunks": chunks, "questions": questions}, ensure_ascii=False),
"utf-8",
)
log.info("rag: 问答缓存已生成(%d 块)-> %s", len(chunks), QA_FILE)
return questions
# ── 双路向量索引 ────────────────────────────────────────────────
def _migrate_legacy():
"""把旧版单文件 kb_index.json(向量内嵌在 JSON)就地转为 kb_vectors.npy + kb_meta.json,再删除旧文件。"""
if not LEGACY_INDEX_FILE.exists() or META_FILE.exists():
return
try:
d = json.loads(LEGACY_INDEX_FILE.read_text("utf-8"))
np.save(VECTOR_FILE, np.asarray(d["embeddings"], dtype=np.float32))
META_FILE.write_text(
json.dumps({
"model": d.get("model"),
"data_hash": d.get("data_hash"),
"chunks": d["chunks"],
"questions": d.get("questions", []),
"embed_texts": d["embed_texts"],
"embed_owner": d["embed_owner"],
}, ensure_ascii=False),
"utf-8",
)
LEGACY_INDEX_FILE.unlink()
log.info("rag: 已迁移旧 kb_index.json -> kb_vectors.npy + kb_meta.json(无重建,未调 API)")
except Exception as e: # noqa: BLE001
log.warning("rag: 旧索引迁移失败(将重建): %s", e)
def _load_index():
"""懒加载索引;缺失时构建并缓存"""
"""构建/加载双路向量索引(原句 + 生成问题)。向量存二进制 .npy,元数据存 JSON。"""
global _index
if _index is not None:
return _index
if INDEX_FILE.exists():
_migrate_legacy()
h = _data_hash()
if META_FILE.exists() and VECTOR_FILE.exists():
try:
_index = json.loads(INDEX_FILE.read_text("utf-8"))
log.info("rag: 知识索引已加载(%d 块)", len(_index["chunks"]))
return _index
data = json.loads(META_FILE.read_text("utf-8"))
valid = (
data.get("model") == EMBED_MODEL
and data.get("data_hash") == h
and len(data.get("embed_texts", [])) == len(data.get("embed_owner", []))
)
if valid:
vectors = np.load(VECTOR_FILE) # 已归一化 float32
if len(vectors) == len(data["embed_texts"]):
_index = {
"chunks": data["chunks"],
"questions": data.get("questions", []),
"embed_texts": data["embed_texts"],
"embed_owner": data["embed_owner"],
"embeddings": vectors,
}
log.info("rag: 双路知识索引已加载(%d 块 → %d 向量, %s)",
len(_index["chunks"]), len(_index["embed_texts"]), EMBED_MODEL)
return _index
log.warning("rag: 索引失效(model/hash/长度不符),重建")
except Exception: # noqa: BLE001
log.warning("rag: 索引损坏,重建")
# 构建
chunks = _chunk_md(KB_FILE.read_text("utf-8"))
log.info("rag: 构建知识索引(%d 块,embedding 中…)", len(chunks))
embs = _embed(chunks)
_index = {"chunks": chunks, "embeddings": embs}
INDEX_FILE.write_text(json.dumps(_index, ensure_ascii=False), "utf-8")
log.info("rag: 知识索引构建完成 -> %s", INDEX_FILE)
chunks = _chunk_md(_kb_text())
questions = _load_questions(chunks)
embed_texts: list[str] = []
embed_owner: list[int] = []
for i, c in enumerate(chunks):
embed_texts.append(c)
embed_owner.append(i)
# 企业块:把「企业名称」作为确定性问题加入索引,保证按名称可召回(名称为问、介绍为答)
cname = _company_name(c)
if cname:
embed_texts.append(cname)
embed_owner.append(i)
for q in questions[i]:
embed_texts.append(q)
embed_owner.append(i)
log.info("rag: 构建双路索引(%d 块 → %d 向量,%s embedding 中…)",
len(chunks), len(embed_texts), EMBED_MODEL)
arr = _normalize(_embed(embed_texts))
_index = {
"chunks": chunks,
"questions": questions,
"embed_texts": embed_texts,
"embed_owner": embed_owner,
"embeddings": arr,
}
np.save(VECTOR_FILE, arr)
META_FILE.write_text(
json.dumps({
"model": EMBED_MODEL,
"data_hash": h,
"chunks": chunks,
"questions": questions,
"embed_texts": embed_texts,
"embed_owner": embed_owner,
}, ensure_ascii=False),
"utf-8",
)
log.info("rag: 双路索引构建完成(%d 块, dim=%d)-> %s + %s",
len(chunks), arr.shape[1], VECTOR_FILE, META_FILE)
return _index
def retrieve(query: str, top_k: int = 3) -> list[str]:
"""检索与 query 最相关的知识段落"""
# ── 对外接口 ────────────────────────────────────────────────────
def retrieve(query: str, top_k: int = 3, threshold: float = 0.3) -> list[str]:
"""双路检索:query 同时比对原句与问题向量,按块去重,返回 top_k 个原文块。"""
if not query or not query.strip():
return []
idx = _load_index()
q = _embed([query])[0]
sims = np.dot(np.array(idx["embeddings"]), np.array(q))
top = np.argsort(-sims)[:top_k]
return [idx["chunks"][int(i)] for i in top if float(sims[int(i)]) > 0.3]
q = _normalize([_embed([query])[0]])[0]
sims = np.dot(idx["embeddings"], q)
order = np.argsort(-sims)
best: dict[int, float] = {} # owner -> 该块最高分
for e in order:
owner = idx["embed_owner"][int(e)]
sc = float(sims[int(e)])
if sc < threshold:
break
if owner not in best:
best[owner] = sc
if len(best) >= top_k:
break
ordered = sorted(best.items(), key=lambda kv: kv[1], reverse=True)
hits = [idx["chunks"][o] for o, _ in ordered]
log.info("rag: retrieve() query=%s top_k=%d threshold=%.2f -> 命中 %d 段",
_trunc(query, 120), top_k, threshold, len(hits))
for j, (o, sc) in enumerate(ordered):
log.info("rag: [%d] %.4f %s", j, sc, _trunc(idx["chunks"][o], 300))
return hits
def brief(max_chars: int = 1200) -> str:
"""固定知识摘要(前端拼进 instructions,静态知识一次注入)"""
"""固定知识摘要(前 N 字;兼容 /api/kb/brief 与检索无命中回退)。"""
idx = _load_index()
out = []
total = 0
@@ -99,17 +433,28 @@ def brief(max_chars: int = 1200) -> str:
break
out.append(c)
total += len(c)
return "\n\n".join(out)
text = "\n\n".join(out)
log.info("rag: brief() 知识库摘要 %d 字 -> %s", total, _trunc(text, 300))
return text
def full_kb() -> str:
"""整库原文(小库全量注入,用于语音 instructions,修复尾部知识丢失)。"""
idx = _load_index()
text = "\n\n".join(idx["chunks"])
log.info("rag: full_kb() 全库 %d 字 / %d 块", len(text), len(idx["chunks"]))
return text
def build_instructions() -> str:
"""语音助手的完整基础提示词 = 配置提示词 + 园区知识库摘要(服务端统一组装)"""
"""语音助手的完整基础提示词 = 配置提示词 + 整库知识(服务端统一组装)。"""
from .config import settings
base = settings.S2S_INSTRUCTIONS.strip()
try:
kb = brief()
kb = full_kb()
if kb:
base += f"\n\n【园区知识库】\n{kb}"
except Exception as e: # noqa: BLE001
log.warning("rag: 知识库拼接失败(仅用基础提示词): %s", e)
log.info("rag: build_instructions() 语音系统提示词=%s", _trunc(base, 600))
return base
+49 -7
View File
@@ -111,23 +111,34 @@ async def get_settings():
@router.get("/api/config")
async def runtime_config():
"""运行配置(供前端启动引导覆盖):MQTT 地址/账号、API 基址
async def runtime_config(request: Request):
"""运行配置(供前端启动引导覆盖):MQTT 地址/账号、API 基址、语音地址
—— 打包部署时展播端从后端拉取,避免构建期写死的局域网 IP 失效
"""
ws = settings.MQTT_WS_URL
# 从 ws://host:port/mqtt 中提取 broker 主机
# 后端可达主机:优先取客户端实际访问本服务的 Host(与 api_base 一致),
# 回退到从 MQTT broker 地址提取。
host = "192.168.1.9"
try:
host = ws.split("://", 1)[1].split(":", 1)[0]
except Exception: # noqa: BLE001
pass
try:
rh = (request.headers.get("host") or "").split(":")[0]
if rh:
host = rh
except Exception: # noqa: BLE001
pass
# s2s 与后端同机启动 → voice_url 默认跟随后端主机;显式 DPM_VOICE_WS 优先
voice_url = settings.S2S_WS_URL
if not settings.VOICE_WS and host:
voice_url = f"ws://{host}:{settings.S2S_PORT}/v1/realtime"
return {
"ok": True,
"mqtt_url": ws,
"mqtt_username": settings.MQTT_USERNAME or "",
"mqtt_password": settings.MQTT_PASSWORD or "",
"voice_url": settings.S2S_WS_URL,
"voice_url": voice_url,
"api_base": f"http://{host}:{settings.PORT}",
"broker_host": host,
}
@@ -418,7 +429,7 @@ AI_GROUPED_QUESTIONS = [
]
AI_OPC_TOOLS = [
"DeepSeek-V3", "通义千问", "ChatGPT", "豆包",
"DeepSeek", "通义千问", "ChatGPT", "豆包",
"Midjourney", "Stable Diffusion", "剪映", "Notion AI",
"WPS AI", "GitHub Copilot",
]
@@ -550,6 +561,28 @@ def vision_frame(body: VisionFrameBody):
return predict_base64(img_b64)
class VisionLlmBody(BaseModel):
image: str = "" # JPEG base64
prompt: str = "" # 可选,自定义识别指令
@router.post("/api/vision/llm")
def vision_llm(body: VisionLlmBody):
"""对话开场画面识别:多模态 LLM(qwen3-vl-flash)识别人数/性别,返回 {ok, people, males, females, desc}。"""
img = (body.image or "").strip()
if not img:
return {"ok": False, "error": "missing image"}
try:
from .vision_llm import analyze_scene
result = analyze_scene(img, body.prompt)
log.info("api: /api/vision/llm -> people=%s males=%s females=%s desc=%s",
result.get("people"), result.get("males"), result.get("females"),
(result.get("desc") or "")[:60])
return result
except Exception as e: # noqa: BLE001
log.warning("vision llm 失败: %s", e)
return {"ok": False, "error": str(e)}
# ==================== 智能体工具调用 + 知识库 ====================
class ToolsExecBody(BaseModel):
name: str = ""
@@ -563,6 +596,9 @@ def tools_exec(body: ToolsExecBody):
return {"ok": False, "error": "missing tool name"}
from .tools import exec_tool
result = exec_tool(name, body.args or {})
log.info("api: /api/tools/exec name=%s args=%s result=%s", name,
json.dumps(body.args or {}, ensure_ascii=False)[:300],
str(result)[:400])
return {"ok": True, "result": result}
@router.get("/api/kb/brief")
@@ -570,7 +606,9 @@ def kb_brief(max_chars: int = 1200):
"""园区知识摘要(前端拼进对话 instructions)"""
try:
from .rag import brief
return {"ok": True, "brief": brief(max_chars=max_chars)}
result = brief(max_chars=max_chars)
log.info("api: /api/kb/brief max_chars=%d -> %s", max_chars, str(result)[:300])
return {"ok": True, "brief": result}
except Exception as e: # noqa: BLE001
log.warning("kb brief 失败: %s", e)
return {"ok": False, "error": str(e)}
@@ -583,6 +621,7 @@ def kb_retrieve(q: str = "", top_k: int = 3):
try:
from .rag import retrieve
chunks = retrieve(q, top_k=top_k)
log.info("api: /api/kb/retrieve q=%s top_k=%d -> %d 段", q[:100], top_k, len(chunks))
return {"ok": True, "chunks": chunks}
except Exception as e: # noqa: BLE001
log.warning("kb retrieve 失败: %s", e)
@@ -593,7 +632,10 @@ def s2s_instructions():
"""语音助手基础提示词(服务端统一组装:配置提示词 + 园区知识库)"""
try:
from .rag import build_instructions
return {"ok": True, "instructions": build_instructions()}
instructions = build_instructions()
log.info("api: /api/s2s/instructions -> 系统提示词(%d 字)=%s",
len(instructions), instructions[:600])
return {"ok": True, "instructions": instructions}
except Exception as e: # noqa: BLE001
log.warning("s2s instructions 构建失败: %s", e)
return {"ok": False, "error": str(e)}
+2 -2
View File
@@ -99,10 +99,10 @@ FEED = [
{"id": 7, "icon": "trophy", "text": "\"创赢未来\"2026创业大赛昆明选拔赛暨马兰花创业培训讲师大赛举行(13个项目参赛)", "time": "2026-04-22"},
{"id": 8, "icon": "broadcast", "text": "2026年第二期创业企业(项目)入驻招募公告发布(官网 www.kmyc.gov.cn)", "time": "2026-04-15"},
{"id": 21, "icon": "sparkles", "text": "「PineSound」AI驱动音频管理平台上线 pinesound.cn:智能配乐与音效生成,集成100W+音效库、50W+配乐库,全球版权授权(官网)", "time": "2026"},
{"id": 22, "icon": "medal", "text": "「PineSound」发布企业标准 Q/YNPY 001-2026《数字内容创作 配乐通用分类标准》", "time": "2026"},
{"id": 22, "icon": "medal", "text": "PineSound发布企业标准 Q/YNPY 001-2026《数字内容创作 配乐通用分类标准》", "time": "2026"},
{"id": 9, "icon": "award", "text": "2026年第一期入驻评审结果在昆明市人社局官网公示(23个优质项目正式入驻)", "time": "2026-03-27"},
{"id": 10, "icon": "users", "text": "2026年第一期招募集中评审完成:38个申请,23个优质项目正式入驻", "time": "2026-03-24"},
{"id": 19, "icon": "sparkles", "text": "园内企业「云南派音人工智能科技(PineSound)」专注多模态音频技术研发:自研Pine系列模型覆盖音频识别、向量嵌入、音效生成、配乐创作", "time": "2026-04"},
{"id": 19, "icon": "sparkles", "text": "云南派音人工智能科技专注多模态音频技术研发:自研Pine系列模型覆盖音频识别、向量嵌入、音效生成、配乐创作", "time": "2026-04"},
{"id": 20, "icon": "bolt", "text": "「PineSound」2026年4月注册成立,获北京投资支持并吸纳就业4人,入驻加速区(入驻企业信息表)", "time": "2026-04"},
{"id": 11, "icon": "cpu", "text": "云南首个人工智能OPC创新人才基地落地昆明,填补省内个体AI创业培育空白(公开报道)", "time": "2026-03-23"},
{"id": 12, "icon": "medal", "text": "第九届\"春城创业荟\"创业创新大赛圆满闭幕,获奖项目名单公布", "time": "2025-09-30"},
+8 -1
View File
@@ -12,6 +12,12 @@ from datetime import datetime
log = logging.getLogger("dpm.tools")
def _trunc(s, n=800):
"""日志用截断:超长内容只留前 n 字并标注总长。"""
s = str(s)
return s if len(s) <= n else s[:n] + f"...(共{len(s)}字)"
# ── 工具实现 ────────────────────────────────────────────────────────────
def _get_park_overview(args):
@@ -127,7 +133,8 @@ def exec_tool(name: str, args: dict):
return json.dumps({"error": f"未知工具: {name}"}, ensure_ascii=False)
try:
result = t["fn"](args or {})
log.info("tools: %s args=%s -> %s", name, json.dumps(args, ensure_ascii=False)[:120], str(result)[:120])
log.info("tools: 执行工具 %s args=%s", name, json.dumps(args, ensure_ascii=False))
log.info("tools: 结果 -> %s", _trunc(result, 1000))
return result
except Exception as e: # noqa: BLE001
log.error("tools: %s 执行失败 %s", name, e, exc_info=True)
+83
View File
@@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
"""对话开场画面识别 —— 用多模态 LLM(qwen3-vl-flash)识别实时画面中的人数、性别等,
并把结果作为上下文注入对话,让 AI 了解当前在场观众。
与 vision_yolo(YOLO 数人脸/姿态)互补:这里用 LLM 做语义级理解(人数、性别构成、场景描述)。
"""
import json
import logging
import ssl
import urllib.request
import certifi
from .config import settings
log = logging.getLogger("dpm.vision")
_SSL_CTX = ssl.create_default_context(cafile=certifi.where())
_DEFAULT_PROMPT = (
"请识别这张实时画面,只输出一个 JSON 对象(不要输出任何其他文字):"
'{"people": 画面中人数(int), "males": 其中男性人数(int), "females": 其中女性人数(int), '
'"desc": 一句话中文描述画面(含大致人数、性别构成、人物大致状态,如年龄/坐站/是否看屏幕)。'
"若画面无人或不确定,则 people=0、males=0、females=0,desc='画面中暂时没有人'。"
)
def analyze_scene(jpeg_b64: str, prompt: str = "") -> dict:
"""调用多模态 LLM 识别画面中人数/性别,返回 {ok, people, males, females, desc}。"""
payload = {
"model": settings.VISION_LLM_MODEL,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": prompt or _DEFAULT_PROMPT},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{jpeg_b64}"}},
],
}],
}
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=30, context=_SSL_CTX) as resp:
data = json.loads(resp.read().decode("utf-8"))
content = data["choices"][0]["message"].get("content") or ""
return _parse(content)
def _parse(text: str) -> dict:
"""从模型输出中抽取 JSON 对象并规范化为结果 dict。"""
text = (text or "").strip()
if text.startswith("```"):
lines = [l for l in text.splitlines() if not l.strip().startswith("```")]
text = "\n".join(lines).strip()
obj: dict = {}
i, j = text.find("{"), text.rfind("}")
if i != -1 and j > i:
try:
obj = json.loads(text[i:j + 1])
except Exception: # noqa: BLE001
log.warning("vision llm: 返回 JSON 解析失败: %s", text[:120])
people = _as_int(obj.get("people"))
males = _as_int(obj.get("males"))
females = _as_int(obj.get("females"))
return {
"ok": True,
"people": people,
"males": males,
"females": females,
"desc": str(obj.get("desc") or "").strip(),
}
def _as_int(v):
try:
return int(float(v))
except (TypeError, ValueError):
return 0