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:
@@ -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": [],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user