# NLU 接入设计方案 > 状态:**已确认,进入实现阶段** > 关联文档:`bert_integration_analysis.md`、`architecture_overview.html` --- ## 第一部分:概念解释 — 两套术语怎么对应 ### 1.1 Canvas 设计里用的词 在 `architecture_overview.html` 的技术流程视图里,BERT NLU 节点描述为: ``` BERT NLU 意图识别 输出 domain / intent / slot / confidence 置信度 ``` 这是一套**面向业务语义**的描述,每个词的含义: | 词 | 含义 | 例子 | |---|---|---| | `domain` | 所属业务域,意图的分组 | `machine_control`、`equipment_knowledge`、`smalltalk` | | `intent` | 用户想做什么,域内的细分动作 | `wirecut_set_speed`、`wirecut_start_run`、`query_alarm` | | `slot` | 动作的具体参数,从句子里提取的关键值 | `speed=80`、`voltage=90`、`axis=X` | | `confidence` | 模型对这次识别结果的置信程度,0~1 | `0.94`(高)、`0.61`(中)、`0.32`(低)| Canvas 的路由逻辑就是:拿到这四个值之后,判断 `confidence ≥ 阈值 AND domain = machine_control` → 走工具调用路径。 --- ### 1.2 intelligent_cabin NLU 服务里用的词 `intelligent_cabin` 后端分两层输出: #### 层 A:JointBertNLU 的原始输出(`joint_nlu.py`) ```python @dataclass class JointNluResult: intent_id: str | None # 识别出的意图 id,如 "wirecut_set_speed" intent_score: float # softmax 后的概率,就是置信度,0~1 candidates: list[JointCandidate] # Top-K 候选意图及其概率 slots: dict[str, Any] # 从句子里提取的 slot,如 {"speed": 80} slot_items: list[JointSlot] # slot 在原文中的精确位置和得分 ``` 这里的 `intent_id + intent_score + slots` 对应 Canvas 描述里的 `intent + confidence + slot`。 `domain` 不是模型直接输出的,而是根据 `intent_id` 在 `domain.yml` 里查到的(`wirecut_set_speed` → domain `machine_control`)。 #### 层 B:Router / FusionGrader 的决策输出(`router.py`) ```python # MultiStageIntentMatcher._build_fusion_stage() 里 decision = "execute" | "clarify" | "route_to_cloud" | "reject" ``` 这是在原始 NLU 结果基础上做的**二次路由判断**,加入了: - 置信度是否够高(`score ≥ execute_score_threshold=0.55`) - 头两名候选的分差是否足够大(`margin ≥ execute_margin_threshold=0.18`) - 是否有多义性(ambiguous) 它告诉上层"这个识别结果能不能直接执行",而不只是"模型认为是哪个意图"。 --- ### 1.3 两套词汇的完整映射关系 ``` Canvas 的描述 ←→ intelligent_cabin 的实际字段 ───────────────────────────────────────────────────────────── domain ←→ intent_def.domain (从 domain.yml 查) intent ←→ JointNluResult.intent_id slot ←→ JointNluResult.slots (dict) confidence ←→ JointNluResult.intent_score (0~1) (以上是 NLU 层的概念对应) Canvas 的路由逻辑 ←→ Router 层的 decision 字段 "高置信 + 设备控制域" ←→ decision="execute" AND domain="machine_control" "知识域/低置信兜底" ←→ decision="route_to_cloud" 或 domain="equipment_knowledge" "smalltalk" ←→ decision="reject" 或 social_router 处理 ``` > **关键点**:Canvas 当前用 Mock NLU(`src/lib/nlu/mock.ts`),它直接输出 `domain + intent + confidence + routeHint`。 > 接入真实 NLU 后,两个项目**原生打通,当成一个项目**,不做兼容适配层,接口可以随时改。 --- ## 第二部分:Canvas ↔ NLU 服务的统一路由方案 ### 2.1 两个项目合并为一个项目(已确认) `intelligent_cabin` 不作为独立服务做适配,而是直接作为 `ai-canvas` 的后端子模块。 原来 `src/lib/nlu/mock.ts` 的格式**可以废弃**,不需要保持向后兼容。 ### 2.2 真实 NLU 服务的 HTTP 响应 调用 `POST /api/v1/agent/chat` 后,服务返回 `ChatResponse`,与路由相关的核心字段: ```json { "session_id": "xxx", "intent": "wirecut_set_speed", "domain": "machine_control", "decision": "execute", "status": "completed", "filled_slots": { "speed": 80 }, "routing_debug": { "confidence_grade": "high", "stages": [ { "stage": "classifier", "score": 0.87, "candidates": [...] }, { "stage": "fusion", "metadata": { "decision": "execute", "grade": "high", "classifier_signal": 0.87, "classifier_margin": 0.34 } } ] } } ``` > `domain` 字段需要在 intelligent_cabin 的 `ChatResponse` schema 里加上(从 `IntentDefinition.domain` 填充),改动极小。 ### 2.3 Canvas 侧的 NluResult 类型(替换 mock.ts) ```typescript // src/lib/nlu/types.ts (新建,替换 mock.ts 里的类型) export type RouteHint = | "tool_call" // decision=execute + machine_control 域 | "knowledge_query" // decision=route_to_cloud 或 equipment_knowledge 域 | "smalltalk" // decision=reject | "fallback"; export type NluResult = { modelVersion: string; domain: string; // 后端直接返回 intent: string; // intent_id confidence: number; // classifier stage score slots: Record; // filled_slots routeHint: RouteHint; decisionGrade: "high" | "medium" | "low"; rawDecision: "execute" | "clarify" | "route_to_cloud" | "reject"; }; export function mapDecisionToRouteHint( decision: string, domain: string ): RouteHint { if (decision === "execute") { if (domain === "machine_control") return "tool_call"; if (domain === "equipment_knowledge") return "knowledge_query"; return "tool_call"; } if (decision === "route_to_cloud") return "knowledge_query"; if (decision === "reject") return "smalltalk"; return "fallback"; // clarify 等待补槽,暂用 fallback } ``` ### 2.4 confidence 读取位置 `routing_debug.stages` 里找 `stage === "classifier"` 的记录,取其 `score` 字段。 这是 BERT 分类器 softmax 后的原始概率,等价于 Canvas 描述里的 `confidence`。 --- ## 第三部分:语音处理前置拦截链路(已定稿) ### 3.1 设计原则 - **所有当前可见 UI 组件的按钮文本都参与语音匹配**,命中则直接触发点击事件,不调 BERT - 调机流程(GuidedProcedure)当前**不在实现范围**,相关 1c 拦截逻辑暂不涉及 - BERT 报错**直接抛出**,不降级,不用 Mock 兜底 ### 3.2 四阶段处理链路 ``` ASR 文本输入 │ ▼ [阶段 0] 停止词检测 ← 静态词表,构建时嵌入 ├── 命中 cancel_words → 生成 stop_action,链路终止 └── 未命中 ↓ │ ▼ [阶段 1] UI 可见元素语音点击匹配 ← 纯前端规则,<1ms ├── 1a. session.status=waiting_confirmation 时的 affirm/deny(最高优先级) └── 1b. 当前可见 Artifact 按钮 text 匹配 │ 命中任意 → 生成 ActionEvent,走 Canvas 状态机,链路终止 │ │ 全部未命中 ▼ [阶段 1.5] waiting_slot + inform 检测 ├── session.status=waiting_slot && 输入为数字/数值类 └── 命中 → 调 fill_slots 接口,链路终止 │ ▼ [阶段 2] BERT NLU(intelligent_cabin /api/v1/agent/chat) ├── 报错 → 直接抛出,不降级 ├── decision=execute → 工具调用层(DBus)→ Artifact ├── decision=clarify → 渲染补槽卡,等待 waiting_slot ├── decision=route_to_cloud → LLM + 知识库 → KnowledgeLessonArtifact └── decision=reject → LLM 直接作答,不写 ArtifactStore ``` ### 3.3 阶段 1 内部优先级说明 ```typescript // 优先级从高到低(1c 调机 textAliases 暂不实现) const PRIORITY_ORDER = [ "waiting_confirmation_affirm_deny", // 1a "visible_artifact_button", // 1b ]; ``` **为什么 1a 最高**:当高风险操作(如"开始加工")弹出确认卡时, 操作员说"确认"应当触发确认动作,而不是响应画布上同时存在的其他按钮。 状态(`session.status`)决定优先级,而非文本本身。 ### 3.4 阶段 1 匹配实现(pipeline.ts 骨架) ```typescript // src/lib/nlu/pipeline.ts import { AFFIRM_WORDS, CANCEL_WORDS } from "./voice-aliases.gen"; // 构建时生成 type ActionEvent = { type: "voice_click_event" | "slot_fill_event" | "stop_action"; actionId?: string; artifactId?: string; sourceText: string; }; export async function processVoiceInput( asrText: string, session: CanvasSession ): Promise { // 阶段 0:停止词 const norm = normalizeVoice(asrText); if (CANCEL_WORDS.some(w => norm.includes(w))) { return { type: "stop_action", sourceText: asrText }; } // 阶段 1a:waiting_confirmation 状态的 affirm/deny if (session.status === "waiting_confirmation") { if (AFFIRM_WORDS.some(w => norm.includes(w))) { return { type: "voice_click_event", actionId: "confirm", sourceText: asrText }; } if (CANCEL_WORDS.some(w => norm.includes(w))) { return { type: "voice_click_event", actionId: "cancel", sourceText: asrText }; } } // 阶段 1b:当前 Artifact 按钮匹配 const voiceClick = matchVoiceToAction(asrText, session.visibleActions); if (voiceClick) { return { type: "voice_click_event", actionId: voiceClick.actionId, artifactId: voiceClick.artifactId, sourceText: asrText, }; } // 阶段 1.5:waiting_slot + 数值输入 if (session.status === "waiting_slot" && isNumericInput(asrText)) { return { type: "slot_fill_event", sourceText: asrText }; } // 阶段 2:BERT NLU(报错直接抛出) const response = await callNluService(asrText, session.sessionId); return adaptNluResponse(response); } ``` ### 3.5 voice_aliases 配置(已确认:静态构建) **词表位置**:`intelligent_cabin/config/voice_aliases.yml`(和 `dialog_acts.yml` 放在一起) ```yaml # voice_aliases.yml affirm_words: ["确认", "好的", "执行", "是", "对", "继续", "好", "ok"] cancel_words: ["取消", "算了", "不要", "不用", "停止", "停"] # 工控设备别名(按 intent_id 分组,用于阶段 1b 的 Artifact voiceActions) intent_aliases: wirecut_start_run: ["开始", "启动", "加工", "跑起来"] wirecut_stop_run: ["停", "停机", "急停", "停止"] wirecut_home_all: ["回零", "归零", "回原点"] wirecut_pause_run: ["暂停", "变频暂停"] ``` **构建时生成**:构建脚本读取 yml → 生成 `src/lib/nlu/voice-aliases.gen.ts`, TypeScript 侧直接 import,不需要运行时 HTTP 请求。 --- ## 第四部分:下一步实现计划 | 步骤 | 位置 | 内容 | |---|---|---| | 1 | `intelligent_cabin` Python 侧 | `ChatResponse` schema 加 `domain` 字段,`agent_service.py` 填充 | | 2 | `intelligent_cabin/config/` | 创建 `voice_aliases.yml`,补充工控别名 | | 3 | `src/lib/nlu/` | 新建 `types.ts`,废弃 `mock.ts` 中旧类型 | | 4 | `src/lib/nlu/` | 新建 `pipeline.ts`,实现四阶段处理链路 | | 5 | `src/lib/artifacts/types.ts` | 各 Artifact 类型上加 `voiceActions` 字段 | | 6 | 构建配置 | 添加 yml → ts 生成脚本(`voice-aliases.gen.ts`) |