feat(agent): 智能体接入工具调用 + 园区知识库(RAG)

后端:
- tools.py:工具注册表(get_park_overview 园区实时数据 / query_companies 企业名录 / control_display 大屏控制 / get_time),POST /api/tools/exec 执行
- rag.py:园区知识库(park.md 分块 → dashscope text-embedding-v3 → numpy 余弦检索),/api/kb/brief 摘要注入 + /api/kb/retrieve 动态检索
- 知识索引 kb_index.json 入库,部署免首次构建

前端(VoiceAssistant):
- TOOL_DEFS 经 session.update 传给 LLM;toolcall 事件 → 后端执行 → sendToolOutput + requestResponse
- instructions 拼接园区知识摘要;工具调用气泡提示
- 测试通过:4 个工具,检索「入驻政策」正确命中政策段落
This commit is contained in:
Pine
2026-08-18 02:06:07 +08:00
parent 1e60744961
commit 83bf19a3e5
6 changed files with 372 additions and 4 deletions
+102
View File
@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
"""园区知识库 —— 轻量本地 RAG(embedding + numpy 余弦检索)
- 知识源:backend/knowledge/park.md(按标题/段落分块)
- 向量化:dashscope text-embedding-v3(云端 API,索引构建一次后缓存)
- 检索:numpy 余弦 top-k(资料量小,暴力检索足够)
- 提供:
- retrieve(query, top_k) → 相关段落(供对话动态注入)
- brief() → 固定知识摘要(供前端拼进 instructions,静态知识一次注入)
"""
import json
import logging
import os
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 _chunk_md(text: str) -> list[str]:
"""按标题/段落分块"""
chunks: list[str] = []
cur: list[str] = []
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]
def _embed(texts: list[str]) -> list[list[float]]:
"""dashscope text-embedding-v3 批量向量化"""
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"]]
return out
def _load_index():
"""懒加载索引;缺失时构建并缓存"""
global _index
if _index is not None:
return _index
if INDEX_FILE.exists():
try:
_index = json.loads(INDEX_FILE.read_text("utf-8"))
log.info("rag: 知识索引已加载(%d 块)", len(_index["chunks"]))
return _index
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)
return _index
def retrieve(query: str, top_k: int = 3) -> list[str]:
"""检索与 query 最相关的知识段落"""
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]
def brief(max_chars: int = 1200) -> str:
"""固定知识摘要(前端拼进 instructions,静态知识一次注入)"""
idx = _load_index()
out = []
total = 0
for c in idx["chunks"]:
if total + len(c) > max_chars:
break
out.append(c)
total += len(c)
return "\n\n".join(out)
+39
View File
@@ -426,3 +426,42 @@ def vision_frame(body: VisionFrameBody):
return {"ok": False, "error": "missing image"}
from .vision_yolo import predict_base64
return predict_base64(img_b64)
# ==================== 智能体工具调用 + 知识库 ====================
class ToolsExecBody(BaseModel):
name: str = ""
args: dict = {}
@router.post("/api/tools/exec")
def tools_exec(body: ToolsExecBody):
"""执行智能体工具(LLM function calling):name + args → 结果字符串"""
name = (body.name or "").strip()
if not name:
return {"ok": False, "error": "missing tool name"}
from .tools import exec_tool
result = exec_tool(name, body.args or {})
return {"ok": True, "result": result}
@router.get("/api/kb/brief")
def kb_brief(max_chars: int = 1200):
"""园区知识摘要(前端拼进对话 instructions)"""
try:
from .rag import brief
return {"ok": True, "brief": brief(max_chars=max_chars)}
except Exception as e: # noqa: BLE001
log.warning("kb brief 失败: %s", e)
return {"ok": False, "error": str(e)}
@router.get("/api/kb/retrieve")
def kb_retrieve(q: str = "", top_k: int = 3):
"""知识库检索(query → 相关段落)"""
if not q.strip():
return {"ok": False, "error": "missing q"}
try:
from .rag import retrieve
chunks = retrieve(q, top_k=top_k)
return {"ok": True, "chunks": chunks}
except Exception as e: # noqa: BLE001
log.warning("kb retrieve 失败: %s", e)
return {"ok": False, "error": str(e)}
+127
View File
@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
"""智能体工具注册表 —— LLM function calling 的后端执行器
- TOOLS:工具名 → 描述 + JSON Schema 参数(给 LLM 声明用)
- exec_tool(name, args):执行工具,返回给 LLM 的字符串结果(JSON)
- 供 POST /api/tools/exec 调用;前端 s2s 客户端收到 toolcall 事件后转发到此执行
"""
import json
import logging
from datetime import datetime
log = logging.getLogger("dpm.tools")
# ── 工具实现 ────────────────────────────────────────────────────────────
def _get_park_overview(args):
"""园区实时概览(在园项目/就业/营收/在园人数等)"""
from .sim_engine import sim_engine
d = sim_engine.snapshot()
return json.dumps({
"在园项目": d["projects"]["inPark"],
"累计孵化企业": d["projects"]["cum"],
"今日新增项目": d["projects"]["todayNew"],
"带动就业(人)": d["jobs"]["total"],
"今日新增就业": d["jobs"]["todayNew"],
"累计营收(万元)": d["revenue"]["total"],
"今日营收(万元)": d["revenue"]["today"],
"营收同比增速(%)": d["revenue"]["growth"],
"设备在线率(%)": d["park"]["devices"],
"今日能耗(kWh)": d["park"]["energy"],
"当前在园人数": d["park"]["people"],
"会议预约": d["park"]["meeting"],
"工位使用": d["park"]["desk"],
}, ensure_ascii=False)
def _query_companies(args):
"""园区入驻企业名录查询(关键词过滤)"""
from .sim_engine import COMPANY_NAMES
q = (args.get("keyword") or "").strip()
names = [n for n in COMPANY_NAMES if q in n] if q else list(COMPANY_NAMES)
return json.dumps({
"total": len(names),
"keyword": q,
"companies": names[:20],
}, ensure_ascii=False)
def _control_display(args):
"""大屏显示控制:切页 / 弹通知 / 媒体播放暂停(经 MQTT 广播到大屏)"""
from .mqtt import hub
action = (args.get("action") or "").strip()
target = (args.get("target") or "").strip()
if action not in ("switch_page", "alert", "media_play", "media_pause"):
return json.dumps({"error": f"不支持的 action: {action}"}, ensure_ascii=False)
params = {"target": target} if target else {}
ok = hub.publish_command(action, params).get("published", False)
return json.dumps({"ok": ok, "action": action, "target": target}, ensure_ascii=False)
def _get_time(args):
"""当前日期时间"""
return json.dumps({"datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}, ensure_ascii=False)
# ── 注册表 ──────────────────────────────────────────────────────────────
TOOLS = {
"get_park_overview": {
"fn": _get_park_overview,
"description": "获取园区实时运营概览:在园项目数、累计孵化企业、带动就业、营收(今日/累计)、设备在线率、在园人数、能耗等。回答园区数据类问题时使用。",
"parameters": {"type": "object", "properties": {}},
},
"query_companies": {
"fn": _query_companies,
"description": "查询园区入驻企业名录,支持按企业名关键词过滤。回答'有哪些企业/某企业是否入驻'时使用。",
"parameters": {
"type": "object",
"properties": {"keyword": {"type": "string", "description": "企业名关键词,可为空字符串"}},
},
},
"control_display": {
"fn": _control_display,
"description": "控制大屏显示:switch_page 切换页面、alert 弹通知、media_play/media_pause 控制媒体播放。用户要求控制大屏时使用。",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["switch_page", "alert", "media_play", "media_pause"]},
"target": {"type": "string", "description": "目标:页面路径(如 /、/twin、/ai、/voice)或通知文本"},
},
},
},
"get_time": {
"fn": _get_time,
"description": "获取当前日期时间。用户问'现在几点/今天几号'时使用。",
"parameters": {"type": "object", "properties": {}},
},
}
def tool_schemas():
"""给 LLM 的 tools 定义(OpenAI function calling 格式)"""
return [
{
"type": "function",
"name": name,
"description": t["description"],
"parameters": t["parameters"],
}
for name, t in TOOLS.items()
]
def exec_tool(name: str, args: dict):
"""执行工具 → 返回给 LLM 的字符串结果(JSON)"""
t = TOOLS.get(name)
if not t:
log.warning("tools: 未知工具 %s", name)
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])
return result
except Exception as e: # noqa: BLE001
log.error("tools: %s 执行失败 %s", name, e, exc_info=True)
return json.dumps({"error": f"{name} 执行失败: {e}"}, ensure_ascii=False)