4 Commits
Author SHA1 Message Date
weichen 3674dc4bfd Add deterministic slot constraints with labels and questions, and the planner-facing capability catalog.
ci / vet-and-test (push) Failing after 2s
ci / docker-checkout-hostgateway (push) Successful in 2s
2026-09-29 16:32:50 +00:00
weichen 52ce798ebf Record the orchestration capability-catalog design: intent specs as the planner-facing catalog.
ci / vet-and-test (push) Failing after 2s
ci / docker-checkout-hostgateway (push) Successful in 2s
2026-09-29 16:24:59 +00:00
weichen 452dddcea3 Record the scope boundaries for compound instructions, slot states, and multi-turn dialog.
ci / vet-and-test (push) Failing after 2s
ci / docker-checkout-hostgateway (push) Successful in 3s
2026-09-29 16:17:03 +00:00
weichen ae2c80bd4c Record the cross-repo integration test report for the Squad gate and Native decorator.
ci / vet-and-test (push) Failing after 3s
ci / docker-checkout-hostgateway (push) Successful in 3s
2026-09-29 16:06:13 +00:00
11 changed files with 790 additions and 33 deletions
+6 -1
View File
@@ -102,19 +102,24 @@ Go 与 JSON 共用同一结构;JSON overlay 文档形如:
| `id` | 全局唯一;首段必须等于 `domain`,各段 `^[a-z][a-z0-9_-]{0,63}$`,可多级(`demo.run.status_query`) |
| `version` | ≥1;overlay 覆盖同 id 时必须递增 |
| `matcher` | 见上文计分规则;至少一个正向条件;`case_sensitive` 只作用于词表,pattern 用 `(?i)` |
| `slots` | `group` 默认 1;`required` 缺失记录到 `MissingRequiredSlots` |
| `slots` | `group` 默认 1;`required` 未捕获记录到 `MissingRequiredSlots`;`label`/`question` 供 HITL 提问与目录展示;`enum`/`format`/`normalize`(trim\|lower\|upper)/`validator`(`RegisterSlotValidator` 注册)构成确定性校验链,非法值进 `Top.InvalidSlots`(与"缺失"区分) |
| `confidence` | 缺省 `base=0.7 / pattern_bonus=0.2 / threshold=0.6` |
| `risk` / `requires_confirm` / `handler` / `metadata` | 模块不解释,透传给宿主做授权、确认与路由 |
| `disabled` | 定义但停用(灰度暂存) |
合并语义:Go 内置为基线;目录 root 内 `*.json` 按文件名字典序、roots 按给定顺序应用;同 id 覆盖需版本递增;`disable` 按 id 禁用(保护域整批失败);未知字段拒绝并定位到文件。数量上限见 `Options`(spec 总数、单 spec 正则/槽位数、输入长度),防止配置膨胀。
## 能力目录(Catalog)
`Snapshot.Catalog()/CatalogMarkdown()` 把启用的非守卫 spec 渲染为规划者/领导 Agent 可消费的声明式目录:有哪些可直接调用的能力、必填槽位与提问建议、风险与确认要求。用于"目录驱动的编排"——规划者据此把子任务短路给系统功能、按槽位 question 生成定向 HITL(Agent-Squad 已按此接入,见其 `squadintent` 与 `docs/records`)。
## 诊断(intentctl)
```bash
go run ./cmd/intentctl lint -root configs/intents # 校验 + 冲突提示
go run ./cmd/intentctl explain -root configs/intents "查询任务进度" # 分支/候选/槽位/裁决轨迹
go run ./cmd/intentctl eval -root configs/intents evals/corpus.json
go run ./cmd/intentctl catalog -root configs/intents # 能力目录(注入规划者提示词)
```
评测集格式见 `intent/testdata/corpus.json`(`name / input / expect_branch / expect_top / expect_slots / expect_missing_slots`)。评测集放在配置目录之外。注意:宿主以 Go 内置注册守卫时,守卫用例只能在宿主自己的测试(或 conformance.Run)中验证;intentctl 只加载 JSON 配置。
+33 -1
View File
@@ -33,6 +33,8 @@ func run(args []string) error {
err = cmdExplain(args[1:])
case "eval":
err = cmdEval(args[1:])
case "catalog":
err = cmdCatalog(args[1:])
default:
err = usage()
}
@@ -40,7 +42,7 @@ func run(args []string) error {
}
func usage() error {
return fmt.Errorf("用法:intentctl <lint|explain|eval> ...\n lint -root <dir> [-protected a,b] [-guard a,b]\n explain -root <dir> [-protected a,b] [-guard a,b] <input>\n eval -root <dir> [-protected a,b] [-guard a,b] <corpus.json>")
return fmt.Errorf("用法:intentctl <lint|explain|eval|catalog> ...\n lint -root <dir> [-protected a,b] [-guard a,b]\n explain -root <dir> [-protected a,b] [-guard a,b] <input>\n eval -root <dir> [-protected a,b] [-guard a,b] <corpus.json>\n catalog -root <dir> [-protected a,b] [-guard a,b] 输出能力目录(注入规划者/领导提示词用)")
}
type commonOpts struct {
@@ -149,6 +151,13 @@ func cmdExplain(args []string) error {
if len(res.Top.MissingRequiredSlots) > 0 {
fmt.Printf(" 缺失必填槽位:%s\n", strings.Join(res.Top.MissingRequiredSlots, ","))
}
if len(res.Top.InvalidSlots) > 0 {
fmt.Printf(" 非法槽位:")
for _, k := range sortedKeys(res.Top.InvalidSlots) {
fmt.Printf("%s(%s) ", k, res.Top.InvalidSlots[k])
}
fmt.Println()
}
}
if len(res.Candidates) > 0 {
fmt.Printf("候选(%d):\n", len(res.Candidates))
@@ -180,6 +189,29 @@ func printStage(name string, scored []intent.ScoredSpec, rejected []intent.Rejec
}
}
func cmdCatalog(args []string) error {
fs := flag.NewFlagSet("catalog", flag.ContinueOnError)
c := parseCommon(fs)
if err := fs.Parse(args); err != nil {
return err
}
reg, err := buildRegistry(c)
if err != nil {
return fmt.Errorf("配置校验失败:%w", err)
}
fmt.Print(reg.Snapshot().CatalogMarkdown())
return nil
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// evalCase 与 intent 包评测集(testdata/corpus.json)同格式。
type evalCase struct {
Name string `json:"name"`
@@ -0,0 +1,99 @@
# 编排能力目录:意图识别与规划者/小队领导的配合(设计思考)
状态:待审(设计结论,未实施);owner:weichen。
触发:用户对 Squad/Native 分工的模型与核心疑问——理想状态下规划者/领导把任务拆解为单一意图步骤下发给 Native 执行;领导可通过 HITL 收集信息后生成单一意图指令;**但未接入意图识别时,领导如何知道要通过 HITL 收集哪些信息?**
关联:[2026-09-29-scope-boundaries-compound-slots-dialog.md](2026-09-29-scope-boundaries-compound-slots-dialog.md)、[2026-09-29-decoupling-and-llm-layering.md](2026-09-29-decoupling-and-llm-layering.md)。
## 一、先校正分工事实(对齐用户模型)
准确表述是:"**机制在 Squad,智能在 Native 之上**":
| 能力 | 机制(脚手架) | 智能(谁在思考) |
|---|---|---|
| 任务拆解 | Squad:Planner 接口、蓝图、flowDriver | 跑在 Native 上的规划者(LLMPlanner)或领导 Agent(leader 委派循环) |
| 任务分配 | Squad:编制、BackendResolver、委派协议 | 领导 Agent(delegation 决策)或规划器 |
| 任务执行 | Native:循环、工具、工作区、交付物落盘、失败分类 | 执行 Agent |
| 结果校验 | Squad:Checker/Evaluator、质检 HITL、黑板 | 质检 Agent / 规则检查器 |
所以"Squad 是组织功能模组"成立——但拆解/分配/校验的**机制**确实在 Squad;被组织的"智能体"全部跑在 Native 上。这个校正很重要:它决定了意图知识应该注入的位置是 **Squad 的规划入口(PlanInput / 领导上下文)**,而不是 Native 内部。
## 二、"意图识别与 Native 配合"的准确形态
用户理想:被分配的 Native 角色只收到**单一意图**的输入。正确,且落地形态是——下发给执行的每一步只有两种:
1. **系统功能步**:意图已识别、槽位已填齐 → 直接调 handler(不打模型、不进 agent 循环);
2. **Agent 步**:单一明确目标的简报(不含可被系统功能短路的部分),上下文完整。
注意"单一意图"对 agent 步意味着"单一明确目标",不必然是系统功能意图(调研/撰写类步骤天然开放)。
## 三、核心问题:领导如何知道要问什么?
### 现状(未接入目录)
规划者/领导只看得见**角色目录**(RoleRegistry:谁会干什么,Keywords/Responsibilities)与蓝图关键词。它**看不见系统有哪些可直接调用的功能**,因此:
- 拆解时无法把子任务短路给系统功能(明明有 `taskrun.query_status`,却派了个 agent 去"想办法查");
- HITL 提问只能自由发挥(leader 的 escalate 是自由文本):要么问不到点(缺的字段没收集),要么问错(问了不存在的字段);
- 收集到的信息没有契约校验(字段真伪/枚举合法与否无人把关)。
### 答案:意图注册表就是能力目录(Capability Catalog)
IntentSpec 天然携带目录所需的全部声明:
```text
id / description → 有哪些系统能力(人话,供 LLM 匹配)
slots[].required → 每项能力缺什么信息(HITL 提问清单的来源)
slots 的约束/标签 → 问什么、怎么问、什么算合法答案(E2/E4 扩展)
risk / requires_confirm → 哪些能力执行前必须确认(对齐 HITL 确认门)
handler / metadata → 短路调用方式与路由提示
```
对称性很有说服力:**角色目录回答"谁能干活",意图目录回答"有哪些系统能力不必干活(直接调用)"**。今天规划者只有前者;补上后者,拆解才是完备的。
### 三触点架构(意图在 Squad×Native 栈中的完整位置)
```
用户目标
│
▼
[触点 1] 入口闸门(Squad gate,已建)
│ 整句确定性识别:守卫拒绝 / 整句命中系统功能 → 短路 handler / 其余放行
▼
规划者 / 领导 Agent(跑在 Native 上)
│ 注入[触点 2] 能力目录(本篇新增的关键件):
│ · 拆解时把子任务匹配到系统功能(不派 agent)
│ · 按 slots[].required 生成定向 HITL 提问(不再自由发挥)
│ · 产出计划后经确定性校验:意图存在?必填槽齐?枚举合法?——fail-closed 打回
▼
步骤下发
├─ 系统功能步:handler 直调(槽位已填齐,无 agent)
└─ Agent 步:单一目标简报 → Native 执行
│
▼
[触点 3] 执行防线(Native intentgate 装饰器,已建)
步骤 prompt 兜底拦截:守卫命中 / 意外命中系统功能 → 合成结果,模型零调用
```
触点 1 与 3 已建成并验证;**缺的是触点 2**。它同时解决上一轮"复合指令"的执行侧:整句可短路则触点 1 直接办;混合指令由领导携目录拆解;领导拆解结果再被确定性校验——LLM 负责语义匹配,规则负责契约把关,各居其位。
### 防"领导幻觉"的闭环
领导(LLM)可能提议不存在的功能或漏填槽位。确定性校验(用本模块的 SpecByID + 槽位约束)构成闭环:不通过 → 打回重拆或转 HITL 补问,绝不带着残缺契约执行。这与 Squad"未知 backend fail-closed"的既有边界同构。
## 四、落地差距清单
| # | 差距 | 归属 | 性质 |
|---|---|---|---|
| G1 | 目录渲染:把 Snapshot 渲染为规划者/领导可消费的目录文本(Markdown/JSON,含 slot 标签与提问建议) | agent-intent(建议 E4:`Catalog()` 助手)或宿主自渲染 | 小 |
| G2 | SlotSpec 增加人类可读 `label` / `question`(HITL 提问生成直接可用);配合 E2 的 enum/range 约束做答案校验 | agent-intent(E2 扩展的一部分) | 小 |
| G3 | PlanInput / 领导 prompt 注入目录;计划输出经确定性校验(意图存在、必填槽齐、确认门对齐 risk) | **Agent-Squad 演进项** | 中(规划器/leader 提示与校验逻辑) |
| G4 | 系统功能步的执行通道:计划步骤 → handler 调用(不经 BackendResolver/agent) | Agent-Squad 演进项 | 中 |
| G5 | 领导 HITL 提问模板:由 slot question 生成澄清请求(复用既有 HitlClarification/HitlEscalation 机制) | Agent-Squad 演进项 | 小 |
agent-intent 侧只需 E2/E4 两个小扩展(保持机制化、确定性);重头在 Squad 侧的 G3–G5,属"目录驱动的编排"演进。
## 五、结论
- 用户模型成立,校正后更准确:机制在 Squad、智能在 Native 之上;意图知识的注入点是 Squad 的规划入口,不是 Native 内部。
- "领导如何知道问什么"的答案:**意图注册表即能力目录**——slots 声明了缺什么、risk 声明了要不要确认、description 声明了有什么;当前缺的是目录暴露(G1/G2)与消费侧(G3–G5)。
- 三触点齐备后:整句短路(触点1)、目录化拆解 + 定向 HITL + 契约校验(触点2)、执行兜底(触点3)——复合指令、槽位收集、单一意图下发在此架构下闭环,且每一触点都保持"规则定契约、LLM 做匹配"的分工。
@@ -0,0 +1,72 @@
# 复合指令、槽位与多轮对话的职责边界(识别器 / 会话层 / 编排层)
状态:待审(设计结论,未实施);owner:weichen。
触发问题:复合串并行多意图指令、槽位模糊/缺失/错误/复杂、多轮描述与意图切换/叠加——哪些进本模块,哪些由其他层维护。
关联:[2026-09-29-decoupling-and-llm-layering.md](2026-09-29-decoupling-and-llm-layering.md)、[2026-09-29-squad-native-integration-report.md](2026-09-29-squad-native-integration-report.md)。
## 总原则:三层职责
```
L4 对话管理层(有状态):会话上下文、DST、多轮澄清流程、意图切换/叠加决策、超时清理
—— 宿主会话层。Agent-System 的 session/TaskState/intentflow 是现成先例;
Squad / Native 直宿主需要时再抽独立 Dialog-Session 模块(判据见文末)。
L3 任务编排层(有状态):拆解、串行/并行依赖、系统功能×通用智能体混合调度、执行与故障
—— Agent-Squad(Planner/蓝图/leader 委派)本来就是这一层。
L2 意图识别层(无状态,本模块):单句(或单段)→ 分支/意图/槽位;提供分句、挂起预期、
L2 分类器等机制接口;同输入同快照同结果。
```
经验证据:Agent-System 中 `internal/intent`(识别器)无状态逐句;多意图计划(intentflow 的 HandleTurn/DispatchEligible/RulesCanDecide 门控)与对话状态机(TaskState.UpdateFromTurn、PendingExpectation)全部位于 session/coordinator 层。**即便在意图层最重的 Agent-System 里,识别与会话管理也是分开的**——本模块沿用同一边界。
## 一、复合指令("先 A,然后 B,接着 C,最后 D,并且要满足 E")
**本模块做的**:分句(`Segmenter`)→ 逐段 `Recognize` → 每段得到独立的(分支/意图/槽位/候选)。"这段是系统功能还是自由任务"正是逐段识别的直接输出:`Matched + handler` = 系统特有功能;`Fallback` = 交给通用智能体。模块可补一个组合助手(见建议 E1),本质仍是纯函数。
**不进本模块、归编排层的**:段间串行/并行/依赖决策、执行顺序、失败传播、系统功能段与 agent 段的混合调度。这些是任务规划的状态与责任,天然属于 Agent-Squad(Planner/蓝图/leader)。Squad 侧的未来增强形态:分段结果 → 系统功能段转 handler/蓝图步骤、自由段聚合为自主 goal,由 Planner 统一定序。这是 Squad 的演进项,不是 agent-intent 的内部功能。
**为什么**:串并行判断需要的是规划语义(依赖、资源、故障责任),不是识别语义;把它塞进识别器会让无状态确定性承诺破裂(识别结果依赖执行状态)。
## 二、槽位四态的归属
| 槽位状态 | 归属 | 机制 |
|---|---|---|
| 缺失 | **模块已支持** | `MissingRequiredSlots` + `ExpectationProvider`:宿主据缺失发起澄清,下一轮 expectation 命中即续上。澄清对话流程本身属宿主 |
| 表面模糊(大小写、别名、格式不一致) | **模块可确定性扩展(建议 E2)** | SlotSpec 增加归一化/约束(enum / range / format / Go 注册的 validator、normalizer),编译期确定性不变 |
| 语义模糊(指代"它"、"上次的任务"、相对时间"下周") | 宿主 LLM 层或功能方 | 需要会话上下文与模型判断(LLM 分层文档的异步重层)或系统功能自身 API 的解析;模块只负责抽出原文 span |
| 用户输入错误 | 模块做确定性校验(E2 的一部分);业务真伪的权威在系统功能方 | 校验失败与"缺失"同路处理(澄清),但带 `invalid:value` 原因,宿主可区分"没给"与"给错了" |
| 极复杂槽位(嵌套结构、多字段) | 系统功能方(handler) | 模块抽取 span/整段;结构化解析依据 handler 自己的契约(只有它知道 schema) |
原则:**槽位的"识别与确定性校验"进模块,槽位的"语义消解与结构化"不进**——后者要么需要上下文(会话层),要么需要领域 schema(功能方)。
## 三、多轮描述、意图切换、意图叠加
**判断信号本模块已在单次识别里全部给出**:
| 用户行为 | 识别输出信号 | 谁决策 |
|---|---|---|
| 多轮描述同一意图(逐轮补细节) | 上一轮澄清后:本轮 `ExpectationHit`;或 `Matched` 同一意图 + `MissingRequiredSlots` 持续收敛 | 宿主 DST 维护"活动意图",决定何时收敛执行 |
| 说一半切新意图 | `ExpectationProvider` 有挂起但未命中 + 本轮 `Matched` 不同意图 | 宿主决定:丢弃旧意图 / 挂起保留 / 询问用户 |
| 在原意图上叠加新要求 | `ExpectationHit` + 候选列表里出现其他过阈意图;或多段分句结果混合 | 宿主决定合并方式(并入活动意图 / 拆两个任务) |
**不进本模块的**:活动意图状态、切换/叠加策略、多轮上下文存储、澄清超时清理——这是 DST(对话状态管理),有状态且策略随产品而异,属宿主会话层。模块刻意不持有会话状态(挂起预期都是宿主经接口注入的),这正是它能被 Squad、Native、Agent-System 等不同形态宿主共用的前提。
**如果未来需要独立的会话层模块**(如 Squad 直宿主做对话产品时):抽一个小仓(暂名 Dialog-Session),组合 agent-intent 为识别引擎 + 自维护 DST 与期望管理。抽仓判据沿用模块契约规范:**至少两个宿主需要同一套 DST 时才抽**,不为单一宿主预建公共仓。
## 结论
| 问题 | 本模块 | 其他层 |
|---|---|---|
| 复合串并行多指令 | 分句 + 逐段识别(机制已有,补组合助手 E1) | 串并行与混合调度 → Agent-Squad/编排层 |
| 槽位缺失 | MissingRequiredSlots + 挂起预期(已有) | 澄清对话流程 → 会话层 |
| 槽位模糊/错误 | 确定性归一与校验(建议扩展 E2) | 语义消解/复杂结构 → LLM 层 / 功能方 |
| 多轮/切换/叠加 | 单次识别给出全部判断信号(已有) | DST 与策略 → 会话层(Agent-System 先例;必要时抽 Dialog-Session) |
### 建议的模块内扩展(待批,均保持机制化与确定性)
- **E1** `SegmentAndRecognize`:分句 + 逐段识别的组合助手,结果带段序号与原文段,宿主免写循环。
- **E2** SlotSpec 确定性约束与归一:`enum` / `range` / `format` 声明式约束 + Go 注册自定义 validator/normalizer;校验失败区分 `missing` 与 `invalid`。
- **E3**(可选)Result 附带 segment 元数据,便于宿主对账与审计。
### 明确不做清单
串并行规划、系统功能×agent 混合调度(→编排层);会话状态、DST、切换/叠加策略、澄清流程(→会话层);热路径 LLM 槽位解析(→LLM 分层策略的异步层)。
@@ -0,0 +1,93 @@
# Agent-Intent × Agent-Squad × Agent-Native 接入与集成测试报告
日期:2026-09-29;执行:weichen(会话内自动化 + 手工 E2E)。
设计依据:[2026-09-29-decoupling-and-llm-layering.md](2026-09-29-decoupling-and-llm-layering.md)(D1–D7 全部按推荐执行)。
提交组合:Error-Code-System `dbeed2f` ← Agent-Intent `55780ae` ← Agent-Native `c659dc1` ← Agent-Squad `6a36308`(guidelines `26b562d2` / logging-system `2c5716f` 全链一致,各仓一致性脚本全绿)。
## 一、外挂了什么意图(本报告全部用例共用的意图集)
E2E 与 intentctl 用同一份 JSON 配置(`/tmp/squad-intents/intents.json`);单测另用等效 Go/MapFS 定义。共 4 条:
| # | 意图 ID | 域 | 触发词(includes_any) | 置信度参数 | 元数据(宿主解释) |
|---|---|---|---|---|---|
| 1 | `guard.config_change` | guard(守卫域,Stage 0) | 系统配置 / 网关 / 改密码 | 默认(base 0.7 / threshold 0.6) | risk=high;guidance="系统配置请在管理界面修改,任务入口不受理" |
| 2 | `demo.report.competitor` | demo | 竞品 / 对标分析 | base 0.8 / threshold 0.7 | handler=report.competitor;**blueprint=competitor_report**(定向模板) |
| 3 | `demo.greet.hello` | demo | 你好 | 默认 | priority=10(无 blueprint → 命中也不干预) |
| 4 | `demo.greet.hi` | demo | 你好 | 默认 | priority=5(与 3 同词同分 → 制造歧义分支) |
配套蓝图(Squad 数据目录):`competitor_report`,关键词 `竞品分析报告`(注意:E2E 目标文本**不含**该关键词),1 步(researcher → output.md)。
## 二、意图识别层用例(intentctl,纯模块层)
命令:`go run ./cmd/intentctl eval -root /tmp/squad-intents /tmp/squad-intents-corpus.json` → **5/5 PASS**。
| 用例 | 输入 | 识别结果(分支 / Top / 置信度) | 期望 | 判定 |
|---|---|---|---|---|
| guard_refuse | `帮我把系统网关地址改一下` | guard_hit / `guard.config_change` / 0.7 | guard_hit + 该 Top | ✅ |
| route_blueprint | `给我做一份竞品情况的分析报告` | matched / `demo.report.competitor` / 0.8(terms=竞品) | matched + 该 Top | ✅ |
| ambiguous_greet | `你好呀` | ambiguous(hello 与 hi 同分 0.7,priority 10>5 排序) | ambiguous | ✅ |
| free_fallback | `帮我整理一下这周的会议纪要` | fallback | fallback(交给原路径) | ✅ |
| hard_negative_excluded | `查询系统状态` | fallback(无 spec 过阈;守卫词"系统配置"未整词命中) | fallback(不误触) | ✅ |
explain 摘录(route_blueprint 输入):`守卫 排除 guard.config_change no_positive_match;规则 命中 demo.report.competitor 0.8000 竞品`。
## 三、Agent-Squad 端到端(真实 CLI,`--hitl-mode auto`,stub 后端)
四组对照实验,同一数据目录与蓝图,唯一变量是是否 `--intent-root /tmp/squad-intents`:
| 用例 | 输入(goal) | 外挂意图 | 实际结果 | 期望 | 判定 |
|---|---|---|---|---|---|
| A 守卫拒绝 | `帮我把系统网关地址改一下` | 有 | `failed`,错误 `squad.intent_refused: 命中守卫意图 guard.config_change`,exit 1 | 拒绝且带稳定码 | ✅ |
| B 同输入对照 | 同上 | 无 | `completed`,4 步自主规划,质检 100,exit 0 | 正常执行(证明拒绝只来自外挂意图) | ✅ |
| C 意图定向 | `给我做一份竞品情况的分析报告`(**不含**蓝图关键词) | 有 | `completed`,**1 步**(= 蓝图步骤 b1);state.json 记录 `task_key=competitor_report`、`planning_mode=blueprint`、`intent_id=demo.report.competitor`、`intent_blueprint=competitor_report` | 定向到模板并审计落盘 | ✅ |
| D 同输入对照 | 同上 | 无 | `completed`,**4 步**自主规划 | 不定向(证明路由变化来自意图) | ✅ |
| E 自由透传 | `帮我整理一下这周的会议纪要` | 有(不命中) | `completed`,4 步自主规划,质检 100 | 与无意图行为一致(意图只增不删) | ✅ |
真实输出摘录:
```text
# A(有意图)
▶ 运行 run_20260929T160446.234228623 终态:failed
错误: squad.intent_refused: 命中守卫意图 guard.config_change exit=1
# B(无意图,同文本)
▶ 运行 run_20260929T160446.236881964 终态:completed
步骤 4 个(完成 4/跳过 0/失败 0)。质检得分 100。 exit=0
# C(有意图)
▶ 运行 run_20260929T160453.123945617 终态:completed
步骤 1 个(完成 1/跳过 0/失败 0)。质检得分 100。 exit=0
# D(无意图,同文本)
▶ 运行 run_20260929T160453.126982316 终态:completed
步骤 4 个(完成 4/跳过 0/失败 0)。质检得分 100。 exit=0
```
## 四、Agent-Native 装饰器(adapters/intentgate,单测层)
后端为计数 stub;外挂意图与第一节同源(守卫 spec 等效定义):
| 用例 | 外挂意图 | 输入(Prompt) | 识别结果 | 期望 | 判定 |
|---|---|---|---|---|---|
| 零 spec 透传逐字节等价 | 无(零 spec 快照) | `帮我改一下系统网关地址` | fallback | Wrap 与裸后端 Result 深比较相等;Handler 零触发 | ✅ |
| 守卫拦截不经模型 | guard.config_change | `帮我改一下系统网关地址` | guard_hit | Handler 收到 GuardHit;返回拒绝合成结果;**后端零调用** | ✅ |
| fallback 透传 | 同上 | `写一份周报` | fallback | 透传,后端恰好 1 次调用 | ✅ |
| Handler 放弃回退 | 同上 | `帮我改一下系统网关地址` | guard_hit | 透传原后端 | ✅ |
| 组件为 nil 原样返回 | n/a | n/a | n/a | Wrap 返回原后端 | ✅ |
| 识别错误 fail-closed | MaxInputLen=8 | 超长输入 | sys.invalid_argument | 返回错误,后端零调用 | ✅ |
## 五、自动化测试与解耦证明汇总
| 验证项 | 位置 | 结果 |
|---|---|---|
| 既有全链路契约回归(等价证明) | Agent-Squad `conformance` | 通过(未改动即过) |
| 零配置等价(nil 闸门 / 零内容闸门 / 无闸门引擎三路对照) | Squad `squad` + `squadintent` | 通过 |
| 守卫拒绝稳定码 / 蓝图缺失 fail-closed / 闸门错误 fail-closed / TaskKey 旁路 | Squad `squad` 引擎级 | 通过 |
| 适配器→引擎→蓝图→执行→质检全链路(含 -race) | Squad `squadintent.TestEngineEndToEndWithAdapterGate` | 通过 |
| 结构断言:`go list -deps ./squad ./server ./store` 与 Native 核心 | 两仓 | agent-intent 0 命中 |
| submodule 一致性(Squad 8 项比对 / Native 4 项 / Agent-Intent 3 项) | 各仓脚本 | 全绿 |
| 全仓 `go vet` + `go test -count=1`(三仓)+ 关键包 `-race` | 三仓 | 全绿 |
## 六、结论与回退
- 结论:Squad(引擎级闸门)与 Native(Backend 装饰器)均以 T1 形态接入;未配置意图时行为与原实现一致(T0 已由测试与对照实验双重证明);守卫拒绝与配置失败均 fail-closed 且带稳定码。
- 回退:各仓删除对应适配器包 + 接线 + submodule(清单见各仓 docs/records 采纳记录),核心包零改动。
过程记录:Agent-Squad `docs/records/2026-09-29-intent-gate-adoption.md`;Agent-Native `docs/records/2026-09-29-intentgate-adoption.md`。
+121
View File
@@ -0,0 +1,121 @@
package intent
import (
"fmt"
"strings"
)
// CatalogSlot 目录中的槽位视图:给规划者/领导 Agent 看的声明式信息
// (有哪些能力、缺什么信息、建议怎么问),不暴露匹配实现细节。
type CatalogSlot struct {
Key string `json:"key"`
Required bool `json:"required,omitempty"`
Label string `json:"label,omitempty"`
Question string `json:"question,omitempty"`
Enum []string `json:"enum,omitempty"`
Format string `json:"format,omitempty"`
}
// CatalogSpec 目录中的单条能力。
type CatalogSpec struct {
ID string `json:"id"`
Domain string `json:"domain,omitempty"`
Version int `json:"version,omitempty"`
Description string `json:"description,omitempty"`
Risk string `json:"risk,omitempty"`
RequiresConfirm bool `json:"requires_confirm,omitempty"`
Handler string `json:"handler,omitempty"`
Slots []CatalogSlot `json:"slots,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// Catalog 返回能力目录:全部启用的非守卫 spec,按合并序(确定性)。
// 守卫域 spec 是拦截器而非可调用能力,不进目录;需要全量时用 SpecIDs/SpecByID。
func (s *Snapshot) Catalog() []CatalogSpec {
out := make([]CatalogSpec, 0, len(s.specs))
for _, cs := range s.specs {
if cs.guard {
continue
}
view := CatalogSpec{
ID: cs.spec.ID,
Domain: cs.spec.Domain,
Version: cs.spec.Version,
Description: cs.spec.Description,
Risk: cs.spec.Risk,
RequiresConfirm: cs.spec.RequiresConfirm,
Handler: cs.spec.Handler,
Metadata: cs.spec.Metadata,
}
if view.Risk == "" {
view.Risk = RiskLow
}
for _, sl := range cs.slots {
view.Slots = append(view.Slots, CatalogSlot{
Key: sl.key, Required: sl.required, Label: sl.label, Question: sl.question,
Enum: sl.enum, Format: specSlotFormat(cs.spec, sl.key),
})
}
out = append(out, view)
}
return out
}
// CatalogMarkdown 渲染注入规划者/领导提示词用的目录文本(确定性)。
// 文案面向 LLM:说明可直接调用、必填槽位与提问建议。
func (s *Snapshot) CatalogMarkdown() string {
var b strings.Builder
fmt.Fprintf(&b, "# 系统能力目录(意图快照 revision=%d)\n\n", s.rev)
b.WriteString("以下能力可直接调用,无需派智能体执行。子任务命中其一时,输出携带 intent_id 的步骤并填齐必填槽位;\n")
b.WriteString("必填槽位信息不足时不要编造,改为向用户澄清(优先使用各槽位的提问建议)。\n\n")
specs := s.Catalog()
if len(specs) == 0 {
b.WriteString("(目录为空:当前没有可直接调用的系统能力)\n")
return b.String()
}
for _, sp := range specs {
desc := sp.Description
if desc == "" {
desc = sp.ID
}
fmt.Fprintf(&b, "## %s — %s\n", sp.ID, desc)
confirm := "否"
if sp.RequiresConfirm {
confirm = "是"
}
fmt.Fprintf(&b, "- 风险:%s;执行前需人工确认:%s;handler:%s\n", sp.Risk, confirm, sp.Handler)
for _, sl := range sp.Slots {
req := "选填"
if sl.Required {
req = "必填"
}
label := sl.Label
if label == "" {
label = sl.Key
}
fmt.Fprintf(&b, "- 槽位 %s(%s,%s)", sl.Key, req, label)
if len(sl.Enum) > 0 {
fmt.Fprintf(&b, ";取值:%s", strings.Join(sl.Enum, " | "))
}
if sl.Format != "" {
fmt.Fprintf(&b, ";格式:%s", sl.Format)
}
b.WriteString("\n")
if sl.Question != "" {
fmt.Fprintf(&b, " - 提问建议:%s\n", sl.Question)
}
}
b.WriteString("\n")
}
return b.String()
}
// specSlotFormat 从原始 spec 取槽位的 Format 声明(编译产物不保留字符串正则源)。
func specSlotFormat(spec IntentSpec, key string) string {
for _, sl := range spec.Slots {
if sl.Key == key {
return sl.Format
}
}
return ""
}
+5 -31
View File
@@ -94,13 +94,7 @@ func (o Options) limits() limits {
}
// ---- 编译产物 ----
type compiledSlot struct {
key string
required bool
re *regexp.Regexp
group int
}
// 槽位的编译产物与抽取/校验逻辑见 slots.go。
type compiledSpec struct {
spec IntentSpec
@@ -169,26 +163,6 @@ func (cs *compiledSpec) evaluate(input, folded string) matchOutcome {
return matchOutcome{score: math.Round(score*10000) / 10000, passed: score >= cs.threshold, hitTerms: hitTerms, hitPat: hitPat}
}
// extractSlots 从原文抽取槽位;返回缺失的必填槽位键。
func (cs *compiledSpec) extractSlots(input string) (map[string]string, []string) {
if len(cs.slots) == 0 {
return nil, nil
}
slots := make(map[string]string, len(cs.slots))
var missing []string
for _, s := range cs.slots {
m := s.re.FindStringSubmatch(input)
if m != nil && s.group < len(m) && m[s.group] != "" {
slots[s.key] = m[s.group]
continue
}
if s.required {
missing = append(missing, s.key)
}
}
return slots, missing
}
func compileSpec(spec IntentSpec, guard bool, index int) (*compiledSpec, error) {
m := spec.Matcher
fold := func(terms []string) []string {
@@ -231,11 +205,11 @@ func compileSpec(spec IntentSpec, guard bool, index int) (*compiledSpec, error)
cs.patterns[i] = re
}
for _, s := range spec.Slots {
re, err := regexp.Compile(s.Pattern)
cs2, err := compileSlot(s)
if err != nil {
return nil, newError(InvalidSpec, "slot pattern 编译失败", "field", "slots", "key", s.Key, "id", spec.ID, "error", err.Error())
return nil, err.(*Error).with("id", spec.ID)
}
cs.slots = append(cs.slots, compiledSlot{key: s.Key, required: s.Required, re: re, group: effectiveGroup(s.Group)})
cs.slots = append(cs.slots, cs2)
}
return cs, nil
}
@@ -432,7 +406,7 @@ func (h specHit) candidate(input string, withSlots bool) Candidate {
}
}
if withSlots {
c.Slots, c.MissingRequiredSlots = cs.extractSlots(input)
c.Slots, c.MissingRequiredSlots, c.InvalidSlots = cs.extractSlots(input)
}
return c
}
+3
View File
@@ -32,6 +32,9 @@ type Candidate struct {
Metadata map[string]string
Slots map[string]string
MissingRequiredSlots []string
// InvalidSlots 捕获到但未通过确定性校验的槽位(key→原因,如枚举不合法)。
// 必填且非法只进此处(不重复进 Missing),宿主可区分"没给"与"给错了"。
InvalidSlots map[string]string
// Reason 命中解释:命中词与模式,供宿主审计与 intentctl explain 复用。
Reason string
}
+148
View File
@@ -0,0 +1,148 @@
package intent
import (
"regexp"
"strings"
"sync"
)
// SlotValidator 宿主注册的槽位值校验器。必须纯函数、确定性、无 I/O。
type SlotValidator func(value string) error
var slotValidators sync.Map // name → SlotValidator
// RegisterSlotValidator 注册具名校验器;spec 通过 SlotSpec.Validator 引用。
// 必须在快照构建前注册(构建期会校验名字存在)。
func RegisterSlotValidator(name string, fn SlotValidator) {
slotValidators.Store(name, fn)
}
// LookupSlotValidator 按名查找已注册校验器。
func LookupSlotValidator(name string) (SlotValidator, bool) {
fn, ok := slotValidators.Load(name)
if !ok {
return nil, false
}
v, ok := fn.(SlotValidator)
return v, ok
}
// 内置归一化步骤:trim / lower / upper。
var builtinNormalizers = map[string]func(string) string{
"trim": strings.TrimSpace,
"lower": strings.ToLower,
"upper": strings.ToUpper,
}
func normalizeSlotValue(v string, ops []string) string {
for _, op := range ops {
if fn, ok := builtinNormalizers[op]; ok {
v = fn(v)
}
}
return v
}
// compiledSlot 编译后的槽位抽取与校验产物。
type compiledSlot struct {
key string
required bool
label string
question string
re *regexp.Regexp
group int
enumSet map[string]bool
enum []string
formatRe *regexp.Regexp
normalize []string
validator SlotValidator
}
// validate 对归一化后的值做确定性校验;返回(原因, 是否合法)。
func (s *compiledSlot) validate(v string) (string, bool) {
if s.formatRe != nil && !s.formatRe.MatchString(v) {
return "format mismatch: " + s.formatRe.String(), false
}
if s.enumSet != nil && !s.enumSet[v] {
return "value not in enum", false
}
if s.validator != nil {
if err := s.validator(v); err != nil {
return "validator: " + err.Error(), false
}
}
return "", true
}
// extractSlots 抽取并归一化槽位。
// missing:必填槽位未捕获(模式没匹配上);invalid:捕获到但校验不合法(key→原因)。
// 必填且非法只进 invalid(宿主可区分"没给"与"给错了"来决定澄清话术)。
func (cs *compiledSpec) extractSlots(input string) (slots map[string]string, missing []string, invalid map[string]string) {
if len(cs.slots) == 0 {
return nil, nil, nil
}
for _, s := range cs.slots {
m := s.re.FindStringSubmatch(input)
if m == nil || s.group >= len(m) || m[s.group] == "" {
if s.required {
missing = append(missing, s.key)
}
continue
}
v := normalizeSlotValue(m[s.group], s.normalize)
if reason, ok := s.validate(v); !ok {
if invalid == nil {
invalid = map[string]string{}
}
invalid[s.key] = reason
continue
}
if slots == nil {
slots = map[string]string{}
}
slots[s.key] = v
}
return slots, missing, invalid
}
func compileSlot(spec SlotSpec) (compiledSlot, error) {
re, err := regexp.Compile(spec.Pattern)
if err != nil {
return compiledSlot{}, newError(InvalidSpec, "slot pattern 编译失败",
"field", "slots", "key", spec.Key, "error", err.Error())
}
cs := compiledSlot{
key: spec.Key,
required: spec.Required,
label: spec.Label,
question: spec.Question,
re: re,
group: effectiveGroup(spec.Group),
normalize: append([]string(nil), spec.Normalize...),
}
if len(spec.Enum) > 0 {
cs.enum = append([]string(nil), spec.Enum...)
cs.enumSet = make(map[string]bool, len(spec.Enum))
for _, e := range spec.Enum {
cs.enumSet[e] = true
}
}
if spec.Format != "" {
fre, err := regexp.Compile(spec.Format)
if err != nil {
return compiledSlot{}, newError(InvalidSpec, "slot format 编译失败",
"field", "slots", "key", spec.Key, "format", spec.Format, "error", err.Error())
}
cs.formatRe = fre
}
if spec.Validator != "" {
fn, ok := LookupSlotValidator(spec.Validator)
if !ok {
return compiledSlot{}, newError(InvalidSpec, "slot validator 未注册",
"field", "slots", "key", spec.Key, "validator", spec.Validator,
"hint", "先调用 intent.RegisterSlotValidator 再构建快照")
}
cs.validator = fn
}
return cs, nil
}
+176
View File
@@ -0,0 +1,176 @@
package intent
import (
"context"
"errors"
"strings"
"testing"
)
func slotSpecWith(overrides ...func(*IntentSpec)) IntentSpec {
spec := IntentSpec{
ID: "demo.run.status_query", Domain: "demo", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"进度"}},
}
for _, f := range overrides {
f(&spec)
}
return spec
}
func TestSlotNormalizeAndValidateChain(t *testing.T) {
RegisterSlotValidator("runid", func(v string) error {
if !strings.HasPrefix(v, "run-") {
return errors.New("须以 run- 开头")
}
return nil
})
b := NewRegistryBuilder(Options{})
if err := b.Register(slotSpecWith(func(s *IntentSpec) {
s.Slots = []SlotSpec{{
Key: "run_id", Required: true,
Pattern: "(?:run|任务)[:# ]*([A-Za-z0-9-]+)", Group: 1,
Normalize: []string{"trim", "lower"},
Validator: "runid",
}}
})); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
rec := NewRecognizer(Options{}, nil, nil)
// 归一化 + 校验通过:大写、带空白 → trim+lower 后通过自定义校验。
res, _ := rec.Recognize(context.Background(), "查询任务 RUN-42 的进度", reg.Snapshot())
if res.Top == nil || res.Top.Slots["run_id"] != "run-42" {
t.Fatalf("normalized slot: %+v", res.Top)
}
if len(res.Top.InvalidSlots) != 0 || len(res.Top.MissingRequiredSlots) != 0 {
t.Fatalf("unexpected missing/invalid: %+v", res.Top)
}
// 捕获到但校验不合法:只进 InvalidSlots,不进 Missing。
res, _ = rec.Recognize(context.Background(), "查询任务 42 的进度", reg.Snapshot())
if res.Top == nil {
t.Fatal("expected match")
}
if res.Top.InvalidSlots["run_id"] == "" {
t.Fatalf("expected invalid slot with reason, got %+v", res.Top.InvalidSlots)
}
if len(res.Top.MissingRequiredSlots) != 0 {
t.Fatalf("invalid must not duplicate into missing: %+v", res.Top.MissingRequiredSlots)
}
if res.Top.Slots["run_id"] != "" {
t.Fatalf("invalid slot must not be filled: %+v", res.Top.Slots)
}
}
func TestSlotEnumAndFormat(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(slotSpecWith(func(s *IntentSpec) {
s.Slots = []SlotSpec{
{Key: "env", Pattern: "环境[: ]*(\\w+)", Group: 1, Enum: []string{"prod", "staging"}},
{Key: "date", Pattern: "日期[: ]*(\\S+)", Group: 1, Format: `^\d{4}-\d{2}-\d{2}$`},
}
})); err != nil {
t.Fatal(err)
}
reg, _ := b.Build()
rec := NewRecognizer(Options{}, nil, nil)
res, _ := rec.Recognize(context.Background(), "查进度 环境: prod 日期: 2026-09-29", reg.Snapshot())
if res.Top.InvalidSlots != nil && len(res.Top.InvalidSlots) != 0 {
t.Fatalf("valid values flagged: %+v", res.Top.InvalidSlots)
}
if res.Top.Slots["env"] != "prod" || res.Top.Slots["date"] != "2026-09-29" {
t.Fatalf("slots: %+v", res.Top.Slots)
}
res, _ = rec.Recognize(context.Background(), "查进度 环境: dev 日期: 明天", reg.Snapshot())
if res.Top.InvalidSlots["env"] != "value not in enum" {
t.Fatalf("enum check: %+v", res.Top.InvalidSlots)
}
if !strings.Contains(res.Top.InvalidSlots["date"], "format mismatch") {
t.Fatalf("format check: %+v", res.Top.InvalidSlots)
}
}
func TestSlotSpecValidationRejections(t *testing.T) {
lim := Options{}.normalized().limits()
cases := []struct {
name string
slot SlotSpec
field string
}{
{"bad_format", SlotSpec{Key: "k", Pattern: "(x)", Format: "["}, "slots[0].format"},
{"bad_normalize", SlotSpec{Key: "k", Pattern: "(x)", Normalize: []string{"camel"}}, "slots[0].normalize"},
{"unregistered_validator", SlotSpec{Key: "k", Pattern: "(x)", Validator: "nope"}, "slots[0].validator"},
{"empty_enum", SlotSpec{Key: "k", Pattern: "(x)", Enum: []string{" "}}, "slots[0].enum"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
spec := slotSpecWith(func(s *IntentSpec) { s.Slots = []SlotSpec{tc.slot} })
err := validateSpec(spec, lim)
var e *Error
if !errors.As(err, &e) || e.Code != InvalidSpec {
t.Fatalf("expected invalid_spec, got %v", err)
}
if got, _ := e.Fields["field"].(string); got != tc.field {
t.Fatalf("field %q, got %q (%v)", tc.field, got, e.Fields)
}
})
}
}
func TestCatalogExcludesGuardAndIncludesSlots(t *testing.T) {
b := NewRegistryBuilder(Options{GuardDomains: []string{"guard"}})
if err := b.Register(
IntentSpec{ID: "guard.config_change", Domain: "guard", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"网关"}}},
slotSpecWith(func(s *IntentSpec) {
s.Description = "查询任务运行进度"
s.Handler = "taskrun.query_status"
s.Slots = []SlotSpec{{
Key: "run_id", Required: true, Pattern: "(run-\\d+)",
Label: "运行编号", Question: "要查询哪个运行?请提供 run 编号",
}}
}),
); err != nil {
t.Fatal(err)
}
reg, _ := b.Build()
snap := reg.Snapshot()
catalog := snap.Catalog()
if len(catalog) != 1 || catalog[0].ID != "demo.run.status_query" {
t.Fatalf("catalog must exclude guard specs: %+v", catalog)
}
cs := catalog[0]
if cs.Handler != "taskrun.query_status" || cs.Risk != RiskLow || len(cs.Slots) != 1 {
t.Fatalf("catalog spec: %+v", cs)
}
if cs.Slots[0].Label != "运行编号" || cs.Slots[0].Question == "" || !cs.Slots[0].Required {
t.Fatalf("catalog slot: %+v", cs.Slots[0])
}
md := snap.CatalogMarkdown()
for _, want := range []string{"demo.run.status_query", "查询任务运行进度", "必填", "运行编号", "提问建议:要查询哪个运行"} {
if !strings.Contains(md, want) {
t.Fatalf("markdown missing %q:\n%s", want, md)
}
}
if strings.Contains(md, "guard.config_change") {
t.Fatalf("markdown must exclude guard specs:\n%s", md)
}
}
func TestCatalogEmpty(t *testing.T) {
reg, _ := NewRegistryBuilder(Options{}).Build()
if got := reg.Snapshot().Catalog(); len(got) != 0 {
t.Fatalf("expected empty catalog, got %+v", got)
}
if md := reg.Snapshot().CatalogMarkdown(); !strings.Contains(md, "目录为空") {
t.Fatalf("empty markdown: %q", md)
}
}
+34
View File
@@ -34,11 +34,25 @@ type MatchSpec struct {
}
// SlotSpec 槽位抽取规则。group 未填默认取 1(第一个捕获组)。
// label/question 供宿主做 HITL 提问与目录展示;enum/format/normalize/validator
// 构成确定性校验链:抽取 → 归一化 → 校验,校验失败进 Candidate.InvalidSlots。
type SlotSpec struct {
Key string `json:"key"`
Required bool `json:"required,omitempty"`
Pattern string `json:"pattern"`
Group int `json:"group,omitempty"`
// Label 槽位的人类可读名(目录/HITL 展示用)。
Label string `json:"label,omitempty"`
// Question 建议的澄清提问(HITL 生成用),如"要查询哪个运行?请提供 run 编号"。
Question string `json:"question,omitempty"`
// Enum 合法值枚举(归一化后校验)。
Enum []string `json:"enum,omitempty"`
// Format 整段匹配正则(校验,区别于 Pattern 的抽取用途)。
Format string `json:"format,omitempty"`
// Normalize 归一化步骤,按序应用:trim | lower | upper。
Normalize []string `json:"normalize,omitempty"`
// Validator 宿主经 RegisterSlotValidator 注册的校验器名。
Validator string `json:"validator,omitempty"`
}
// ConfidenceSpec 置信度参数。Base 初始分;PatternBonus 模式命中加成;
@@ -151,6 +165,26 @@ func validateSpec(spec IntentSpec, lim limits) error {
if g < 1 || g > re.NumSubexp() {
return newError(InvalidSpec, "slot group 超出捕获组范围", "field", fmt.Sprintf("slots[%d].group", i), "key", s.Key, "group", s.Group, "groups", re.NumSubexp())
}
for _, e := range s.Enum {
if strings.TrimSpace(e) == "" {
return newError(InvalidSpec, "slot enum 含空值", "field", fmt.Sprintf("slots[%d].enum", i), "key", s.Key)
}
}
if s.Format != "" {
if _, err := regexp.Compile(s.Format); err != nil {
return newError(InvalidSpec, "slot format 编译失败", "field", fmt.Sprintf("slots[%d].format", i), "key", s.Key, "error", err.Error())
}
}
for _, op := range s.Normalize {
if _, ok := builtinNormalizers[op]; !ok {
return newError(InvalidSpec, "slot normalize 步骤不合法(trim|lower|upper)", "field", fmt.Sprintf("slots[%d].normalize", i), "key", s.Key, "op", op)
}
}
if s.Validator != "" {
if _, ok := LookupSlotValidator(s.Validator); !ok {
return newError(InvalidSpec, "slot validator 未注册", "field", fmt.Sprintf("slots[%d].validator", i), "key", s.Key, "validator", s.Validator, "hint", "先调用 intent.RegisterSlotValidator 再注册/加载 spec")
}
}
}
c := spec.Confidence