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:
@@ -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)
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,31 @@
|
||||
# 昆明大学生创业园(OPC 园区)
|
||||
|
||||
## 园区简介
|
||||
昆明大学生创业园是云南省首家政府主办的大学生创业孵化园区,为大学生创业者提供"空间+孵化+融资+政策+资源+AI赋能+综合服务"七位一体的一站式创业服务。
|
||||
|
||||
## 空间服务
|
||||
园区提供办公工位、独立办公室、共享会议室、路演大厅、直播基地等创业空间,水电网络全配套,拎包入驻。
|
||||
|
||||
## 孵化服务
|
||||
园区为入驻企业提供创业辅导、工商财税、知识产权、法律咨询等孵化服务;配备创业导师团队,提供一对一辅导;定期举办创业大赛、项目路演、融资对接会。
|
||||
|
||||
## 融资服务
|
||||
园区对接银行、投资机构、政府引导基金,为入驻企业提供贷款贴息、天使投资、股权融资对接等金融服务。
|
||||
|
||||
## 政策支持
|
||||
入驻企业可享受大学生创业补贴、场地租金减免、税收优惠、一次性创业补贴、社保补贴等政府政策;园区协助申报各类科技、人才项目。
|
||||
|
||||
## AI 赋能
|
||||
园区建设 OPC 智能运营中心(数字运营平台),通过 PineSound 智能语音助手、数据大屏、YOLO 视觉识别、手势控制等 AI 能力,为企业提供智能咨询、数据洞察与展厅互动服务。
|
||||
|
||||
## 入驻流程
|
||||
1. 提交入驻申请(线上/线下);2. 项目评审;3. 签订入驻协议;4. 办理工商注册;5. 入驻孵化。
|
||||
|
||||
## 园区数据口径
|
||||
园区累计孵化企业 200 余家,目前在园企业 150 余家,带动就业 2000 余人,累计营收超 2 亿元,高新技术企业 30 余家,省级以上重点研发项目 18 项,创业大赛奖项 47 项。
|
||||
(实时运营数据请调用 get_park_overview 工具获取。)
|
||||
|
||||
## 常见问题
|
||||
- 问:如何入驻园区?答:见"入驻流程",可直接联系园区服务台或通过 PineSound 语音助手咨询。
|
||||
- 问:园区提供哪些服务?答:见"孵化服务"与"七位一体",涵盖空间、孵化、融资、政策、资源、AI 赋能、综合服务。
|
||||
- 问:有什么政策支持?答:见"政策支持",包括补贴、租金减免、税收优惠等,具体以最新政策为准。
|
||||
@@ -16,10 +16,40 @@ import './voice-original.css';
|
||||
import './voice-overrides.css';
|
||||
|
||||
const VOICE_URL = window.__DPM_VOICE_WS__ || `ws://${window.location.hostname}:8765/v1/realtime`;
|
||||
const DEFAULT_INSTRUCTIONS = '你是园区智能助手,请用简洁专业的中文回答,不超过三句话。';
|
||||
const DEFAULT_INSTRUCTIONS = '你是 PineSound 园区智能语音助手。可用工具:get_park_overview(园区实时数据)、query_companies(企业名录)、control_display(大屏控制)、get_time(时间)。涉及园区数据/企业/大屏控制时务必调用工具获取准确信息。请用简洁专业的中文回答,不超过三句话。';
|
||||
const DEFAULT_VOICE = 'Cherry';
|
||||
const DEFAULT_GATE_DB = -50;
|
||||
|
||||
// 工具定义(与后端 app/tools.py 的 tool_schemas 一致,经 session.update 传给 LLM)
|
||||
const TOOL_DEFS = [
|
||||
{
|
||||
type: 'function', name: 'get_park_overview',
|
||||
description: '获取园区实时运营概览:在园项目数、累计孵化企业、带动就业、营收(今日/累计)、设备在线率、在园人数、能耗等。回答园区数据类问题时使用。',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
{
|
||||
type: 'function', name: 'query_companies',
|
||||
description: '查询园区入驻企业名录,支持按企业名关键词过滤。回答"有哪些企业/某企业是否入驻"时使用。',
|
||||
parameters: { type: 'object', properties: { keyword: { type: 'string', description: '企业名关键词,可为空字符串' } } },
|
||||
},
|
||||
{
|
||||
type: 'function', name: '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)或通知文本' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function', name: 'get_time',
|
||||
description: '获取当前日期时间。用户问"现在几点/今天几号"时使用。',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
];
|
||||
|
||||
const STATE_VIEWS = {
|
||||
idle: '点击开始',
|
||||
connecting: '思考中',
|
||||
@@ -121,6 +151,33 @@ export default function VoiceAssistant() {
|
||||
console.warn('[vision] 自动问候已发送:', GREETING_TEXT);
|
||||
}, []);
|
||||
|
||||
// ── 工具调用执行器:LLM 请求工具 → 转发后端 /api/tools/exec → 结果回传 ──
|
||||
const onToolCall = useCallback(async (e) => {
|
||||
const { name, arguments: argsStr, callId } = e?.detail ?? {};
|
||||
if (!name || !callId) return;
|
||||
let args = {};
|
||||
try { args = JSON.parse(argsStr || '{}'); } catch { /* 忽略非法参数 */ }
|
||||
console.warn(`[tools] LLM 调用工具: ${name}`, args);
|
||||
pushMessage('assistant', `🔧 调用工具:${name}`, false);
|
||||
let output;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/tools/exec`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, args }),
|
||||
});
|
||||
const data = await res.json();
|
||||
output = data?.result ?? JSON.stringify(data);
|
||||
} catch (err) {
|
||||
output = JSON.stringify({ error: String(err) });
|
||||
}
|
||||
const c = clientRef.current;
|
||||
if (c) {
|
||||
c.sendToolOutput(callId, output);
|
||||
c.requestResponse();
|
||||
}
|
||||
}, [pushMessage]);
|
||||
|
||||
// ── 右下角气泡渲染(复刻原版 ui/chat.js)───────────────────────────────
|
||||
// 流程:新气泡插入 → rAF 加 `.in`(淡入)→ 4s 后加 `.out`(淡出)→ 400ms 移除。
|
||||
// 持续更新的气泡(partial 逐字流)续期 4s;栈上限 8 条,最旧优先淡出,
|
||||
@@ -257,11 +314,21 @@ export default function VoiceAssistant() {
|
||||
if (!audioCtxRef.current) audioCtxRef.current = new AudioContext();
|
||||
if (audioCtxRef.current.state === 'suspended') await audioCtxRef.current.resume();
|
||||
|
||||
// 知识库:拉取园区知识摘要拼进 instructions(静态知识一次注入;失败不影响对话)
|
||||
let instructions = DEFAULT_INSTRUCTIONS;
|
||||
try {
|
||||
const kbRes = await fetch(`${API_BASE}/api/kb/brief`, { cache: 'no-store' });
|
||||
const kbData = await kbRes.json();
|
||||
if (kbData?.ok && kbData.brief) {
|
||||
instructions = `${DEFAULT_INSTRUCTIONS}\n\n【园区知识库】\n${kbData.brief}`;
|
||||
}
|
||||
} catch { /* 知识库不可用不影响对话 */ }
|
||||
|
||||
const client = new S2sWsRealtimeClient({
|
||||
directUrl: VOICE_URL,
|
||||
voice: DEFAULT_VOICE,
|
||||
instructions: DEFAULT_INSTRUCTIONS,
|
||||
tools: [],
|
||||
instructions,
|
||||
tools: TOOL_DEFS,
|
||||
noiseGate: { enabled: true, thresholdDb: DEFAULT_GATE_DB },
|
||||
audioContext: audioCtxRef.current,
|
||||
micStream: stream,
|
||||
@@ -285,6 +352,7 @@ export default function VoiceAssistant() {
|
||||
}
|
||||
});
|
||||
client.addEventListener('input-level', (e) => paintInputLevel(e.detail?.rms));
|
||||
client.addEventListener('toolcall', (e) => void onToolCall(e));
|
||||
client.addEventListener('response-finished', () => {
|
||||
setState('listening');
|
||||
// 自动问候的回复播放完毕 → 启动"等待用户回答"倒计时;无回答则自动挂断
|
||||
@@ -308,7 +376,7 @@ export default function VoiceAssistant() {
|
||||
setErrorMsg(err?.message || String(err));
|
||||
setState('error');
|
||||
}
|
||||
}, [pushMessage, paintInputLevel, sendGreeting]);
|
||||
}, [pushMessage, paintInputLevel, sendGreeting, onToolCall]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
// 清理自动问候相关状态/倒计时
|
||||
|
||||
Reference in New Issue
Block a user