Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3674dc4bfd | ||
|
|
52ce798ebf | ||
|
|
452dddcea3 | ||
|
|
ae2c80bd4c | ||
|
|
55780ae2cc | ||
|
|
1509d7472a | ||
|
|
55950f6414 | ||
|
|
40c33d5edc | ||
|
|
a2cf07f693 |
+7
-38
@@ -6,48 +6,17 @@ on:
|
||||
branches: [main]
|
||||
jobs:
|
||||
vet-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
runs-on: nightly
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init -q
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout -q --detach FETCH_HEAD
|
||||
if [ -z "${SUBMODULE_TOKEN}" ]; then
|
||||
echo "SUBMODULE_TOKEN is not configured" >&2
|
||||
exit 1
|
||||
fi
|
||||
git config --global url."https://weichen:${SUBMODULE_TOKEN}@git-home.u1680832.nyat.app:30319/".insteadOf "ssh://git@git-ssh.u1680832.nyat.app:31411/"
|
||||
git -c fetch.recurseSubmodules=false submodule update --init --recursive
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
|
||||
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
|
||||
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
- name: Vet, test, and race
|
||||
run: GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./... && GOWORK=off go test -count=1 -race ./...
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
- run: go vet ./... && go test -count=1 ./...
|
||||
docker-checkout-hostgateway:
|
||||
runs-on: ubuntu-latest
|
||||
options: --add-host gitea:host-gateway
|
||||
env:
|
||||
GITHUB_SERVER_URL: http://gitea:3000
|
||||
steps:
|
||||
- name: Checkout without GitHub actions
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init -q
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout -q --detach FETCH_HEAD
|
||||
test -f go.mod && echo "docker-mode checkout via host-gateway OK"
|
||||
- uses: actions/checkout@v4
|
||||
- run: test -f go.mod && echo "docker-mode checkout via host-gateway OK"
|
||||
|
||||
@@ -1,36 +1,32 @@
|
||||
# Agent 工作说明(Agent-Intent)
|
||||
# 项目工作入口
|
||||
|
||||
本仓开发遵循 Development-Guidelines(Dev 仓 `deps/development-guidelines`,gitlink 固定版本):
|
||||
核心规则见其 `core/`,按任务触发阅读 `standards/` 专题;日常交付走 `workflows/delivery.md`。
|
||||
本项目采纳的通用规则:[核心规则](deps/development-guidelines/core/baseline.md)。
|
||||
采纳范围、生效状态及项目差异:[采纳记录](policy/guidelines-adoption.md)。
|
||||
项目事实、测试和环境入口:[PROJECT.md](PROJECT.md)。
|
||||
|
||||
## 任务启动门禁
|
||||
- 启动:阅读以上短入口;只加载本次任务触发的专题。
|
||||
- 普通交付:[交付流程](deps/development-guidelines/workflows/delivery.md)。
|
||||
- 并行开发/正式测试/审核/合入:[共同基线与同步节点](deps/development-guidelines/standards/collaboration.md),必须确认并实际同步最新权威目标。
|
||||
- Bug/异常:[系统性排障](deps/development-guidelines/workflows/debugging.md)。
|
||||
- 会话结束/换机器/换客户端/换执行者:[会话、执行者与机器交接](deps/development-guidelines/workflows/handoff.md),状态写入任务主记录,不依赖本地会话历史。
|
||||
- 测试与验收:[验证标准](deps/development-guidelines/standards/verification.md)。
|
||||
- 模块/接口:[模块契约](deps/development-guidelines/standards/module-contracts.md)。
|
||||
- 功能设计/跨模块变更:[设计与验证](deps/development-guidelines/workflows/design-verification.md),按增量明确验收、独立测试及故障范围。
|
||||
- 错误与过程事件:[可观测性](deps/development-guidelines/standards/observability.md)。
|
||||
- 配置来源与生效:[配置规范](deps/development-guidelines/standards/configuration.md),spec/overlay 合并、快照 revision 与诊断工具须遵守。
|
||||
- 拆仓与 submodule:[多仓库与固定版本组合](deps/development-guidelines/standards/multi-repo.md)。
|
||||
- 其他专题:按 [任务索引](deps/development-guidelines/README.md) 选择。
|
||||
|
||||
- 任务启动或恢复后,第一次写操作前在 Dev 仓运行 `make precheck` 并附完整输出(本仓作为子仓随父仓编排);
|
||||
本仓独立开发时核对工作树 clean、与权威 origin/main 同步,并按任务触发读对应专题。
|
||||
- 从权威 origin/main 固定共同起点 R0,建 feature|fix|hotfix/<slug> 分支(Dev 仓 `make task-start` 自动化);
|
||||
任务中途不重复 precheck。
|
||||
- 验收、请求审核、合入前各同步一次最新目标分支并附证据;合入后回收任务工作区与已合入分支。
|
||||
- 提交主题行:英文祈使句一行(≤100 列,不以句点结尾);body 写 `Why: / Evidence: / Impact: / Refs:`。
|
||||
项目补充硬边界:
|
||||
|
||||
## 仓内规则
|
||||
- 本仓只做意图识别机制(定义、合并、识别、槽位、诊断),不内置任何业务意图内容;意图词表与正则一律由宿主注册或配置。
|
||||
- 依赖方向:仅可选接入 error-code-system 与 logging-system;禁止导入 agent-native、agent-squad、agent-system 或任何宿主类型。LLM 分类器、挂起预期、分句等扩展点只定义接口,实现留在宿主。
|
||||
- 配置/spec 校验失败 fail-closed:拒绝生成新快照并保留上一生效快照。「无匹配」(fallback)是正常识别结果,不是错误。
|
||||
- 错误码常量与 Error-Code-System 的 `intent.*` / `sys.*` 规范码对齐,`catalogcheck` 防止双权威;未知码不静默当成功。
|
||||
- 核心识别路径纯内存、无 I/O、确定性:同输入 + 同快照 → 同结果,不含时间与随机因素。
|
||||
- 依赖仓版本以本仓 gitlink 为准,不执行 `submodule update --remote`;除两个生态子模块外零第三方依赖。
|
||||
|
||||
- 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md。
|
||||
- 跨仓 gitlink 变更须经组合验证后在 Dev 仓推进;受保护分支经 PR 合入。
|
||||
- 验证命令见 PROJECT.md「验证」节;完成声明须附实际命令输出证据。
|
||||
|
||||
## Gitea 与 Git 访问事实(2026-09-30 实测)
|
||||
|
||||
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
|
||||
见到一律按过期处理,不要再试。当前权威通道:
|
||||
|
||||
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
|
||||
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
|
||||
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
|
||||
(各仓 origin 与 gitlink 均为此格式)。
|
||||
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
|
||||
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
|
||||
用作 `Authorization: token <值>` 请求头。**token 值不入仓、不回显、不写进产出物**
|
||||
(baseline 硬边界 2:只记录来源、字段与可用状态)。
|
||||
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
|
||||
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
|
||||
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
|
||||
任务权威:Gitea 仓库 `weichen/Agent-Intent` 的 Issue 与 Pull Request。
|
||||
规范性文档目录与过程记录位置:`policy/` 放项目规则;`docs/records/` 为过程记录豁免目录(方案、验证、交接)。项目规范与过程记录不进通用规范库。
|
||||
默认集成基线和提交身份:`main`;提交身份沿用本机 git 作者,不在文档中写死密钥或个人 fork URL。
|
||||
共享环境/发布入口及授权:无共享运行环境。权威远端 `ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git`。
|
||||
|
||||
+15
-86
@@ -106,46 +106,15 @@ func cmdLint(args []string) error {
|
||||
conflicts := snap.Conflicts()
|
||||
if len(conflicts) == 0 {
|
||||
fmt.Println("共享词条提示:无")
|
||||
} else {
|
||||
fmt.Printf("共享词条提示(%d 条,非错误,计分与确定性次序会裁决):\n", len(conflicts))
|
||||
for _, cf := range conflicts {
|
||||
fmt.Printf(" %s 与 %s 共享词条 %q\n", cf.A, cf.B, cf.Term)
|
||||
}
|
||||
}
|
||||
// 词界提示(I3):短词条且未开 boundary 的 spec 容易被中文子串误命中
|
||||
// (如「配置」命中「配置项」)。提示非错误;boundary: true 可压掉。
|
||||
var hints []string
|
||||
for _, id := range snap.SpecIDs() {
|
||||
spec, ok := snap.SpecByID(id)
|
||||
if !ok || spec.Matcher.Boundary {
|
||||
continue
|
||||
}
|
||||
terms := append(append([]string(nil), spec.Matcher.IncludesAny...), spec.Matcher.IncludesAll...)
|
||||
for _, term := range terms {
|
||||
if runeLen(term) <= 2 {
|
||||
hints = append(hints, fmt.Sprintf(" %s 词条 %q 短且未开 boundary(中文子串误命中风险;可考虑 boundary: true)", id, term))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(hints) == 0 {
|
||||
fmt.Println("词界提示:无")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("词界提示(%d 条,非错误):\n", len(hints))
|
||||
for _, h := range hints {
|
||||
fmt.Println(h)
|
||||
fmt.Printf("共享词条提示(%d 条,非错误,计分与确定性次序会裁决):\n", len(conflicts))
|
||||
for _, cf := range conflicts {
|
||||
fmt.Printf(" %s 与 %s 共享词条 %q\n", cf.A, cf.B, cf.Term)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runeLen(s string) int {
|
||||
n := 0
|
||||
for range s {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func cmdExplain(args []string) error {
|
||||
fs := flag.NewFlagSet("explain", flag.ContinueOnError)
|
||||
c := parseCommon(fs)
|
||||
@@ -243,11 +212,18 @@ func sortedKeys(m map[string]string) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
// evalCase 与 intent 包评测集(testdata/corpus.json)同格式。
|
||||
type evalCase struct {
|
||||
Name string `json:"name"`
|
||||
Input string `json:"input"`
|
||||
ExpectBranch string `json:"expect_branch"`
|
||||
ExpectTop string `json:"expect_top"`
|
||||
ExpectSlots map[string]string `json:"expect_slots"`
|
||||
ExpectMissingSlots []string `json:"expect_missing_slots"`
|
||||
}
|
||||
|
||||
type evalCorpus struct {
|
||||
Cases []intent.CorpusCase `json:"cases"`
|
||||
BuiltinSpecs []intent.IntentSpec `json:"builtin_specs,omitempty"`
|
||||
GuardDomains []string `json:"guard_domains,omitempty"`
|
||||
ProtectedDomains []string `json:"protected_domains,omitempty"`
|
||||
Cases []evalCase `json:"cases"`
|
||||
}
|
||||
|
||||
func cmdEval(args []string) error {
|
||||
@@ -270,23 +246,7 @@ func cmdEval(args []string) error {
|
||||
if len(corpus.Cases) == 0 {
|
||||
return fmt.Errorf("评测集为空")
|
||||
}
|
||||
// 语料自声明:builtin_specs 先行注册(如 guard 这类 Go 内置形态的 spec),
|
||||
// 域名与命令行取并集——评测集可以自包含复现,不依赖隐式内置。
|
||||
var reg *intent.Registry
|
||||
if len(corpus.BuiltinSpecs) > 0 || len(corpus.GuardDomains) > 0 || len(corpus.ProtectedDomains) > 0 {
|
||||
if c.root == "" {
|
||||
return fmt.Errorf("-root 必填")
|
||||
}
|
||||
guard := append(append([]string(nil), splitDomains(c.guard)...), corpus.GuardDomains...)
|
||||
protected := append(append([]string(nil), splitDomains(c.protected)...), corpus.ProtectedDomains...)
|
||||
b := intent.NewRegistryBuilder(intent.Options{GuardDomains: guard, ProtectedDomains: protected})
|
||||
if err := b.Register(corpus.BuiltinSpecs...); err != nil {
|
||||
return fmt.Errorf("语料 builtin_specs 注册失败:%w", err)
|
||||
}
|
||||
reg, err = b.BuildFS(context.Background(), os.DirFS(c.root), ".")
|
||||
} else {
|
||||
reg, err = buildRegistry(c)
|
||||
}
|
||||
reg, err := buildRegistry(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("配置校验失败:%w", err)
|
||||
}
|
||||
@@ -295,21 +255,6 @@ func cmdEval(args []string) error {
|
||||
|
||||
failed := 0
|
||||
for _, tc := range corpus.Cases {
|
||||
if len(tc.Turns) > 0 {
|
||||
// v2 多轮:链式预期逐轮执行,逐轮命令期望断言(I1 WP6)。
|
||||
result := intent.RunCorpusCase(context.Background(), snap, tc)
|
||||
if result.Passed() {
|
||||
fmt.Printf("PASS %-28s 多轮(%d 轮)\n", tc.Name, len(result.Turns))
|
||||
continue
|
||||
}
|
||||
failed++
|
||||
for _, tr := range result.Turns {
|
||||
for _, mm := range tr.Mismatches {
|
||||
fmt.Printf("FAIL %-28s turn %d 输入=%q:%s\n", tc.Name, tr.Index, tr.Input, mm)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
res, err := rec.Recognize(context.Background(), tc.Input, snap)
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL %-28s 识别错误:%v\n", tc.Name, err)
|
||||
@@ -340,22 +285,6 @@ func cmdEval(args []string) error {
|
||||
fmt.Printf("PASS %-28s %s\n", tc.Name, res.Branch)
|
||||
}
|
||||
fmt.Printf("总计 %d 用例:%d 通过,%d 失败\n", len(corpus.Cases), len(corpus.Cases)-failed, failed)
|
||||
// I2:指标输出(precision/recall 按意图标签计;确定性重跑,不计入通过判定)。
|
||||
var results []intent.CaseResult
|
||||
for _, tc := range corpus.Cases {
|
||||
results = append(results, intent.RunCorpusCase(context.Background(), snap, tc))
|
||||
}
|
||||
metrics := intent.MetricsOf(corpus.Cases, results)
|
||||
p, pok := metrics.Precision()
|
||||
r, rok := metrics.Recall()
|
||||
ps, rs := "-", "-"
|
||||
if pok {
|
||||
ps = fmt.Sprintf("%.3f", p)
|
||||
}
|
||||
if rok {
|
||||
rs = fmt.Sprintf("%.3f", r)
|
||||
}
|
||||
fmt.Printf("指标: precision=%s recall=%s(TP=%d FP=%d FN=%d)\n", ps, rs, metrics.TP, metrics.FP, metrics.FN)
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("%d/%d 用例失败", failed, len(corpus.Cases))
|
||||
}
|
||||
|
||||
Vendored
+1
-1
Submodule deps/error-code-system updated: a3ee67bba2...dbeed2f1b6
Vendored
+1
-1
Submodule deps/logging-system updated: 49b5d8be60...2c5716fdcf
@@ -91,22 +91,3 @@ explain 摘录(route_blueprint 输入):`守卫 排除 guard.config_change
|
||||
- 回退:各仓删除对应适配器包 + 接线 + submodule(清单见各仓 docs/records 采纳记录),核心包零改动。
|
||||
|
||||
过程记录:Agent-Squad `docs/records/2026-09-29-intent-gate-adoption.md`;Agent-Native `docs/records/2026-09-29-intentgate-adoption.md`。
|
||||
|
||||
---
|
||||
|
||||
## 七、追加轮:目录驱动的编排(E2/E4 + G3–G5,组合 AI `3674dc4` ← N `292142a` ← S `5454f74`)
|
||||
|
||||
本轮外挂意图在第一节基础上新增 `demo.run.status_query`(必填槽 run_id:pattern `run-\d+`、label 运行编号、提问建议"要查询哪个运行?请提供 run 编号")。
|
||||
|
||||
| 用例 | 输入 / 场景 | 实际结果 | 期望 | 判定 |
|
||||
|---|---|---|---|---|
|
||||
| 槽位归一+校验链 | `查询任务 RUN-42 的进度` | slot run_id 归一为 `run-42`,通过自定义 validator | 抽取→归一→校验链生效 | ✅ |
|
||||
| 槽位非法区分 | `查询任务 42 的进度` | `InvalidSlots[run_id]="validator: 须以 run- 开头"`,不进 Missing | "给错了"与"没给"分离 | ✅ |
|
||||
| 枚举/格式校验 | `环境: dev 日期: 明天` | env 枚举不合法、date 格式不合法,均进 InvalidSlots | 确定性校验 | ✅ |
|
||||
| 目录渲染 | intentctl catalog | 目录含能力/必填槽位/提问建议;守卫 spec 不出现 | 规划者可消费 | ✅ |
|
||||
| LLM 规划器/leader 提示词 | buildPrompt/leaderPrompt | 目录与提问建议注入;未配置时不出现 | G5 提问模板落地 | ✅ |
|
||||
| 计划确定性校验 | 缺目录/未知意图/缺必填槽/枚举违规 | 四类全部 fail-closed 拒绝计划(错误含澄清建议) | 防领导幻觉闭环 | ✅ |
|
||||
| E2E 混合步 F | `做一次混合演示`(蓝图=系统功能步+agent 步,EchoRunner) | completed 2 步:sys1 物化系统功能调用(intent_id/handler/slot 落盘),write1 走 agent 后端 | 系统功能不经智能体 | ✅ |
|
||||
| E2E fail-closed G | 同输入但未注册 handler | failed:`handler "taskrun.query_status" 未注册(fail-closed,不静默降级)` | 不静默降级 | ✅ |
|
||||
|
||||
三仓 vet/test/-race 全绿;既有 conformance 套件两轮均未改动即通过(等价回归持续成立);核心包 agent-intent 依赖 0 命中;一致性脚本全绿(Squad 8 项比对)。
|
||||
|
||||
@@ -7,8 +7,6 @@ require (
|
||||
logging-system v0.0.0
|
||||
)
|
||||
|
||||
require golang.org/x/text v0.29.0 // indirect
|
||||
|
||||
replace error-code-system => ./deps/error-code-system
|
||||
|
||||
replace logging-system => ./deps/logging-system
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
@@ -1,62 +0,0 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// boundedContains 词界匹配。规则分两边:
|
||||
// - 右缘(必须):命中的右侧不得与词条末字符同类——汉字对汉字、词字符对词字符。
|
||||
// 「配置」不命中「配置项」(右邻「项」为汉字)、「write」不命中「rewrites」。
|
||||
// - 左缘(仅拉丁词条):汉字结尾的词条不要求左界——中文没有天然词界,
|
||||
// 「改配置」「系统配置」里的「配置」仍是用户想说那个词;
|
||||
// 拉丁词条按经典 \b 要求两侧(rewrites 两侧的 write 都不算命中)。
|
||||
func boundedContains(haystack, term string) bool {
|
||||
if term == "" {
|
||||
return false
|
||||
}
|
||||
first, _ := utf8.DecodeRuneInString(term)
|
||||
last, _ := utf8.DecodeLastRuneInString(term)
|
||||
checkLeft := isWord(first) && !isHan(first) // 仅拉丁开头的词条要求左界
|
||||
for i := strings.Index(haystack, term); i >= 0; {
|
||||
end := i + len(term)
|
||||
if (!checkLeft || leftBounded(haystack, i, first)) && rightBounded(haystack, end, last) {
|
||||
return true
|
||||
}
|
||||
j := strings.Index(haystack[i+1:], term)
|
||||
if j < 0 {
|
||||
return false
|
||||
}
|
||||
i += 1 + j
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func leftBounded(s string, i int, termFirst rune) bool {
|
||||
if i == 0 {
|
||||
return true
|
||||
}
|
||||
prev, _ := utf8.DecodeLastRuneInString(s[:i])
|
||||
return !sameClass(prev, termFirst)
|
||||
}
|
||||
|
||||
func rightBounded(s string, end int, termLast rune) bool {
|
||||
if end >= len(s) {
|
||||
return true
|
||||
}
|
||||
next, _ := utf8.DecodeRuneInString(s[end:])
|
||||
return !sameClass(next, termLast)
|
||||
}
|
||||
|
||||
func sameClass(a, b rune) bool {
|
||||
return (isHan(a) && isHan(b)) || (isWord(a) && isWord(b))
|
||||
}
|
||||
|
||||
func isHan(r rune) bool {
|
||||
return unicode.Is(unicode.Han, r)
|
||||
}
|
||||
|
||||
func isWord(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBoundaryCJKFalsePositiveSuppressed:词界开启后,「配置」不命中「配置项」(I3)。
|
||||
func TestBoundaryCJKFalsePositiveSuppressed(t *testing.T) {
|
||||
mk := func(boundary bool) *Snapshot {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(IntentSpec{
|
||||
ID: "demo.configure", Domain: "demo", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"配置"}, Boundary: boundary},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reg.Snapshot()
|
||||
}
|
||||
res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "配置项在哪里", mk(true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchFallback {
|
||||
t.Fatalf("boundary on: branch = %s, want fallback", res.Branch)
|
||||
}
|
||||
// 词界关闭(旧行为):子串命中依旧,证明差异来自 boundary。
|
||||
res, err = NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "配置项在哪里", mk(false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchMatched {
|
||||
t.Fatalf("boundary off: branch = %s, want matched", res.Branch)
|
||||
}
|
||||
// 词界开启时,独立成词的「配置」照常命中。
|
||||
res, err = NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "我要改配置", mk(true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchMatched {
|
||||
t.Fatalf("boundary on, standalone term: branch = %s, want matched", res.Branch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBoundaryLatinWordFlank:拉丁词界——write 不命中 rewrites。
|
||||
func TestBoundaryLatinWordFlank(t *testing.T) {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(IntentSpec{
|
||||
ID: "demo.write", Domain: "demo", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"write"}, Boundary: true},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := reg.Snapshot()
|
||||
res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "rewrites everything", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchFallback {
|
||||
t.Fatalf("branch = %s, want fallback", res.Branch)
|
||||
}
|
||||
res, err = NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "please write this", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchMatched {
|
||||
t.Fatalf("branch = %s, want matched", res.Branch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTieBreakByPriority:同分时高 Priority 胜出(I5);同分同 Priority 仍标歧。
|
||||
func TestTieBreakByPriority(t *testing.T) {
|
||||
mk := func(p1, p2 int) *Snapshot {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(IntentSpec{ID: "demo.alpha", Domain: "demo", Version: 1, Priority: p1, Matcher: MatchSpec{IncludesAny: []string{"同分词"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Register(IntentSpec{ID: "demo.beta", Domain: "demo", Version: 1, Priority: p2, Matcher: MatchSpec{IncludesAny: []string{"同分词"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reg.Snapshot()
|
||||
}
|
||||
res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "同分词", mk(10, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.alpha" {
|
||||
t.Fatalf("tie-break: branch = %s top = %+v", res.Branch, res.Top)
|
||||
}
|
||||
// 同分同 Priority:仍 ambiguous。
|
||||
res, err = NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "同分词", mk(5, 5))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchAmbiguous {
|
||||
t.Fatalf("same priority: branch = %s, want ambiguous", res.Branch)
|
||||
}
|
||||
// Explain 与裁决同规则。
|
||||
tr := mk(10, 0).Explain("同分词")
|
||||
if tr.Branch != BranchMatched || tr.TopID != "demo.alpha" {
|
||||
t.Fatalf("explain = %+v", tr)
|
||||
}
|
||||
}
|
||||
+2
-26
@@ -1,8 +1,6 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
@@ -47,7 +45,7 @@ func (s *Snapshot) Catalog() []CatalogSpec {
|
||||
Risk: cs.spec.Risk,
|
||||
RequiresConfirm: cs.spec.RequiresConfirm,
|
||||
Handler: cs.spec.Handler,
|
||||
Metadata: catalogMetadata(cs.spec.Metadata),
|
||||
Metadata: cs.spec.Metadata,
|
||||
}
|
||||
if view.Risk == "" {
|
||||
view.Risk = RiskLow
|
||||
@@ -85,7 +83,7 @@ func (s *Snapshot) CatalogMarkdown() string {
|
||||
if sp.RequiresConfirm {
|
||||
confirm = "是"
|
||||
}
|
||||
fmt.Fprintf(&b, "- 风险:%s;执行前需人工确认:%s\n", sp.Risk, confirm)
|
||||
fmt.Fprintf(&b, "- 风险:%s;执行前需人工确认:%s;handler:%s\n", sp.Risk, confirm, sp.Handler)
|
||||
for _, sl := range sp.Slots {
|
||||
req := "选填"
|
||||
if sl.Required {
|
||||
@@ -121,25 +119,3 @@ func specSlotFormat(spec IntentSpec, key string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CatalogTopK 取目录的前 k 条:按 Priority 降序、ID 升序(确定性排序;
|
||||
// 排序口径是 priority,不是语义相关度——相关度排序由宿主经 examples 自行计算)。
|
||||
// k <= 0 时等同 Catalog() 全量。
|
||||
func (s *Snapshot) CatalogTopK(k int) []CatalogSpec {
|
||||
all := s.Catalog()
|
||||
if k <= 0 || len(all) <= k {
|
||||
return all
|
||||
}
|
||||
// Priority 不在目录视图里;用 spec 表内的 priority 排序。
|
||||
prio := map[string]int{}
|
||||
for _, cs := range s.specs {
|
||||
prio[cs.spec.ID] = cs.spec.Priority
|
||||
}
|
||||
sort.SliceStable(all, func(i, j int) bool {
|
||||
if prio[all[i].ID] != prio[all[j].ID] {
|
||||
return prio[all[i].ID] > prio[all[j].ID]
|
||||
}
|
||||
return all[i].ID < all[j].ID
|
||||
})
|
||||
return all[:k]
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ import "context"
|
||||
// 返回错误时识别保持 Ambiguous,不会使 Recognize 失败(识别确定性不受实现影响)。
|
||||
// 宿主应保证 Refine 幂等且无副作用:同样的输入与候选应得到同样的重排结果。
|
||||
type Classifier interface {
|
||||
// Refine 对候选重打分/重排。返回的 ID 集合必须与输入完全相同且不能重复;
|
||||
// Confidence 必须是有限值并落在 [0,1],越界会被拒绝而不是截断。
|
||||
// 除 Confidence 以外的字段会被按 ID 从原候选重建,改写不生效。
|
||||
// 校验失败时识别保持 Ambiguous。
|
||||
// Refine 对候选重打分/重排。返回的切片应包含与输入相同的一组候选
|
||||
// (可更新 Confidence 与顺序);快照信息已随候选 ID 携带。
|
||||
Refine(ctx context.Context, input string, candidates []Candidate) ([]Candidate, error)
|
||||
}
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CommandKind 是一条路由指令的类型。
|
||||
type CommandKind string
|
||||
|
||||
const (
|
||||
// CommandStart 启动一个动作(流程/命令/蓝图/处理器),携带已抽取槽位。
|
||||
CommandStart CommandKind = "start"
|
||||
// CommandSetSlot 记录一个槽位值(多轮澄清/预期回答的落槽)。
|
||||
CommandSetSlot CommandKind = "set_slot"
|
||||
// CommandChangeSlot 改写一个已有槽位值。
|
||||
CommandChangeSlot CommandKind = "change_slot"
|
||||
// CommandCancel 取消某个进行中的动作(否定语义的命令层表达)。
|
||||
CommandCancel CommandKind = "cancel"
|
||||
// CommandClarify 向用户澄清:缺槽位或候选消歧。
|
||||
CommandClarify CommandKind = "clarify"
|
||||
// CommandChitchat 闲聊(不由规则漏斗产出;留给语义层与宿主映射)。
|
||||
CommandChitchat CommandKind = "chitchat"
|
||||
// CommandHandoff 转人工(不由规则漏斗产出;留给语义层与宿主映射)。
|
||||
CommandHandoff CommandKind = "handoff"
|
||||
)
|
||||
|
||||
// ActionRef 指向一个可执行动作。auto 表示由宿主在命令总线与工作流之间
|
||||
// 裁决(与现网「意图 ID 同时是命令 ID 与 workflow IntentID」的现状兼容);
|
||||
// handler 与 blueprint 来自 spec 字段的显式声明;command/workflow 预留给
|
||||
// spec.action 显式声明(WP2)。
|
||||
type ActionRef struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
const (
|
||||
ActionAuto = "auto"
|
||||
ActionHandler = "handler"
|
||||
ActionBlueprint = "blueprint"
|
||||
ActionCommand = "command"
|
||||
ActionWorkflow = "workflow"
|
||||
)
|
||||
|
||||
// Command 是一条对宿主路由层的指令。一次识别可产出多条命令
|
||||
// (如先 SetSlot 后 Start),宿主按序消费。
|
||||
type Command struct {
|
||||
Kind CommandKind `json:"kind"`
|
||||
Action ActionRef `json:"action,omitempty"`
|
||||
Slots map[string]string `json:"slots,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Missing []string `json:"missing,omitempty"`
|
||||
Question string `json:"question,omitempty"`
|
||||
Candidates []string `json:"candidates,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
}
|
||||
|
||||
// actionOf 从候选推导动作引用:spec.action 显式声明优先,
|
||||
// 其后是 blueprint 键、Handler,都没有时回落 auto(宿主裁决)。
|
||||
func actionOf(c Candidate) ActionRef {
|
||||
if c.Action != nil {
|
||||
return *c.Action
|
||||
}
|
||||
if bp := strings.TrimSpace(c.Metadata["blueprint"]); bp != "" {
|
||||
return ActionRef{Kind: ActionBlueprint, ID: bp}
|
||||
}
|
||||
if h := strings.TrimSpace(c.Handler); h != "" {
|
||||
return ActionRef{Kind: ActionHandler, ID: h}
|
||||
}
|
||||
return ActionRef{Kind: ActionAuto, ID: c.ID}
|
||||
}
|
||||
|
||||
// deriveCommands 把识别结果展开为命令列表(纯函数、确定性)。
|
||||
// 规则:
|
||||
// - guard_hit:不产出命令(守卫语义是「识别即拒绝」,由宿主处理;
|
||||
// Cancel 命令留给声明 action 的意图与语义层,不在规则漏斗内代为表达)。
|
||||
// - expectation_hit:每个已填槽位一条 SetSlot;槽位齐全且合法追加 Start,
|
||||
// 否则追加 Clarify(缺槽/非法)。
|
||||
// - matched:槽位齐全且合法 → 单条 Start;有缺失/非法 → Start(携带已抽槽,
|
||||
// 宿主可带槽启动并追问,与现网行为一致)+ Clarify。
|
||||
// - ambiguous:单条 Clarify(候选消歧,按现有候选序)。
|
||||
// - fallback:不产出命令(交给宿主默认通道;Chitchat/Handoff 不由规则漏斗产出)。
|
||||
func deriveCommands(res Result, snap *Snapshot) []Command {
|
||||
if res.Top == nil {
|
||||
if res.Branch == BranchAmbiguous && len(res.Candidates) > 1 {
|
||||
ids := make([]string, 0, len(res.Candidates))
|
||||
for _, c := range res.Candidates {
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
return []Command{{Kind: CommandClarify, Candidates: ids}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
top := res.Top
|
||||
complete := len(top.MissingRequiredSlots) == 0 && len(top.InvalidSlots) == 0
|
||||
switch res.Branch {
|
||||
case BranchExpectationHit:
|
||||
var cmds []Command
|
||||
for _, k := range sortedKeys(top.Slots) {
|
||||
cmds = append(cmds, Command{Kind: CommandSetSlot, Name: k, Value: top.Slots[k]})
|
||||
}
|
||||
if complete {
|
||||
cmds = append(cmds, Command{Kind: CommandStart, Action: actionOf(*top), Slots: top.Slots})
|
||||
} else {
|
||||
cmds = append(cmds, clarifyOf(*top, snap))
|
||||
}
|
||||
return cmds
|
||||
case BranchMatched:
|
||||
start := Command{Kind: CommandStart, Action: actionOf(*top), Slots: top.Slots}
|
||||
if complete {
|
||||
return []Command{start}
|
||||
}
|
||||
return []Command{start, clarifyOf(*top, snap)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clarifyOf 组装澄清命令:Missing 为必填缺失与非法槽位的合并(确定性排序);
|
||||
// Question 取第一个有 Question 配置的缺失/非法槽位的建议提问(查得到 spec 时)。
|
||||
func clarifyOf(top Candidate, snap *Snapshot) Command {
|
||||
invalid := sortedKeys(top.InvalidSlots)
|
||||
missing := append(append([]string(nil), top.MissingRequiredSlots...), invalid...)
|
||||
var question string
|
||||
if snap != nil {
|
||||
if spec, ok := snap.SpecByID(top.ID); ok {
|
||||
asked := map[string]bool{}
|
||||
for _, k := range missing {
|
||||
if asked[k] {
|
||||
continue
|
||||
}
|
||||
asked[k] = true
|
||||
for _, s := range spec.Slots {
|
||||
if s.Key == k && strings.TrimSpace(s.Question) != "" {
|
||||
question = s.Question
|
||||
break
|
||||
}
|
||||
}
|
||||
if question != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Command{Kind: CommandClarify, Missing: missing, Question: question}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// buildCommandSnap 组装命令测试用快照:一个带必填槽位的意图与一个无槽位意图。
|
||||
func buildCommandSnap(t *testing.T) *Snapshot {
|
||||
t.Helper()
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(IntentSpec{
|
||||
ID: "report.generate", Domain: "report", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
|
||||
Slots: []SlotSpec{
|
||||
{Key: "topic", Required: true, Pattern: `主题是(.+)`, Question: "要查询哪个主题?"},
|
||||
},
|
||||
Handler: "reportHandler",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Register(IntentSpec{
|
||||
ID: "report.chat", Domain: "report", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"闲聊一下"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reg.Snapshot()
|
||||
}
|
||||
|
||||
func recognize(t *testing.T, snap *Snapshot, input string) Result {
|
||||
t.Helper()
|
||||
res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), input, snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// TestCommandsMatchedFullSlots:槽位齐全 → 单条 Start,ActionRef 取 Handler。
|
||||
func TestCommandsMatchedFullSlots(t *testing.T) {
|
||||
snap := buildCommandSnap(t)
|
||||
res := recognize(t, snap, "帮我生成报告,主题是电机")
|
||||
if res.Branch != BranchMatched {
|
||||
t.Fatalf("branch = %s", res.Branch)
|
||||
}
|
||||
if len(res.Commands) != 1 {
|
||||
t.Fatalf("commands = %+v", res.Commands)
|
||||
}
|
||||
cmd := res.Commands[0]
|
||||
if cmd.Kind != CommandStart || cmd.Action.Kind != ActionHandler || cmd.Action.ID != "reportHandler" {
|
||||
t.Fatalf("start = %+v", cmd)
|
||||
}
|
||||
if cmd.Slots["topic"] != "电机" {
|
||||
t.Fatalf("slots = %v", cmd.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommandsMatchedMissingSlot:必填缺失 → Start(带已抽槽)+ Clarify(含建议提问)。
|
||||
func TestCommandsMatchedMissingSlot(t *testing.T) {
|
||||
snap := buildCommandSnap(t)
|
||||
res := recognize(t, snap, "帮我生成报告")
|
||||
if len(res.Commands) != 2 {
|
||||
t.Fatalf("commands = %+v", res.Commands)
|
||||
}
|
||||
start, clarify := res.Commands[0], res.Commands[1]
|
||||
if start.Kind != CommandStart {
|
||||
t.Fatalf("first = %+v", start)
|
||||
}
|
||||
if clarify.Kind != CommandClarify || len(clarify.Missing) != 1 || clarify.Missing[0] != "topic" {
|
||||
t.Fatalf("clarify = %+v", clarify)
|
||||
}
|
||||
if clarify.Question != "要查询哪个主题?" {
|
||||
t.Fatalf("question = %q", clarify.Question)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommandsExpectationSetSlotPlusStart:预期命中 → SetSlot + Start(验收形态)。
|
||||
func TestCommandsExpectationSetSlotPlusStart(t *testing.T) {
|
||||
snap := buildCommandSnap(t)
|
||||
exp := listExpectation{ids: []string{"report.generate"}}
|
||||
res, err := NewRecognizer(Options{}, exp, nil).Recognize(context.Background(), "生成报告,主题是电机", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchExpectationHit {
|
||||
t.Fatalf("branch = %s", res.Branch)
|
||||
}
|
||||
var setSlot, start *Command
|
||||
for i := range res.Commands {
|
||||
switch res.Commands[i].Kind {
|
||||
case CommandSetSlot:
|
||||
setSlot = &res.Commands[i]
|
||||
case CommandStart:
|
||||
start = &res.Commands[i]
|
||||
}
|
||||
}
|
||||
if setSlot == nil || start == nil {
|
||||
t.Fatalf("commands = %+v", res.Commands)
|
||||
}
|
||||
if setSlot.Name != "topic" || setSlot.Value != "电机" {
|
||||
t.Fatalf("set_slot = %+v", *setSlot)
|
||||
}
|
||||
if start.Action.Kind != ActionHandler || start.Slots["topic"] != "电机" {
|
||||
t.Fatalf("start = %+v", *start)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommandsAmbiguousClarify:标歧 → 单条 Clarify 携带候选 ID。
|
||||
func TestCommandsAmbiguousClarify(t *testing.T) {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(IntentSpec{ID: "demo.alpha", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"模糊词"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Register(IntentSpec{ID: "demo.beta", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"模糊词"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := recognize(t, reg.Snapshot(), "模糊词")
|
||||
if res.Branch != BranchAmbiguous {
|
||||
t.Fatalf("branch = %s", res.Branch)
|
||||
}
|
||||
if len(res.Commands) != 1 || res.Commands[0].Kind != CommandClarify {
|
||||
t.Fatalf("commands = %+v", res.Commands)
|
||||
}
|
||||
if len(res.Commands[0].Candidates) != 2 {
|
||||
t.Fatalf("candidates = %v", res.Commands[0].Candidates)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommandsFallbackEmpty:fallback 不产出命令。
|
||||
func TestCommandsFallbackEmpty(t *testing.T) {
|
||||
snap := buildCommandSnap(t)
|
||||
res := recognize(t, snap, "完全不相关的一句话")
|
||||
if res.Branch != BranchFallback || len(res.Commands) != 0 {
|
||||
t.Fatalf("fallback commands = %+v", res.Commands)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActionRefDerivation:blueprint 键优先于 Handler;都没有回落 auto。
|
||||
func TestActionRefDerivation(t *testing.T) {
|
||||
if got := actionOf(Candidate{ID: "x", Metadata: map[string]string{"blueprint": "bp-1"}, Handler: "h"}); got.Kind != ActionBlueprint || got.ID != "bp-1" {
|
||||
t.Fatalf("blueprint = %+v", got)
|
||||
}
|
||||
if got := actionOf(Candidate{ID: "x", Handler: "h"}); got.Kind != ActionHandler || got.ID != "h" {
|
||||
t.Fatalf("handler = %+v", got)
|
||||
}
|
||||
if got := actionOf(Candidate{ID: "x"}); got.Kind != ActionAuto || got.ID != "x" {
|
||||
t.Fatalf("auto = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type listExpectation struct{ ids []string }
|
||||
|
||||
func (s listExpectation) Pending(ctx context.Context) *PendingExpectation {
|
||||
return &PendingExpectation{IntentIDs: s.ids}
|
||||
}
|
||||
|
||||
// TestSpecActionDeclaredWins:spec.action 显式声明在 ActionRef 推导中胜出(WP2)。
|
||||
func TestSpecActionDeclaredWins(t *testing.T) {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(IntentSpec{
|
||||
ID: "report.explicit", Domain: "report", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"显式动作"}},
|
||||
Action: &ActionRef{Kind: ActionWorkflow, ID: "wf-report"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := recognize(t, reg.Snapshot(), "显式动作")
|
||||
if len(res.Commands) != 1 || res.Commands[0].Kind != CommandStart {
|
||||
t.Fatalf("commands = %+v", res.Commands)
|
||||
}
|
||||
if got := res.Commands[0].Action; got.Kind != ActionWorkflow || got.ID != "wf-report" {
|
||||
t.Fatalf("action = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpecActionValidation:action.kind 非法与 id 为空都被拒绝。
|
||||
func TestSpecActionValidation(t *testing.T) {
|
||||
bad := IntentSpec{
|
||||
ID: "report.bad", Domain: "report", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"x"}},
|
||||
Action: &ActionRef{Kind: "bogus", ID: "y"},
|
||||
}
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(bad); err == nil {
|
||||
t.Fatal("bogus action kind accepted")
|
||||
}
|
||||
bad.Action = &ActionRef{Kind: ActionCommand, ID: ""}
|
||||
b2 := NewRegistryBuilder(Options{})
|
||||
if err := b2.Register(bad); err == nil {
|
||||
t.Fatal("empty action id accepted")
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CorpusCase 是评测用例:v1 单轮(input/expect_*)与 v2 多轮(turns)共用。
|
||||
// v1 字段在 Turns 为空时生效,并自动等价于一个单 turn 用例(不含命令断言)。
|
||||
type CorpusCase struct {
|
||||
Name string `json:"name"`
|
||||
Input string `json:"input,omitempty"`
|
||||
ExpectBranch string `json:"expect_branch,omitempty"`
|
||||
ExpectTop string `json:"expect_top,omitempty"`
|
||||
ExpectSlots map[string]string `json:"expect_slots,omitempty"`
|
||||
ExpectMissingSlots []string `json:"expect_missing_slots,omitempty"`
|
||||
Turns []CorpusTurn `json:"turns,omitempty"`
|
||||
}
|
||||
|
||||
// CorpusTurn 是多轮用例中的一轮:输入与逐轮期望(分支/top/命令列表)。
|
||||
type CorpusTurn struct {
|
||||
Input string `json:"input"`
|
||||
ExpectBranch string `json:"expect_branch,omitempty"`
|
||||
ExpectTop string `json:"expect_top,omitempty"`
|
||||
ExpectCommands []CommandExpect `json:"expect_commands,omitempty"`
|
||||
}
|
||||
|
||||
// CommandExpect 是对一条命令的期望;只有写出的字段参与断言。
|
||||
type CommandExpect struct {
|
||||
Kind string `json:"kind,omitempty"`
|
||||
ActionKind string `json:"action_kind,omitempty"`
|
||||
ActionID string `json:"action_id,omitempty"`
|
||||
Slots map[string]string `json:"slots,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Missing []string `json:"missing,omitempty"`
|
||||
Question string `json:"question,omitempty"`
|
||||
Candidates []string `json:"candidates,omitempty"`
|
||||
}
|
||||
|
||||
// TurnResult 一轮的实际结果与差异。
|
||||
type TurnResult struct {
|
||||
Index int
|
||||
Input string
|
||||
Branch string
|
||||
TopID string
|
||||
Commands []Command
|
||||
Mismatches []string
|
||||
}
|
||||
|
||||
// CaseResult 一个用例的全部轮次结果。Passed = 所有轮次无差异。
|
||||
type CaseResult struct {
|
||||
Name string
|
||||
Turns []TurnResult
|
||||
}
|
||||
|
||||
// Passed 报告用例是否全部轮次通过。
|
||||
func (c CaseResult) Passed() bool {
|
||||
for _, t := range c.Turns {
|
||||
if len(t.Mismatches) > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// chainedExpectation 模拟宿主的挂起预期链:上一轮 Top 有必填缺失或非法
|
||||
// 槽位时,下一轮挂起该意图(与产品「带槽启动后追问」的行为对应)。
|
||||
type chainedExpectation struct {
|
||||
next *PendingExpectation
|
||||
}
|
||||
|
||||
func (c *chainedExpectation) Pending(ctx context.Context) *PendingExpectation {
|
||||
return c.next
|
||||
}
|
||||
|
||||
// RunCorpusCase 执行一个用例的全部轮次(v1 用例自动包装为单轮)。
|
||||
// 识别器按轮次构建在链式预期上,保证 SetSlot+Start 这类组合可在评测中到达。
|
||||
func RunCorpusCase(ctx context.Context, snap *Snapshot, tc CorpusCase) CaseResult {
|
||||
turns := tc.Turns
|
||||
if len(turns) == 0 {
|
||||
turns = []CorpusTurn{{Input: tc.Input, ExpectBranch: tc.ExpectBranch, ExpectTop: tc.ExpectTop}}
|
||||
}
|
||||
exp := &chainedExpectation{}
|
||||
rec := NewRecognizer(Options{}, exp, nil)
|
||||
out := CaseResult{Name: tc.Name}
|
||||
for i, turn := range turns {
|
||||
tr := TurnResult{Index: i, Input: turn.Input}
|
||||
res, err := rec.Recognize(ctx, turn.Input, snap)
|
||||
if err != nil {
|
||||
tr.Mismatches = append(tr.Mismatches, "识别错误:"+err.Error())
|
||||
out.Turns = append(out.Turns, tr)
|
||||
break
|
||||
}
|
||||
tr.Branch = string(res.Branch)
|
||||
if res.Top != nil {
|
||||
tr.TopID = res.Top.ID
|
||||
}
|
||||
tr.Commands = res.Commands
|
||||
tr.Mismatches = matchTurn(turn, res)
|
||||
out.Turns = append(out.Turns, tr)
|
||||
exp.next = nil
|
||||
if res.Top != nil && (len(res.Top.MissingRequiredSlots) > 0 || len(res.Top.InvalidSlots) > 0) {
|
||||
exp.next = &PendingExpectation{IntentIDs: []string{res.Top.ID}, Reason: "eval chained"}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func matchTurn(turn CorpusTurn, res Result) []string {
|
||||
var mm []string
|
||||
if turn.ExpectBranch != "" && string(res.Branch) != turn.ExpectBranch {
|
||||
mm = append(mm, fmt.Sprintf("branch=%s(期望 %s)", res.Branch, turn.ExpectBranch))
|
||||
}
|
||||
if turn.ExpectTop != "" {
|
||||
got := ""
|
||||
if res.Top != nil {
|
||||
got = res.Top.ID
|
||||
}
|
||||
if got != turn.ExpectTop {
|
||||
mm = append(mm, fmt.Sprintf("top=%s(期望 %s)", got, turn.ExpectTop))
|
||||
}
|
||||
}
|
||||
if turn.ExpectCommands != nil {
|
||||
mm = append(mm, matchCommands(turn.ExpectCommands, res.Commands)...)
|
||||
}
|
||||
return mm
|
||||
}
|
||||
|
||||
func matchCommands(want []CommandExpect, got []Command) []string {
|
||||
if len(want) != len(got) {
|
||||
return []string{fmt.Sprintf("commands 数量=%d(期望 %d)", len(got), len(want))}
|
||||
}
|
||||
var mm []string
|
||||
for i, w := range want {
|
||||
g := got[i]
|
||||
if w.Kind != "" && string(g.Kind) != w.Kind {
|
||||
mm = append(mm, fmt.Sprintf("cmd[%d].kind=%s(期望 %s)", i, g.Kind, w.Kind))
|
||||
continue
|
||||
}
|
||||
if w.ActionKind != "" && g.Action.Kind != w.ActionKind {
|
||||
mm = append(mm, fmt.Sprintf("cmd[%d].action.kind=%s(期望 %s)", i, g.Action.Kind, w.ActionKind))
|
||||
}
|
||||
if w.ActionID != "" && g.Action.ID != w.ActionID {
|
||||
mm = append(mm, fmt.Sprintf("cmd[%d].action.id=%s(期望 %s)", i, g.Action.ID, w.ActionID))
|
||||
}
|
||||
for k, v := range w.Slots {
|
||||
if g.Slots[k] != v {
|
||||
mm = append(mm, fmt.Sprintf("cmd[%d].slots[%s]=%q(期望 %q)", i, k, g.Slots[k], v))
|
||||
}
|
||||
}
|
||||
if w.Name != "" && g.Name != w.Name {
|
||||
mm = append(mm, fmt.Sprintf("cmd[%d].name=%s(期望 %s)", i, g.Name, w.Name))
|
||||
}
|
||||
if w.Value != "" && g.Value != w.Value {
|
||||
mm = append(mm, fmt.Sprintf("cmd[%d].value=%s(期望 %s)", i, g.Value, w.Value))
|
||||
}
|
||||
for _, m := range w.Missing {
|
||||
if !containsString(g.Missing, m) {
|
||||
mm = append(mm, fmt.Sprintf("cmd[%d].missing 缺 %s", i, m))
|
||||
}
|
||||
}
|
||||
if w.Question != "" && g.Question != w.Question {
|
||||
mm = append(mm, fmt.Sprintf("cmd[%d].question=%q(期望 %q)", i, g.Question, w.Question))
|
||||
}
|
||||
for _, c := range w.Candidates {
|
||||
if !containsString(g.Candidates, c) {
|
||||
mm = append(mm, fmt.Sprintf("cmd[%d].candidates 缺 %s", i, c))
|
||||
}
|
||||
}
|
||||
}
|
||||
return mm
|
||||
}
|
||||
|
||||
func containsString(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CorpusMetrics 评测指标(I2):按意图标签(matched/top)计 TP/FP/FN。
|
||||
// 期望非 matched(fallback/ambiguous)的轮次作负例、不进指标。
|
||||
type CorpusMetrics struct {
|
||||
TP, FP, FN int
|
||||
}
|
||||
|
||||
// Precision 精确率;分母为 0 时返回 (0, false)(未定义,由调用方决定展示)。
|
||||
func (m CorpusMetrics) Precision() (float64, bool) {
|
||||
if m.TP+m.FP == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return float64(m.TP) / float64(m.TP+m.FP), true
|
||||
}
|
||||
|
||||
// Recall 召回率;分母为 0 时返回 (0, false)。
|
||||
func (m CorpusMetrics) Recall() (float64, bool) {
|
||||
if m.TP+m.FN == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return float64(m.TP) / float64(m.TP+m.FN), true
|
||||
}
|
||||
|
||||
// MetricsOf 逐轮累计指标(I2):
|
||||
// - TP:期望 matched/top=X 且实际 matched/top=X;
|
||||
// - FP:实际 matched/top=Y 但期望的标签不是 matched/top=Y
|
||||
// (期望别的 top、期望 fallback/ambiguous、或期望 matched 但 top 不同);
|
||||
// - FN:期望 matched/top=X 但实际没拿到它(branch 不对或 top 不同)。
|
||||
// 只有写了 expect_top 的轮次进入指标(意图识别通行口径)。
|
||||
func MetricsOf(cases []CorpusCase, results []CaseResult) CorpusMetrics {
|
||||
var m CorpusMetrics
|
||||
byName := map[string]CorpusCase{}
|
||||
for _, tc := range cases {
|
||||
byName[tc.Name] = tc
|
||||
}
|
||||
for _, res := range results {
|
||||
tc, ok := byName[res.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
turns := tc.Turns
|
||||
if len(turns) == 0 {
|
||||
turns = []CorpusTurn{{ExpectBranch: tc.ExpectBranch, ExpectTop: tc.ExpectTop}}
|
||||
}
|
||||
for i, turn := range turns {
|
||||
if turn.ExpectTop == "" || i >= len(res.Turns) {
|
||||
continue
|
||||
}
|
||||
got := res.Turns[i]
|
||||
wantMatched := turn.ExpectBranch == "" || turn.ExpectBranch == "matched" || turn.ExpectBranch == "expectation_hit"
|
||||
gotMatched := got.Branch == "matched" || got.Branch == "expectation_hit"
|
||||
if gotMatched && got.TopID == turn.ExpectTop && wantMatched {
|
||||
m.TP++
|
||||
continue
|
||||
}
|
||||
if gotMatched && got.TopID != "" {
|
||||
// 错配同时是假阳(拿了错的)与假阴(期望的没拿到)。
|
||||
m.FP++
|
||||
if wantMatched {
|
||||
m.FN++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if wantMatched {
|
||||
m.FN++
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEvalMultiTurnCorpus 是 I1 验收载体:v2 多轮语料含「SetSlot 加 Start」
|
||||
// 用例并通过(链式预期把第二轮转进 expectation_hit)。
|
||||
func TestEvalMultiTurnCorpus(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/corpus_multiturn.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc struct {
|
||||
Cases []CorpusCase `json:"cases"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(doc.Cases) == 0 {
|
||||
t.Fatal("empty corpus")
|
||||
}
|
||||
_, snap := buildDemoRegistry(t)
|
||||
for _, tc := range doc.Cases {
|
||||
result := RunCorpusCase(context.Background(), snap, tc)
|
||||
if !result.Passed() {
|
||||
for _, tr := range result.Turns {
|
||||
for _, mm := range tr.Mismatches {
|
||||
t.Errorf("case %s turn %d (%q): %s", tc.Name, tr.Index, tr.Input, mm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCorpusV1WrapsAsSingleTurn:v1 单轮形态经同一执行器等价运行(兼容承诺)。
|
||||
func TestCorpusV1WrapsAsSingleTurn(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
tc := CorpusCase{Name: "v1", Input: "你好呀", ExpectBranch: "matched", ExpectTop: "demo.greet.hello"}
|
||||
result := RunCorpusCase(context.Background(), snap, tc)
|
||||
if !result.Passed() {
|
||||
t.Fatalf("v1 wrap: %+v", result.Turns)
|
||||
}
|
||||
if len(result.Turns) != 1 || result.Turns[0].Branch != "matched" {
|
||||
t.Fatalf("turns = %+v", result.Turns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchCommandsFindsMismatches:命令断言的差异定位(kind/action/slot/name)。
|
||||
func TestMatchCommandsFindsMismatches(t *testing.T) {
|
||||
got := []Command{
|
||||
{Kind: CommandStart, Action: ActionRef{Kind: ActionHandler, ID: "h"}, Slots: map[string]string{"topic": "电机"}},
|
||||
}
|
||||
mm := matchCommands([]CommandExpect{
|
||||
{Kind: "start", ActionKind: ActionHandler, ActionID: "other"},
|
||||
}, got)
|
||||
if len(mm) != 1 || !strings.Contains(mm[0], "action.id") {
|
||||
t.Fatalf("mm = %v", mm)
|
||||
}
|
||||
mm = matchCommands([]CommandExpect{
|
||||
{Kind: "start", Slots: map[string]string{"topic": "水泵"}},
|
||||
}, got)
|
||||
if len(mm) != 1 || !strings.Contains(mm[0], "slots") {
|
||||
t.Fatalf("mm = %v", mm)
|
||||
}
|
||||
mm = matchCommands([]CommandExpect{
|
||||
{Kind: "start", ActionKind: ActionHandler, ActionID: "h", Slots: map[string]string{"topic": "电机"}},
|
||||
}, got)
|
||||
if len(mm) != 0 {
|
||||
t.Fatalf("mm = %v", mm)
|
||||
}
|
||||
if mm := matchCommands([]CommandExpect{{Kind: "start"}, {Kind: "clarify"}}, got); len(mm) != 1 {
|
||||
t.Fatalf("count mismatch = %v", mm)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetricsOfCounts:指标按意图标签计 TP/FP/FN(I2 验收的指标形态)。
|
||||
func TestMetricsOfCounts(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
data, err := os.ReadFile("testdata/corpus_multiturn.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc struct {
|
||||
Cases []CorpusCase `json:"cases"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var results []CaseResult
|
||||
for _, tc := range doc.Cases {
|
||||
results = append(results, RunCorpusCase(context.Background(), snap, tc))
|
||||
}
|
||||
m := MetricsOf(doc.Cases, results)
|
||||
// 通过的夹具:指标应当全 TP(precision=recall=1)。
|
||||
if m.FP != 0 || m.FN != 0 {
|
||||
t.Fatalf("metrics = %+v", m)
|
||||
}
|
||||
p, pok := m.Precision()
|
||||
r, rok := m.Recall()
|
||||
if !pok || !rok || p != 1.0 || r != 1.0 {
|
||||
t.Fatalf("precision=%v recall=%v (m=%+v)", p, r, m)
|
||||
}
|
||||
if m.TP < 8 {
|
||||
t.Fatalf("TP = %d, want >= 8(夹具内带 top 期望的轮次)", m.TP)
|
||||
}
|
||||
|
||||
// 手工构造一个 FP+FN 用例验证计数方向。
|
||||
bad := []CorpusCase{{Name: "bad", Turns: []CorpusTurn{
|
||||
{Input: "查询任务 42 的进度", ExpectBranch: "matched", ExpectTop: "demo.greet.hello"},
|
||||
}}}
|
||||
got := []CaseResult{RunCorpusCase(context.Background(), snap, bad[0])}
|
||||
m2 := MetricsOf(bad, got)
|
||||
// 实际 matched status_query 但期望 greet.hello → 1 FP + 1 FN。
|
||||
if m2.TP != 0 || m2.FP != 1 || m2.FN != 1 {
|
||||
t.Fatalf("bad metrics = %+v", m2)
|
||||
}
|
||||
p2, _ := m2.Precision()
|
||||
r2, _ := m2.Recall()
|
||||
if p2 != 0 || r2 != 0 {
|
||||
t.Fatalf("p=%v r=%v, want 0/0", p2, r2)
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// foldMatchText 做识别用折叠。nfkc 只做兼容等价(全角 ASCII、组合字符),
|
||||
// 不做繁简转换。caseSensitive 为真时保留大小写。
|
||||
func foldMatchText(s string, nfkc, caseSensitive bool) string {
|
||||
if nfkc {
|
||||
s = norm.NFKC.String(s)
|
||||
}
|
||||
if !caseSensitive {
|
||||
s = strings.ToLower(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// foldInput 返回用于匹配的文本:第一段保留大小写(可选 NFKC),第二段是其小写。
|
||||
func (s *Snapshot) foldInput(input string) (matchInput, folded string) {
|
||||
if s == nil {
|
||||
return input, strings.ToLower(input)
|
||||
}
|
||||
matchInput = foldMatchText(input, s.nfkc, true)
|
||||
return matchInput, strings.ToLower(matchInput)
|
||||
}
|
||||
|
||||
func cloneStrings(in []string) []string {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), in...)
|
||||
}
|
||||
|
||||
func cloneIntentSpec(in IntentSpec) IntentSpec {
|
||||
out := in
|
||||
out.Matcher.IncludesAny = cloneStrings(in.Matcher.IncludesAny)
|
||||
out.Matcher.IncludesAll = cloneStrings(in.Matcher.IncludesAll)
|
||||
out.Matcher.ExcludesAny = cloneStrings(in.Matcher.ExcludesAny)
|
||||
out.Matcher.Patterns = cloneStrings(in.Matcher.Patterns)
|
||||
if in.Slots != nil {
|
||||
out.Slots = append([]SlotSpec(nil), in.Slots...)
|
||||
for i := range out.Slots {
|
||||
out.Slots[i].Enum = cloneStrings(in.Slots[i].Enum)
|
||||
out.Slots[i].Normalize = cloneStrings(in.Slots[i].Normalize)
|
||||
}
|
||||
}
|
||||
if in.Metadata != nil {
|
||||
out.Metadata = make(map[string]string, len(in.Metadata))
|
||||
for k, v := range in.Metadata {
|
||||
out.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// catalogMetadataAllow 是目录与 Markdown 允许带出的 metadata 键。
|
||||
// handler 仍留在 CatalogSpec 供执行器按名分派,但不写入 Markdown。
|
||||
var catalogMetadataAllow = map[string]bool{
|
||||
"blueprint": true,
|
||||
"guidance": true,
|
||||
}
|
||||
|
||||
func catalogMetadata(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for k, v := range in {
|
||||
if catalogMetadataAllow[k] {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package intent
|
||||
|
||||
import "context"
|
||||
|
||||
// CommandGenerator 模型命令生成端口(I2):宿主用模型实现
|
||||
// (借鉴 Rasa 的 LLM command generators:只把相关流程交给生成器)。
|
||||
// 只在规则与语义都未命中(fallback)时被调用;实现自担延迟与成本。
|
||||
// 返回错误时识别保持 fallback,不会使 Recognize 失败。
|
||||
//
|
||||
// 库侧校验与 deriveCommands 同款:start 命令的 ActionRef 必须可解析——
|
||||
// auto/command/workflow/blueprint 的目标必须在 catalog 的 spec ID 集合内,
|
||||
// handler 的目标必须在 catalog 的 Handler 集合内;槽位键必须在目标 spec 的
|
||||
// 槽位键集合内(可解析时)。生成器幻觉一律 fail-closed 回 fallback。
|
||||
type CommandGenerator interface {
|
||||
// Generate 基于输入与能力目录产出命令列表(catalog 由调用方按 top-k 给)。
|
||||
Generate(ctx context.Context, input string, catalog []CatalogSpec) ([]Command, error)
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeScorer 按 examples 里是否包含特定子串给分(测试用确定性打分器)。
|
||||
type fakeScorer struct {
|
||||
scores map[string]float64 // key: examples[0]
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeScorer) Score(ctx context.Context, input string, examples []string) (float64, error) {
|
||||
if f.err != nil {
|
||||
return 0, f.err
|
||||
}
|
||||
if len(examples) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if s, ok := f.scores[examples[0]]; ok {
|
||||
return s, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func ambiguousFixture(t *testing.T, exA, exB []string) *Snapshot {
|
||||
t.Helper()
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(IntentSpec{ID: "demo.alpha", Domain: "demo", Version: 1, Priority: 5,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"模糊词"}}, Examples: exA}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Register(IntentSpec{ID: "demo.beta", Domain: "demo", Version: 1, Priority: 5,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"模糊词"}}, Examples: exB}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reg.Snapshot()
|
||||
}
|
||||
|
||||
// TestSemanticRerankResolvesAmbiguous:语义过阈且分差够 → matched(I2 分层)。
|
||||
func TestSemanticRerankResolvesAmbiguous(t *testing.T) {
|
||||
snap := ambiguousFixture(t, []string{"查运行进度"}, []string{"闲聊问候"})
|
||||
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": 0.95, "闲聊问候": 0.4}}}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "模糊词", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.alpha" {
|
||||
t.Fatalf("semantic rerank: %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Top.Reason, "refined_by=semantic") {
|
||||
t.Fatalf("reason = %q", res.Top.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSemanticBelowThresholdKeepsAmbiguous:全部不达阈 → 保持 ambiguous。
|
||||
func TestSemanticBelowThresholdKeepsAmbiguous(t *testing.T) {
|
||||
snap := ambiguousFixture(t, []string{"查运行进度"}, []string{"闲聊问候"})
|
||||
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": 0.5}}}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "模糊词", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchAmbiguous {
|
||||
t.Fatalf("below threshold: branch = %s", res.Branch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSemanticTieKeepsAmbiguous:两个过阈但分差不足 → 保持 ambiguous。
|
||||
func TestSemanticTieKeepsAmbiguous(t *testing.T) {
|
||||
snap := ambiguousFixture(t, []string{"查运行进度"}, []string{"查运行状态"})
|
||||
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": 0.9, "查运行状态": 0.89}}}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "模糊词", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchAmbiguous {
|
||||
t.Fatalf("tie: branch = %s", res.Branch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSemanticErrorKeepsAmbiguousAndCallsL2Next:打分错误 → ambiguous,且 L2 接管。
|
||||
func TestSemanticErrorKeepsAmbiguousAndCallsL2Next(t *testing.T) {
|
||||
snap := ambiguousFixture(t, []string{"查运行进度"}, nil)
|
||||
called := false
|
||||
cls := rewriteClassifier{fn: func(c []Candidate) []Candidate {
|
||||
called = true
|
||||
out := append([]Candidate(nil), c...)
|
||||
out[0].Confidence = 0.99
|
||||
out[1].Confidence = 0.1
|
||||
return out
|
||||
}}
|
||||
rec := NewRecognizer(Options{Semantic: fakeScorer{err: errors.New("embed down")}}, nil, cls)
|
||||
res, err := rec.Recognize(context.Background(), "模糊词", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("L2 should be consulted after semantic failure")
|
||||
}
|
||||
if res.Branch != BranchMatched || res.Top.ID != "demo.alpha" {
|
||||
t.Fatalf("L2 fallback after semantic error: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratorValidStartBecomesMatched:合法 start 命令 → matched + 合成 Top(I2)。
|
||||
func TestGeneratorValidStartBecomesMatched(t *testing.T) {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(IntentSpec{ID: "report.generate", Domain: "report", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
|
||||
Slots: []SlotSpec{{Key: "topic", Pattern: `主题是(.+)`}},
|
||||
Handler: "reportHandler"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gen := stubGenerator{cmds: []Command{
|
||||
{Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "report.generate"},
|
||||
Slots: map[string]string{"topic": "电机"}},
|
||||
}}
|
||||
rec := NewRecognizer(Options{Generator: gen}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "完全不相关的一句话", reg.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "report.generate" {
|
||||
t.Fatalf("generated: %+v", res)
|
||||
}
|
||||
if len(res.Commands) != 1 || res.Commands[0].Kind != CommandStart || res.Commands[0].Slots["topic"] != "电机" {
|
||||
t.Fatalf("commands = %+v", res.Commands)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratorInvalidOutputFallsBack:幻觉动作/槽位 → 保持 fallback(fail-closed)。
|
||||
func TestGeneratorInvalidOutputFallsBack(t *testing.T) {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(IntentSpec{ID: "report.generate", Domain: "report", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
|
||||
Slots: []SlotSpec{{Key: "topic", Pattern: `(.+)`}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := reg.Snapshot()
|
||||
cases := map[string][]Command{
|
||||
"unknown spec id": {
|
||||
{Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "ghost.flow"}},
|
||||
},
|
||||
"unknown kind": {
|
||||
{Kind: CommandStart, Action: ActionRef{Kind: "bogus", ID: "report.generate"}},
|
||||
},
|
||||
"unknown slot key": {
|
||||
{Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "report.generate"}, Slots: map[string]string{"bogus": "v"}},
|
||||
},
|
||||
"unknown cmd kind": {
|
||||
{Kind: "teleport"},
|
||||
},
|
||||
"empty": {},
|
||||
}
|
||||
for name, cmds := range cases {
|
||||
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: cmds}}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "完全不相关的一句话", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchFallback {
|
||||
t.Fatalf("%s: branch = %s, want fallback", name, res.Branch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratorErrorKeepsFallback:生成器错误 → 保持 fallback。
|
||||
func TestGeneratorErrorKeepsFallback(t *testing.T) {
|
||||
reg, err := NewRegistryBuilder(Options{}).Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := NewRecognizer(Options{Generator: stubGenerator{err: errors.New("model down")}}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "完全不相关的一句话", reg.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchFallback {
|
||||
t.Fatalf("branch = %s", res.Branch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCatalogTopK:按 priority 降序取 k(确定性口径)。
|
||||
func TestCatalogTopK(t *testing.T) {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(IntentSpec{ID: "demo.low", Domain: "demo", Version: 1, Priority: 1, Matcher: MatchSpec{IncludesAny: []string{"a"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Register(IntentSpec{ID: "demo.high", Domain: "demo", Version: 1, Priority: 9, Matcher: MatchSpec{IncludesAny: []string{"b"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Register(IntentSpec{ID: "demo.mid", Domain: "demo", Version: 1, Priority: 5, Matcher: MatchSpec{IncludesAny: []string{"c"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
top := reg.Snapshot().CatalogTopK(2)
|
||||
if len(top) != 2 || top[0].ID != "demo.high" || top[1].ID != "demo.mid" {
|
||||
t.Fatalf("topk = %+v", top)
|
||||
}
|
||||
if all := reg.Snapshot().CatalogTopK(0); len(all) != 3 {
|
||||
t.Fatalf("k=0 should be full catalog: %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
type stubGenerator struct {
|
||||
cmds []Command
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubGenerator) Generate(ctx context.Context, input string, catalog []CatalogSpec) ([]Command, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return s.cmds, nil
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestR2NFKCFoldsFullwidthButNotTraditional(t *testing.T) {
|
||||
spec := IntentSpec{
|
||||
ID: "demo.greet.hello", Domain: "demo", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"hello"}},
|
||||
}
|
||||
b := NewRegistryBuilder(Options{NFKC: true})
|
||||
if err := b.Register(spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := NewRecognizer(Options{NFKC: true}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "Hello", reg.Snapshot())
|
||||
if err != nil || res.Top == nil || res.Top.ID != spec.ID {
|
||||
t.Fatalf("fullwidth hello: %+v %v", res, err)
|
||||
}
|
||||
trad, err := rec.Recognize(context.Background(), "寫入", reg.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if trad.Branch != BranchFallback {
|
||||
t.Fatalf("NFKC must not convert traditional Chinese, got %+v", trad)
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2SpecByIDDeepCopiesMetadata(t *testing.T) {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(IntentSpec{
|
||||
ID: "demo.meta.copy", Domain: "demo", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"复制"}},
|
||||
Metadata: map[string]string{"guidance": "keep"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := reg.Snapshot().SpecByID("demo.meta.copy")
|
||||
if !ok {
|
||||
t.Fatal("missing spec")
|
||||
}
|
||||
got.Metadata["guidance"] = "mutated"
|
||||
again, _ := reg.Snapshot().SpecByID("demo.meta.copy")
|
||||
if again.Metadata["guidance"] != "keep" {
|
||||
t.Fatalf("snapshot metadata changed: %q", again.Metadata["guidance"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2CatalogHidesPrivateMetadataAndHandlerInMarkdown(t *testing.T) {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(IntentSpec{
|
||||
ID: "demo.catalog.public", Domain: "demo", Version: 1,
|
||||
Handler: "secret.handler",
|
||||
Matcher: MatchSpec{IncludesAny: []string{"目录"}},
|
||||
Metadata: map[string]string{
|
||||
"blueprint": "bp",
|
||||
"token": "do-not-leak",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := reg.Snapshot()
|
||||
cat := snap.Catalog()
|
||||
if len(cat) != 1 {
|
||||
t.Fatalf("catalog: %+v", cat)
|
||||
}
|
||||
if cat[0].Metadata["blueprint"] != "bp" || cat[0].Metadata["token"] != "" {
|
||||
t.Fatalf("metadata allowlist: %+v", cat[0].Metadata)
|
||||
}
|
||||
md := snap.CatalogMarkdown()
|
||||
if strings.Contains(md, "secret.handler") || strings.Contains(md, "do-not-leak") || strings.Contains(md, "handler:") {
|
||||
t.Fatalf("markdown leaked private fields:\n%s", md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2EvalRulesDocumentsMaxSpecs(t *testing.T) {
|
||||
if DefaultMaxSpecs != 2048 {
|
||||
t.Fatalf("linear scan cap changed: %d", DefaultMaxSpecs)
|
||||
}
|
||||
b := NewRegistryBuilder(Options{MaxSpecs: 1})
|
||||
if err := b.Register(
|
||||
IntentSpec{ID: "demo.cap.a", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"a"}}},
|
||||
IntentSpec{ID: "demo.cap.b", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"b"}}},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.Build(); err == nil {
|
||||
t.Fatal("build must reject specs above MaxSpecs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2OverlappingSlotsAreAmbiguous(t *testing.T) {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(IntentSpec{
|
||||
ID: "demo.slot.overlap", Domain: "demo", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"abcd"}},
|
||||
Slots: []SlotSpec{
|
||||
{Key: "left", Pattern: "(abc)"},
|
||||
{Key: "right", Pattern: "(bcd)"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "abcd", reg.Snapshot())
|
||||
if err != nil || res.Top == nil {
|
||||
t.Fatalf("recognize: %+v %v", res, err)
|
||||
}
|
||||
if len(res.Top.Slots) != 0 {
|
||||
t.Fatalf("overlapping slots must not be kept: %+v", res.Top.Slots)
|
||||
}
|
||||
if res.Top.InvalidSlots["left"] != "span overlap" || res.Top.InvalidSlots["right"] != "span overlap" {
|
||||
t.Fatalf("invalid slots: %+v", res.Top.InvalidSlots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2NegationIsOnlyExcludesAny(t *testing.T) {
|
||||
plain := IntentSpec{
|
||||
ID: "demo.write.file", Domain: "demo", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"写入"}},
|
||||
}
|
||||
excluded := plain
|
||||
excluded.ID = "demo.write.excluded"
|
||||
excluded.Matcher.ExcludesAny = []string{"不要"}
|
||||
rec := NewRecognizer(Options{}, nil, nil)
|
||||
|
||||
plainBuilder := NewRegistryBuilder(Options{})
|
||||
if err := plainBuilder.Register(plain); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
built, err := plainBuilder.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hit, err := rec.Recognize(context.Background(), "不要写入文件", built.Snapshot())
|
||||
if err != nil || hit.Top == nil || hit.Top.ID != plain.ID {
|
||||
t.Fatalf("without excludes_any the negated phrase still matches: %+v %v", hit, err)
|
||||
}
|
||||
|
||||
exBuilder := NewRegistryBuilder(Options{})
|
||||
if err := exBuilder.Register(excluded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
builtEx, err := exBuilder.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
miss, err := rec.Recognize(context.Background(), "不要写入文件", builtEx.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if miss.Branch != BranchFallback {
|
||||
t.Fatalf("excludes_any must reject, got %+v", miss)
|
||||
}
|
||||
}
|
||||
+16
-239
@@ -2,8 +2,6 @@ package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -19,22 +17,17 @@ type Recognizer interface {
|
||||
|
||||
// FunnelRecognizer 四级漏斗实现:guard → expectation → rules → adjudicate,
|
||||
// 可选注入挂起预期提供者与 Ambiguous 分支的 L2 分类器。
|
||||
// I2 的分层:歧义先经语义重排(确定性宿主打分)再到 L2 分类器(模型)。
|
||||
type FunnelRecognizer struct {
|
||||
log *logsys.Logger
|
||||
exp ExpectationProvider
|
||||
cls Classifier
|
||||
sem SemanticScorer
|
||||
thr float64
|
||||
gen CommandGenerator
|
||||
}
|
||||
|
||||
// NewRecognizer 构建漏斗识别器。opts 仅使用 Log 字段(阈值类参数在快照内);
|
||||
// exp / cls 可为 nil(对应阶段不启用)。语义与生成端口经 Options 注入(I2)。
|
||||
// exp / cls 可为 nil(对应阶段不启用)。
|
||||
func NewRecognizer(opts Options, exp ExpectationProvider, cls Classifier) *FunnelRecognizer {
|
||||
o := opts.normalized()
|
||||
return &FunnelRecognizer{log: o.Log, exp: exp, cls: cls,
|
||||
sem: o.Semantic, thr: o.SemanticThreshold, gen: o.Generator}
|
||||
return &FunnelRecognizer{log: o.Log, exp: exp, cls: cls}
|
||||
}
|
||||
|
||||
// Recognize 识别一次输入。确定性:同输入 + 同快照 → 同结果(日志中的耗时除外)。
|
||||
@@ -51,10 +44,6 @@ func (r *FunnelRecognizer) Recognize(ctx context.Context, input string, snap *Sn
|
||||
}
|
||||
start := time.Now()
|
||||
res := r.recognize(ctx, input, snap)
|
||||
// 生成端口已经给出经过校验的命令列表时不再用规则展开覆盖(I2)。
|
||||
if res.Commands == nil {
|
||||
res.Commands = deriveCommands(res, snap)
|
||||
}
|
||||
topID, conf := "", 0.0
|
||||
if res.Top != nil {
|
||||
topID, conf = res.Top.ID, res.Top.Confidence
|
||||
@@ -72,11 +61,10 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return Result{Input: input, Branch: BranchFallback, SnapshotRevision: snap.rev}
|
||||
}
|
||||
// 否定只认 excludes_any 子串,不做语义否定;「不要写入」这类说法交给命令层 Cancel。
|
||||
matchInput, folded := snap.foldInput(input)
|
||||
folded := strings.ToLower(input)
|
||||
|
||||
// Stage 0:守卫命中即短路,可表达"识别即拒绝"。
|
||||
if guardHits := snap.evalGuard(matchInput, folded); len(guardHits) > 0 {
|
||||
if guardHits := snap.evalGuard(input, folded); len(guardHits) > 0 {
|
||||
cands := candidatesOf(trimHits(guardHits, snap.topN), input, true)
|
||||
top := cands[0]
|
||||
return Result{Input: input, Branch: BranchGuardHit, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
|
||||
@@ -85,7 +73,7 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
|
||||
// Stage 1:会话挂起预期优先于一般规则(宿主可选注入)。
|
||||
if r.exp != nil {
|
||||
if pending := r.exp.Pending(ctx); pending != nil && len(pending.IntentIDs) > 0 {
|
||||
if hits := snap.evalIDs(pending.IntentIDs, matchInput, folded); len(hits) > 0 {
|
||||
if hits := snap.evalIDs(pending.IntentIDs, input, folded); len(hits) > 0 {
|
||||
cands := candidatesOf(trimHits(hits, snap.topN), input, true)
|
||||
top := cands[0]
|
||||
return Result{Input: input, Branch: BranchExpectationHit, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
|
||||
@@ -94,100 +82,34 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
|
||||
}
|
||||
|
||||
// Stage 2 + 3:规则计分与置信度裁决。
|
||||
hits := snap.evalRules(matchInput, folded)
|
||||
hits := snap.evalRules(input, folded)
|
||||
switch {
|
||||
case len(hits) == 0:
|
||||
// I2:规则与语义都未命中时,可选经模型命令生成端口兜底(校验同款)。
|
||||
if r.gen != nil {
|
||||
if res, ok := r.generateCommands(ctx, input, snap); ok {
|
||||
return res
|
||||
}
|
||||
}
|
||||
return Result{Input: input, Branch: BranchFallback, SnapshotRevision: snap.rev}
|
||||
case len(hits) == 1 || hits[0].score-hits[1].score >= snap.margin || tieBreakByPriority(hits):
|
||||
case len(hits) == 1 || hits[0].score-hits[1].score >= snap.margin:
|
||||
cands := candidatesOf(trimHits(hits, snap.topN), input, true)
|
||||
top := cands[0]
|
||||
return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
|
||||
}
|
||||
|
||||
// Ambiguous:先语义重排(I2 确定性宿主打分),失败或不达阈才到 L2 分类器。
|
||||
// Ambiguous:可选 L2 分类器重排;失败保持 Ambiguous,不影响识别确定性承诺的失败语义。
|
||||
cands := candidatesOf(trimHits(hits, snap.topN), input, false)
|
||||
if r.sem != nil {
|
||||
if top, ok := r.semanticRerank(ctx, input, cands, snap); ok {
|
||||
snap.fillTopSlots(&top, input)
|
||||
top.Reason = top.Reason + "; refined_by=semantic"
|
||||
return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
|
||||
}
|
||||
}
|
||||
if r.cls != nil {
|
||||
refined, err := r.cls.Refine(ctx, input, cands)
|
||||
if err != nil {
|
||||
r.log.Module("intent").WithContext(ctx).Warn("intent classifier failed; keep ambiguous", "error", err.Error())
|
||||
return Result{Input: input, Branch: BranchAmbiguous, Candidates: cands, SnapshotRevision: snap.rev}
|
||||
}
|
||||
rebuilt, err := validateRefined(cands, refined)
|
||||
if err != nil {
|
||||
r.log.Module("intent").WithContext(ctx).Warn("intent classifier result rejected; keep ambiguous", "error", err.Error())
|
||||
return Result{Input: input, Branch: BranchAmbiguous, Candidates: cands, SnapshotRevision: snap.rev}
|
||||
}
|
||||
if top, ok := adjudicateRefined(rebuilt, snap); ok {
|
||||
snap.fillTopSlots(&top, input)
|
||||
if top, ok := adjudicateRefined(refined, snap); ok {
|
||||
cands = refined
|
||||
top.Reason = top.Reason + "; refined_by=classifier"
|
||||
return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: rebuilt, SnapshotRevision: snap.rev}
|
||||
return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
|
||||
}
|
||||
cands = rebuilt
|
||||
cands = refined
|
||||
}
|
||||
return Result{Input: input, Branch: BranchAmbiguous, Candidates: cands, SnapshotRevision: snap.rev}
|
||||
}
|
||||
|
||||
// tieBreakByPriority 报告同分(分差为 0)且前两名 Priority 不同:
|
||||
// 此时高 Priority 者直接胜出,不再标歧(I5)。分差小于 margin 但非零时
|
||||
// 仍按 ambiguous 处理(margin 语义不变)。
|
||||
func tieBreakByPriority(hits []specHit) bool {
|
||||
if len(hits) < 2 {
|
||||
return false
|
||||
}
|
||||
return hits[0].score == hits[1].score && hits[0].cs.spec.Priority > hits[1].cs.spec.Priority
|
||||
}
|
||||
|
||||
// validateRefined 要求分类器返回与原候选完全相同的 ID 集合且无重复。
|
||||
// 置信度必须是有限值并落在 [0,1];越界判为不合法,不截断。
|
||||
// 除 Confidence 外的字段一律按 ID 从原候选重建,分类器改写的 Handler 等不生效。
|
||||
func validateRefined(original, refined []Candidate) ([]Candidate, error) {
|
||||
byID := make(map[string]Candidate, len(original))
|
||||
for _, c := range original {
|
||||
if _, dup := byID[c.ID]; dup {
|
||||
return nil, fmt.Errorf("duplicate original id %s", c.ID)
|
||||
}
|
||||
byID[c.ID] = c
|
||||
}
|
||||
if len(refined) != len(original) {
|
||||
return nil, fmt.Errorf("refined candidate count %d != %d", len(refined), len(original))
|
||||
}
|
||||
seen := make(map[string]struct{}, len(refined))
|
||||
out := make([]Candidate, len(refined))
|
||||
for i, got := range refined {
|
||||
if _, dup := seen[got.ID]; dup {
|
||||
return nil, fmt.Errorf("duplicate refined id %s", got.ID)
|
||||
}
|
||||
seen[got.ID] = struct{}{}
|
||||
src, ok := byID[got.ID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown refined id %s", got.ID)
|
||||
}
|
||||
if !validConfidence(got.Confidence) {
|
||||
return nil, fmt.Errorf("confidence out of range for %s", got.ID)
|
||||
}
|
||||
src.Confidence = got.Confidence
|
||||
out[i] = src
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validConfidence(v float64) bool {
|
||||
return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v <= 1
|
||||
}
|
||||
|
||||
// adjudicateRefined 对分类器结果重排后应用同一裁决规则;分差仍贴近则返回 false。
|
||||
func adjudicateRefined(refined []Candidate, snap *Snapshot) (Candidate, bool) {
|
||||
if len(refined) == 0 {
|
||||
@@ -229,11 +151,11 @@ func (s *Snapshot) Explain(input string) Trace {
|
||||
trace.Branch = BranchFallback
|
||||
return trace
|
||||
}
|
||||
matchInput, folded := s.foldInput(input)
|
||||
folded := strings.ToLower(input)
|
||||
|
||||
for _, idx := range s.guardIdx {
|
||||
cs := s.specs[idx]
|
||||
o := cs.evaluate(matchInput, folded)
|
||||
o := cs.evaluate(input, folded)
|
||||
if o.passed {
|
||||
trace.Guard = append(trace.Guard, ScoredSpec{ID: cs.spec.ID, Score: o.score, HitTerms: o.hitTerms, HitPatterns: o.hitPat})
|
||||
} else {
|
||||
@@ -253,7 +175,7 @@ func (s *Snapshot) Explain(input string) Trace {
|
||||
if cs.guard {
|
||||
continue
|
||||
}
|
||||
o := cs.evaluate(matchInput, folded)
|
||||
o := cs.evaluate(input, folded)
|
||||
if o.passed {
|
||||
hits = append(hits, specHit{cs: cs, score: o.score, terms: o.hitTerms, pats: o.hitPat})
|
||||
trace.Rules = append(trace.Rules, ScoredSpec{ID: cs.spec.ID, Score: o.score, HitTerms: o.hitTerms, HitPatterns: o.hitPat})
|
||||
@@ -268,7 +190,7 @@ func (s *Snapshot) Explain(input string) Trace {
|
||||
switch {
|
||||
case len(hits) == 0:
|
||||
trace.Branch = BranchFallback
|
||||
case len(hits) == 1 || hits[0].score-hits[1].score >= s.margin || tieBreakByPriority(hits):
|
||||
case len(hits) == 1 || hits[0].score-hits[1].score >= s.margin:
|
||||
trace.Branch = BranchMatched
|
||||
trace.TopID = hits[0].cs.spec.ID
|
||||
default:
|
||||
@@ -284,148 +206,3 @@ func sortScored(ss []ScoredSpec) {
|
||||
func sortRejected(rs []RejectedSpec) {
|
||||
sort.SliceStable(rs, func(i, j int) bool { return rs[i].ID < rs[j].ID })
|
||||
}
|
||||
|
||||
// semanticRerank 用宿主打分器对歧义候选按 examples 重排(I2)。
|
||||
// 规则:候选必须来自当前候选集(构造上不可能引入未知 ID);
|
||||
// 只取相似度 ≥ 阈值的候选——唯一过阈或前两名分差 ≥ margin 才判 matched,
|
||||
// 否则保持 ambiguous(与规则裁决同款语义)。打分错误一律记日志并保持 ambiguous。
|
||||
func (r *FunnelRecognizer) semanticRerank(ctx context.Context, input string, cands []Candidate, snap *Snapshot) (Candidate, bool) {
|
||||
type scored struct {
|
||||
cand Candidate
|
||||
score float64
|
||||
}
|
||||
var above []scored
|
||||
for _, c := range cands {
|
||||
spec, ok := snap.SpecByID(c.ID)
|
||||
if !ok || len(spec.Examples) == 0 {
|
||||
continue
|
||||
}
|
||||
s, err := r.sem.Score(ctx, input, spec.Examples)
|
||||
if err != nil {
|
||||
r.log.Module("intent").WithContext(ctx).Warn("intent semantic scorer failed; keep ambiguous",
|
||||
"error", err.Error(), "id", c.ID)
|
||||
continue
|
||||
}
|
||||
if s >= r.thr {
|
||||
above = append(above, scored{cand: c, score: s})
|
||||
}
|
||||
}
|
||||
if len(above) == 0 {
|
||||
return Candidate{}, false
|
||||
}
|
||||
for i := 0; i+1 < len(above); i++ {
|
||||
for j := i + 1; j < len(above); j++ {
|
||||
if above[j].score > above[i].score {
|
||||
above[i], above[j] = above[j], above[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(above) == 1 || above[0].score-above[1].score >= snap.margin {
|
||||
return above[0].cand, true
|
||||
}
|
||||
return Candidate{}, false
|
||||
}
|
||||
|
||||
// generateCommands 经模型命令生成端口兜底(I2)。校验:
|
||||
// start 的 ActionRef 可解析(spec ID 集或 Handler 集内)、命令种类受限、
|
||||
// 槽位键在目标 spec 的键集内(可解析时);全部通过则把首个 start 的目标
|
||||
// spec 合成 Top(refined_by=generator 标注),任一项不过则保持 fallback。
|
||||
func (r *FunnelRecognizer) generateCommands(ctx context.Context, input string, snap *Snapshot) (Result, bool) {
|
||||
catalog := snap.CatalogTopK(DefaultGeneratorCatalogK)
|
||||
cmds, err := r.gen.Generate(ctx, input, catalog)
|
||||
if err != nil {
|
||||
r.log.Module("intent").WithContext(ctx).Warn("intent command generator failed; keep fallback", "error", err.Error())
|
||||
return Result{}, false
|
||||
}
|
||||
if err := validateGenerated(cmds, catalog); err != nil {
|
||||
r.log.Module("intent").WithContext(ctx).Warn("intent generator output rejected; keep fallback", "error", err.Error())
|
||||
return Result{}, false
|
||||
}
|
||||
res := Result{Input: input, Branch: BranchFallback, Commands: cmds, SnapshotRevision: snap.rev}
|
||||
for _, c := range cmds {
|
||||
if c.Kind != CommandStart {
|
||||
continue
|
||||
}
|
||||
if spec, ok := generatedSpecOf(c.Action, catalog); ok {
|
||||
top := Candidate{
|
||||
ID: spec.ID, Domain: spec.Domain, Version: spec.Version,
|
||||
Confidence: 0, Reason: "generated_by=command_generator",
|
||||
Slots: c.Slots,
|
||||
}
|
||||
res.Top = &top
|
||||
res.Branch = BranchMatched
|
||||
res.Candidates = []Candidate{top}
|
||||
}
|
||||
break
|
||||
}
|
||||
return res, true
|
||||
}
|
||||
|
||||
// DefaultGeneratorCatalogK 生成器目录默认 top-k。
|
||||
const DefaultGeneratorCatalogK = 8
|
||||
|
||||
// validateGenerated 校验生成命令(I2;与 spec 级校验同款 fail-closed)。
|
||||
func validateGenerated(cmds []Command, catalog []CatalogSpec) error {
|
||||
if len(cmds) == 0 {
|
||||
return fmt.Errorf("empty command list")
|
||||
}
|
||||
specIDs := map[string]bool{}
|
||||
handlers := map[string]bool{}
|
||||
slotKeys := map[string]map[string]bool{}
|
||||
for _, cs := range catalog {
|
||||
specIDs[cs.ID] = true
|
||||
if cs.Handler != "" {
|
||||
handlers[cs.Handler] = true
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
for _, sl := range cs.Slots {
|
||||
keys[sl.Key] = true
|
||||
}
|
||||
slotKeys[cs.ID] = keys
|
||||
}
|
||||
for i, c := range cmds {
|
||||
switch c.Kind {
|
||||
case CommandStart:
|
||||
ref := c.Action
|
||||
switch ref.Kind {
|
||||
case ActionAuto, ActionCommand, ActionWorkflow, ActionBlueprint:
|
||||
if !specIDs[ref.ID] {
|
||||
return fmt.Errorf("cmd[%d] start: action.id %q 不在目录内", i, ref.ID)
|
||||
}
|
||||
case ActionHandler:
|
||||
if !handlers[ref.ID] {
|
||||
return fmt.Errorf("cmd[%d] start: handler %q 不在目录内", i, ref.ID)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("cmd[%d] start: 未知 action.kind %q", i, ref.Kind)
|
||||
}
|
||||
if keys, ok := slotKeys[ref.ID]; ok {
|
||||
for k := range c.Slots {
|
||||
if !keys[k] {
|
||||
return fmt.Errorf("cmd[%d] start: 槽位 %q 不在 %s 的槽位键集内", i, k, ref.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
case CommandClarify, CommandCancel, CommandSetSlot, CommandChangeSlot, CommandChitchat, CommandHandoff:
|
||||
default:
|
||||
return fmt.Errorf("cmd[%d]: 未知命令种类 %q", i, c.Kind)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generatedSpecOf 把 start 的 ActionRef 解析回目录 spec(handler 键按 Handler 反查)。
|
||||
func generatedSpecOf(ref ActionRef, catalog []CatalogSpec) (CatalogSpec, bool) {
|
||||
for _, cs := range catalog {
|
||||
if ref.Kind == ActionHandler {
|
||||
if cs.Handler == ref.ID {
|
||||
return cs, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cs.ID == ref.ID {
|
||||
return cs, true
|
||||
}
|
||||
}
|
||||
return CatalogSpec{}, false
|
||||
}
|
||||
|
||||
+8
-140
@@ -3,7 +3,6 @@ package intent
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -112,21 +111,20 @@ func TestRecognizePatternBonus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeTieBreakPrefersPriority(t *testing.T) {
|
||||
func TestRecognizeAmbiguousAndPriorityOrder(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
rec := NewRecognizer(Options{}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "你好呀", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// I5:同分(0.7)且 Priority 不同(10 > 5)时高优先者直接胜出,不再标歧。
|
||||
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.greet.hello" {
|
||||
t.Fatalf("tie-break should match hello: %+v", res)
|
||||
if res.Branch != BranchAmbiguous || res.Top != nil {
|
||||
t.Fatalf("expected ambiguous without top: %+v", res)
|
||||
}
|
||||
if len(res.Candidates) < 2 {
|
||||
t.Fatalf("expected >=2 candidates: %+v", res.Candidates)
|
||||
}
|
||||
// 候选次序仍按 priority 降序:hello(10) 在 hi(5) 前。
|
||||
// 同分按 priority 降序:hello(10) 在 hi(5) 前。
|
||||
if res.Candidates[0].ID != "demo.greet.hello" || res.Candidates[1].ID != "demo.greet.hi" {
|
||||
t.Fatalf("priority order: %+v", res.Candidates)
|
||||
}
|
||||
@@ -152,13 +150,13 @@ func (s stubClassifier) Refine(ctx context.Context, input string, cands []Candid
|
||||
|
||||
func TestRecognizeClassifierResolvesAmbiguity(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
cls := stubClassifier{scores: map[string]float64{"demo.tie.alpha": 0.9, "demo.tie.beta": 0.3}}
|
||||
cls := stubClassifier{scores: map[string]float64{"demo.greet.hello": 0.9, "demo.greet.hi": 0.3}}
|
||||
rec := NewRecognizer(Options{}, nil, cls)
|
||||
res, err := rec.Recognize(context.Background(), "打结", snap)
|
||||
res, err := rec.Recognize(context.Background(), "你好呀", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.tie.alpha" {
|
||||
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.greet.hello" {
|
||||
t.Fatalf("classifier should resolve: %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Top.Reason, "refined_by=classifier") {
|
||||
@@ -169,7 +167,7 @@ func TestRecognizeClassifierResolvesAmbiguity(t *testing.T) {
|
||||
func TestRecognizeClassifierFailureKeepsAmbiguous(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
rec := NewRecognizer(Options{}, nil, stubClassifier{err: errors.New("llm down")})
|
||||
res, err := rec.Recognize(context.Background(), "打结", snap)
|
||||
res, err := rec.Recognize(context.Background(), "你好呀", snap)
|
||||
if err != nil {
|
||||
t.Fatalf("classifier failure must not fail recognition: %v", err)
|
||||
}
|
||||
@@ -178,136 +176,6 @@ func TestRecognizeClassifierFailureKeepsAmbiguous(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type rewriteClassifier struct {
|
||||
fn func([]Candidate) []Candidate
|
||||
}
|
||||
|
||||
func (s rewriteClassifier) Refine(ctx context.Context, input string, cands []Candidate) ([]Candidate, error) {
|
||||
return s.fn(cands), nil
|
||||
}
|
||||
|
||||
func TestRecognizeClassifierRejectsBadRefinement(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func([]Candidate) []Candidate
|
||||
}{
|
||||
{"unknown_id", func(cands []Candidate) []Candidate {
|
||||
out := append([]Candidate(nil), cands...)
|
||||
out[0].ID = "not.a.real.id"
|
||||
return out
|
||||
}},
|
||||
{"missing_id", func(cands []Candidate) []Candidate {
|
||||
return cands[:1]
|
||||
}},
|
||||
{"duplicate_id", func(cands []Candidate) []Candidate {
|
||||
return []Candidate{cands[0], cands[0]}
|
||||
}},
|
||||
{"confidence_above_one", func(cands []Candidate) []Candidate {
|
||||
out := append([]Candidate(nil), cands...)
|
||||
out[0].Confidence = 1.2
|
||||
return out
|
||||
}},
|
||||
{"confidence_negative", func(cands []Candidate) []Candidate {
|
||||
out := append([]Candidate(nil), cands...)
|
||||
out[0].Confidence = -0.1
|
||||
return out
|
||||
}},
|
||||
{"confidence_nan", func(cands []Candidate) []Candidate {
|
||||
out := append([]Candidate(nil), cands...)
|
||||
out[0].Confidence = math.NaN()
|
||||
return out
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: tc.fn})
|
||||
res, err := rec.Recognize(context.Background(), "打结", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchAmbiguous || res.Top != nil {
|
||||
t.Fatalf("expected ambiguous kept: %+v", res)
|
||||
}
|
||||
if len(res.Candidates) < 2 || res.Candidates[0].ID != "demo.tie.alpha" {
|
||||
t.Fatalf("original candidates kept: %+v", res.Candidates)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeClassifierRebuildsTamperedHandler(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
plain, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "打结", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantHandler := plain.Candidates[0].Handler
|
||||
rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: func(cands []Candidate) []Candidate {
|
||||
out := append([]Candidate(nil), cands...)
|
||||
for i := range out {
|
||||
out[i].Handler = "tampered.handler"
|
||||
if out[i].ID == "demo.tie.alpha" {
|
||||
out[i].Confidence = 0.95
|
||||
} else {
|
||||
out[i].Confidence = 0.2
|
||||
}
|
||||
}
|
||||
return out
|
||||
}})
|
||||
res, err := rec.Recognize(context.Background(), "打结", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.tie.alpha" {
|
||||
t.Fatalf("expected resolved match: %+v", res)
|
||||
}
|
||||
if res.Top.Handler != wantHandler {
|
||||
t.Fatalf("handler rebuilt from original %q, got %q", wantHandler, res.Top.Handler)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeClassifierRefinedTopGetsSlots(t *testing.T) {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(
|
||||
IntentSpec{
|
||||
ID: "demo.slot.a", Domain: "demo", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"查槽"}},
|
||||
Handler: "handler.a",
|
||||
Slots: []SlotSpec{{Key: "n", Pattern: `查槽[: ]*(\d+)`, Group: 1}},
|
||||
Confidence: ConfidenceSpec{Base: 0.8, Threshold: 0.5},
|
||||
},
|
||||
IntentSpec{
|
||||
ID: "demo.slot.b", Domain: "demo", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"查槽"}},
|
||||
Handler: "handler.b",
|
||||
Confidence: ConfidenceSpec{Base: 0.8, Threshold: 0.5},
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := reg.Snapshot()
|
||||
plain, _ := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "查槽 7", snap)
|
||||
if plain.Branch != BranchAmbiguous || plain.Top != nil {
|
||||
t.Fatalf("expected ambiguous before refine: %+v", plain)
|
||||
}
|
||||
rec := NewRecognizer(Options{}, nil, stubClassifier{scores: map[string]float64{"demo.slot.a": 0.95, "demo.slot.b": 0.2}})
|
||||
res, err := rec.Recognize(context.Background(), "查槽 7", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.slot.a" {
|
||||
t.Fatalf("refined top: %+v", res)
|
||||
}
|
||||
if res.Top.Slots["n"] != "7" {
|
||||
t.Fatalf("top slots: %+v", res.Top.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
type stubExpectation struct {
|
||||
pending *PendingExpectation
|
||||
}
|
||||
|
||||
+16
-64
@@ -35,18 +35,6 @@ type Options struct {
|
||||
MaxSlotsPerSpec int
|
||||
// Log 可选日志器;nil 时使用 Nop。识别日志不记录原始输入。
|
||||
Log *logsys.Logger
|
||||
// Semantic 语义相似度端口(I2):nil 时语义层不启用。
|
||||
Semantic SemanticScorer
|
||||
// SemanticThreshold 语义重排的过阈值(默认 0.8)。
|
||||
SemanticThreshold float64
|
||||
// Generator 模型命令生成端口(I2):nil 时 fallback 不生成。
|
||||
Generator CommandGenerator
|
||||
// SlotValidators 作用域槽位校验器。构建与校验时先查此表,查不到再回落
|
||||
// 已弃用的全局表(RegisterSlotValidator)。不同 Registry 的同名校验器互不影响。
|
||||
SlotValidators map[string]SlotValidator
|
||||
// NFKC 为真时,词条与输入先做兼容等价归一(全角 ASCII、组合字符),再按
|
||||
// case_sensitive 决定是否小写。NFKC 不做繁简转换。
|
||||
NFKC bool
|
||||
}
|
||||
|
||||
// Options 字段默认值。
|
||||
@@ -67,9 +55,6 @@ func (o Options) normalized() Options {
|
||||
if n.AmbiguityMargin <= 0 {
|
||||
n.AmbiguityMargin = DefaultAmbiguityMargin
|
||||
}
|
||||
if n.SemanticThreshold <= 0 {
|
||||
n.SemanticThreshold = DefaultSemanticThreshold
|
||||
}
|
||||
if n.MaxInputLen <= 0 {
|
||||
n.MaxInputLen = DefaultMaxInputLen
|
||||
}
|
||||
@@ -85,13 +70,6 @@ func (o Options) normalized() Options {
|
||||
if n.Log == nil {
|
||||
n.Log = logsys.Nop()
|
||||
}
|
||||
if len(n.SlotValidators) > 0 {
|
||||
cp := make(map[string]SlotValidator, len(n.SlotValidators))
|
||||
for k, v := range n.SlotValidators {
|
||||
cp[k] = v
|
||||
}
|
||||
n.SlotValidators = cp
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -148,19 +126,19 @@ func (cs *compiledSpec) evaluate(input, folded string) matchOutcome {
|
||||
hay = folded
|
||||
}
|
||||
for _, t := range cs.termsEx {
|
||||
if cs.contains(hay, t) {
|
||||
if strings.Contains(hay, t) {
|
||||
return matchOutcome{reject: "excluded_term:" + t}
|
||||
}
|
||||
}
|
||||
for _, t := range cs.termsAll {
|
||||
if !cs.contains(hay, t) {
|
||||
if !strings.Contains(hay, t) {
|
||||
return matchOutcome{reject: "missing_includes_all:" + t}
|
||||
}
|
||||
}
|
||||
var hitTerms, hitPat []string
|
||||
positive := false
|
||||
for _, t := range cs.termsAny {
|
||||
if cs.contains(hay, t) {
|
||||
if strings.Contains(hay, t) {
|
||||
positive = true
|
||||
hitTerms = append(hitTerms, t)
|
||||
}
|
||||
@@ -185,20 +163,16 @@ func (cs *compiledSpec) evaluate(input, folded string) matchOutcome {
|
||||
return matchOutcome{score: math.Round(score*10000) / 10000, passed: score >= cs.threshold, hitTerms: hitTerms, hitPat: hitPat}
|
||||
}
|
||||
|
||||
// contains 按 spec 的 boundary 配置选择子串或词界匹配(I3)。
|
||||
func (cs *compiledSpec) contains(hay, term string) bool {
|
||||
if cs.spec.Matcher.Boundary {
|
||||
return boundedContains(hay, term)
|
||||
}
|
||||
return strings.Contains(hay, term)
|
||||
}
|
||||
|
||||
func compileSpec(spec IntentSpec, guard bool, index int, scoped map[string]SlotValidator, nfkc bool) (*compiledSpec, error) {
|
||||
func compileSpec(spec IntentSpec, guard bool, index int) (*compiledSpec, error) {
|
||||
m := spec.Matcher
|
||||
fold := func(terms []string) []string {
|
||||
out := make([]string, len(terms))
|
||||
for i, t := range terms {
|
||||
out[i] = foldMatchText(t, nfkc, m.CaseSensitive)
|
||||
if m.CaseSensitive {
|
||||
out[i] = t
|
||||
} else {
|
||||
out[i] = strings.ToLower(t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -231,7 +205,7 @@ func compileSpec(spec IntentSpec, guard bool, index int, scoped map[string]SlotV
|
||||
cs.patterns[i] = re
|
||||
}
|
||||
for _, s := range spec.Slots {
|
||||
cs2, err := compileSlot(s, scoped)
|
||||
cs2, err := compileSlot(s)
|
||||
if err != nil {
|
||||
return nil, err.(*Error).with("id", spec.ID)
|
||||
}
|
||||
@@ -255,7 +229,6 @@ type Snapshot struct {
|
||||
disabled []string // 已禁用 id(overlay disable 与 spec.disabled 两个入口)
|
||||
sources map[string]string
|
||||
total int // 含禁用
|
||||
nfkc bool
|
||||
}
|
||||
|
||||
// Revision 返回快照版本号,随每次成功 Reload 递增。
|
||||
@@ -284,11 +257,10 @@ func (s *Snapshot) Source(id string) (string, bool) {
|
||||
return src, ok
|
||||
}
|
||||
|
||||
// SpecByID 返回指定 id 的启用 spec 定义副本。切片与 Metadata 深拷贝,
|
||||
// 调用方改返回值不会改到快照。不存在或已禁用时第二个返回值为 false。
|
||||
// SpecByID 返回指定 id 的启用 spec 定义副本。不存在或已禁用时第二个返回值为 false。
|
||||
func (s *Snapshot) SpecByID(id string) (IntentSpec, bool) {
|
||||
if idx, ok := s.byID[id]; ok {
|
||||
return cloneIntentSpec(s.specs[idx].spec), true
|
||||
return s.specs[idx].spec, true
|
||||
}
|
||||
return IntentSpec{}, false
|
||||
}
|
||||
@@ -385,9 +357,7 @@ func (s *Snapshot) evalIDs(ids []string, input, folded string) []specHit {
|
||||
return hits
|
||||
}
|
||||
|
||||
// evalRules 对全部非守卫 spec 做线性扫描,返回过阈命中(已排序)。
|
||||
// 规模上限是 Options.MaxSpecs(默认 DefaultMaxSpecs = 2048)。倒排索引留到
|
||||
// 超过该上限仍不够用时再做;构建期已经拒绝超过上限的快照。
|
||||
// evalRules 评估全部非守卫 spec,返回过阈命中(已排序)。
|
||||
func (s *Snapshot) evalRules(input, folded string) []specHit {
|
||||
var hits []specHit
|
||||
for _, cs := range s.specs {
|
||||
@@ -429,10 +399,6 @@ func (h specHit) candidate(input string, withSlots bool) Candidate {
|
||||
if c.Risk == "" {
|
||||
c.Risk = RiskLow
|
||||
}
|
||||
if cs.spec.Action != nil {
|
||||
ref := *cs.spec.Action
|
||||
c.Action = &ref
|
||||
}
|
||||
if len(cs.spec.Metadata) > 0 {
|
||||
c.Metadata = make(map[string]string, len(cs.spec.Metadata))
|
||||
for k, v := range cs.spec.Metadata {
|
||||
@@ -445,19 +411,6 @@ func (h specHit) candidate(input string, withSlots bool) Candidate {
|
||||
return c
|
||||
}
|
||||
|
||||
// fillTopSlots 给裁决后的 Top 补抽槽位。Ambiguous 路径建候选时不抽槽位,
|
||||
// 分类器选定 Top 之后才知道该抽哪一条。
|
||||
func (s *Snapshot) fillTopSlots(c *Candidate, input string) {
|
||||
idx, ok := s.byID[c.ID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slots, missing, invalid := s.specs[idx].extractSlots(input)
|
||||
c.Slots = slots
|
||||
c.MissingRequiredSlots = missing
|
||||
c.InvalidSlots = invalid
|
||||
}
|
||||
|
||||
func candidatesOf(hits []specHit, input string, topSlots bool) []Candidate {
|
||||
out := make([]Candidate, 0, len(hits))
|
||||
for i, h := range hits {
|
||||
@@ -487,7 +440,7 @@ func (b *RegistryBuilder) Register(specs ...IntentSpec) error {
|
||||
if b.seen[spec.ID] {
|
||||
return newError(InvalidSpec, "内置 spec id 重复", "id", spec.ID)
|
||||
}
|
||||
if err := validateSpec(spec, lim, b.opts.SlotValidators); err != nil {
|
||||
if err := validateSpec(spec, lim); err != nil {
|
||||
return err
|
||||
}
|
||||
b.seen[spec.ID] = true
|
||||
@@ -621,7 +574,7 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry,
|
||||
continue
|
||||
}
|
||||
spec := e.spec
|
||||
if err := validateSpec(spec, lim, opts.SlotValidators); err != nil {
|
||||
if err := validateSpec(spec, lim); err != nil {
|
||||
return nil, err.(*Error).with("source", e.source)
|
||||
}
|
||||
if idx, ok := pos[spec.ID]; ok {
|
||||
@@ -656,7 +609,6 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry,
|
||||
disabled: disabledList,
|
||||
sources: sources,
|
||||
total: total,
|
||||
nfkc: opts.NFKC,
|
||||
}
|
||||
nextIndex := 0
|
||||
for _, spec := range ordered {
|
||||
@@ -664,7 +616,7 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry,
|
||||
snap.disabled = append(snap.disabled, spec.ID)
|
||||
continue
|
||||
}
|
||||
cs, err := compileSpec(spec, guards[spec.Domain], nextIndex, opts.SlotValidators, opts.NFKC)
|
||||
cs, err := compileSpec(spec, guards[spec.Domain], nextIndex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -35,9 +35,6 @@ type Candidate struct {
|
||||
// InvalidSlots 捕获到但未通过确定性校验的槽位(key→原因,如枚举不合法)。
|
||||
// 必填且非法只进此处(不重复进 Missing),宿主可区分"没给"与"给错了"。
|
||||
InvalidSlots map[string]string
|
||||
// Action 是 spec 的显式动作声明(I1 WP2),随候选透传;nil 表示未声明,
|
||||
// ActionRef 推导回落 blueprint/Handler/auto 顺序。
|
||||
Action *ActionRef
|
||||
// Reason 命中解释:命中词与模式,供宿主审计与 intentctl explain 复用。
|
||||
Reason string
|
||||
}
|
||||
@@ -50,10 +47,6 @@ type Result struct {
|
||||
Top *Candidate
|
||||
Candidates []Candidate
|
||||
SnapshotRevision int64
|
||||
// Commands 是把本结果展开的路由指令列表(I1 命令化识别);
|
||||
// 可能为空(fallback / guard_hit / 宿主默认通道)。Branch 与 Top 保留,
|
||||
// 供诊断与尚未迁移到命令的消费方使用。
|
||||
Commands []Command
|
||||
}
|
||||
|
||||
// ScoredSpec 诊断用:单个 spec 在某个阶段的得分与命中明细。
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package intent
|
||||
|
||||
import "context"
|
||||
|
||||
// SemanticScorer 语义相似度端口(I2 分层与评测):宿主注入向量模型,
|
||||
// 核心不做 I/O——打分由宿主计算,库只消费分数。
|
||||
// 只在规则裁决为 Ambiguous 时被调用(规则→语义→模型分层的中位);
|
||||
// 实现应幂等且无副作用(同一输入与示例集应得到同一分数)。
|
||||
type SemanticScorer interface {
|
||||
// Score 返回 input 与 examples 的最高相似度,取值 [0,1]。
|
||||
// 实现返回错误时识别保持 Ambiguous,不会使 Recognize 失败。
|
||||
Score(ctx context.Context, input string, examples []string) (float64, error)
|
||||
}
|
||||
+14
-76
@@ -11,18 +11,13 @@ type SlotValidator func(value string) error
|
||||
|
||||
var slotValidators sync.Map // name → SlotValidator
|
||||
|
||||
// RegisterSlotValidator 注册进程级具名校验器;spec 通过 SlotSpec.Validator 引用。
|
||||
// RegisterSlotValidator 注册具名校验器;spec 通过 SlotSpec.Validator 引用。
|
||||
// 必须在快照构建前注册(构建期会校验名字存在)。
|
||||
//
|
||||
// Deprecated: 校验器应随 Registry 隔离。请改用 Options.SlotValidators。
|
||||
// 全局表只作回落:构建与校验先查作用域表,查不到再查这里。
|
||||
func RegisterSlotValidator(name string, fn SlotValidator) {
|
||||
slotValidators.Store(name, fn)
|
||||
}
|
||||
|
||||
// LookupSlotValidator 按名查找已注册的全局校验器。
|
||||
//
|
||||
// Deprecated: 请改用 Options.SlotValidators。本函数只服务全局回落。
|
||||
// LookupSlotValidator 按名查找已注册校验器。
|
||||
func LookupSlotValidator(name string) (SlotValidator, bool) {
|
||||
fn, ok := slotValidators.Load(name)
|
||||
if !ok {
|
||||
@@ -32,14 +27,6 @@ func LookupSlotValidator(name string) (SlotValidator, bool) {
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// resolveSlotValidator 先查作用域表,查不到再回落已弃用的全局表。
|
||||
func resolveSlotValidator(scoped map[string]SlotValidator, name string) (SlotValidator, bool) {
|
||||
if fn, ok := scoped[name]; ok && fn != nil {
|
||||
return fn, true
|
||||
}
|
||||
return LookupSlotValidator(name)
|
||||
}
|
||||
|
||||
// 内置归一化步骤:trim / lower / upper。
|
||||
var builtinNormalizers = map[string]func(string) string{
|
||||
"trim": strings.TrimSpace,
|
||||
@@ -87,87 +74,38 @@ func (s *compiledSlot) validate(v string) (string, bool) {
|
||||
return "", true
|
||||
}
|
||||
|
||||
// slotCapture 一次槽位抽取的捕获区间。多槽位各自第一次匹配;区间重叠时
|
||||
// 两边都标 span overlap,不写入 slots。库内不做重叠消解。
|
||||
type slotCapture struct {
|
||||
key string
|
||||
start int
|
||||
end int
|
||||
value string
|
||||
ok bool
|
||||
reason string
|
||||
required bool
|
||||
matched bool
|
||||
}
|
||||
|
||||
// extractSlots 抽取并归一化槽位。
|
||||
// missing:必填槽位未捕获(模式没匹配上);invalid:捕获到但校验不合法,或与另一槽位区间重叠(key→原因)。
|
||||
// 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
|
||||
}
|
||||
caps := make([]slotCapture, 0, len(cs.slots))
|
||||
for _, s := range cs.slots {
|
||||
loc := s.re.FindStringSubmatchIndex(input)
|
||||
g := s.group
|
||||
if loc == nil || 2*g+1 >= len(loc) || loc[2*g] < 0 || loc[2*g] == loc[2*g+1] {
|
||||
caps = append(caps, slotCapture{key: s.key, required: s.required})
|
||||
continue
|
||||
}
|
||||
raw := input[loc[2*g]:loc[2*g+1]]
|
||||
v := normalizeSlotValue(raw, s.normalize)
|
||||
reason, ok := s.validate(v)
|
||||
caps = append(caps, slotCapture{
|
||||
key: s.key, start: loc[2*g], end: loc[2*g+1], value: v,
|
||||
ok: ok, reason: reason, required: s.required, matched: true,
|
||||
})
|
||||
}
|
||||
overlap := map[int]bool{}
|
||||
for i := range caps {
|
||||
if !caps[i].matched {
|
||||
continue
|
||||
}
|
||||
for j := i + 1; j < len(caps); j++ {
|
||||
if !caps[j].matched {
|
||||
continue
|
||||
}
|
||||
if caps[i].start < caps[j].end && caps[j].start < caps[i].end {
|
||||
overlap[i] = true
|
||||
overlap[j] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, c := range caps {
|
||||
if !c.matched {
|
||||
if c.required {
|
||||
missing = append(missing, c.key)
|
||||
m := s.re.FindStringSubmatch(input)
|
||||
if m == nil || s.group >= len(m) || m[s.group] == "" {
|
||||
if s.required {
|
||||
missing = append(missing, s.key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if overlap[i] {
|
||||
v := normalizeSlotValue(m[s.group], s.normalize)
|
||||
if reason, ok := s.validate(v); !ok {
|
||||
if invalid == nil {
|
||||
invalid = map[string]string{}
|
||||
}
|
||||
invalid[c.key] = "span overlap"
|
||||
continue
|
||||
}
|
||||
if !c.ok {
|
||||
if invalid == nil {
|
||||
invalid = map[string]string{}
|
||||
}
|
||||
invalid[c.key] = c.reason
|
||||
invalid[s.key] = reason
|
||||
continue
|
||||
}
|
||||
if slots == nil {
|
||||
slots = map[string]string{}
|
||||
}
|
||||
slots[c.key] = c.value
|
||||
slots[s.key] = v
|
||||
}
|
||||
return slots, missing, invalid
|
||||
}
|
||||
|
||||
func compileSlot(spec SlotSpec, scoped map[string]SlotValidator) (compiledSlot, error) {
|
||||
func compileSlot(spec SlotSpec) (compiledSlot, error) {
|
||||
re, err := regexp.Compile(spec.Pattern)
|
||||
if err != nil {
|
||||
return compiledSlot{}, newError(InvalidSpec, "slot pattern 编译失败",
|
||||
@@ -198,11 +136,11 @@ func compileSlot(spec SlotSpec, scoped map[string]SlotValidator) (compiledSlot,
|
||||
cs.formatRe = fre
|
||||
}
|
||||
if spec.Validator != "" {
|
||||
fn, ok := resolveSlotValidator(scoped, spec.Validator)
|
||||
fn, ok := LookupSlotValidator(spec.Validator)
|
||||
if !ok {
|
||||
return compiledSlot{}, newError(InvalidSpec, "slot validator 未注册",
|
||||
"field", "slots", "key", spec.Key, "validator", spec.Validator,
|
||||
"hint", "在 Options.SlotValidators 登记,或调用已弃用的 intent.RegisterSlotValidator")
|
||||
"hint", "先调用 intent.RegisterSlotValidator 再构建快照")
|
||||
}
|
||||
cs.validator = fn
|
||||
}
|
||||
|
||||
@@ -67,49 +67,6 @@ func TestSlotNormalizeAndValidateChain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedSlotValidatorsDoNotInterfere(t *testing.T) {
|
||||
spec := slotSpecWith(func(s *IntentSpec) {
|
||||
s.Slots = []SlotSpec{{
|
||||
Key: "run_id", Required: true,
|
||||
Pattern: `(?:run|任务)[:# ]*([A-Za-z0-9-]+)`, Group: 1,
|
||||
Validator: "shared",
|
||||
}}
|
||||
})
|
||||
reject := SlotValidator(func(v string) error {
|
||||
if v == "42" {
|
||||
return errors.New("rejected by a")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
accept := SlotValidator(func(string) error { return nil })
|
||||
build := func(fn SlotValidator) *Snapshot {
|
||||
t.Helper()
|
||||
b := NewRegistryBuilder(Options{SlotValidators: map[string]SlotValidator{"shared": fn}})
|
||||
if err := b.Register(spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reg.Snapshot()
|
||||
}
|
||||
snapA := build(reject)
|
||||
snapB := build(accept)
|
||||
rec := NewRecognizer(Options{}, nil, nil)
|
||||
resA, _ := rec.Recognize(context.Background(), "查询任务 42 的进度", snapA)
|
||||
resB, _ := rec.Recognize(context.Background(), "查询任务 42 的进度", snapB)
|
||||
if resA.Top == nil || resA.Top.InvalidSlots["run_id"] == "" {
|
||||
t.Fatalf("registry A should reject 42: %+v", resA.Top)
|
||||
}
|
||||
if resB.Top == nil || resB.Top.Slots["run_id"] != "42" {
|
||||
t.Fatalf("registry B should accept 42: %+v", resB.Top)
|
||||
}
|
||||
if len(resB.Top.InvalidSlots) != 0 {
|
||||
t.Fatalf("registry B invalid: %+v", resB.Top.InvalidSlots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotEnumAndFormat(t *testing.T) {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(slotSpecWith(func(s *IntentSpec) {
|
||||
@@ -155,7 +112,7 @@ func TestSlotSpecValidationRejections(t *testing.T) {
|
||||
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, nil)
|
||||
err := validateSpec(spec, lim)
|
||||
var e *Error
|
||||
if !errors.As(err, &e) || e.Code != InvalidSpec {
|
||||
t.Fatalf("expected invalid_spec, got %v", err)
|
||||
|
||||
+4
-28
@@ -18,8 +18,6 @@ const (
|
||||
DefaultBase = 0.7
|
||||
DefaultPatternBonus = 0.2
|
||||
DefaultThreshold = 0.6
|
||||
// DefaultSemanticThreshold 语义重排默认过阈值。
|
||||
DefaultSemanticThreshold = 0.8
|
||||
)
|
||||
|
||||
// MatchSpec 声明式匹配规则。
|
||||
@@ -27,15 +25,12 @@ const (
|
||||
// 命中条件:excludes_any 全不命中 && includes_all 全命中 &&(includes_any 至少一词命中
|
||||
// 或任一 pattern 命中;仅配置 includes_all 时以全命中为准)。
|
||||
// case_sensitive 只作用于 includes/excludes 词表;pattern 自行用 (?i) 表达大小写。
|
||||
// boundary 为真时 includes/excludes 词表走词界匹配(命中两侧不得与词条边缘
|
||||
// 同类:汉字对汉字、词字符对词字符),用于压住中文子串误命中(I3);默认 false 保持旧行为。
|
||||
type MatchSpec struct {
|
||||
IncludesAny []string `json:"includes_any,omitempty"`
|
||||
IncludesAll []string `json:"includes_all,omitempty"`
|
||||
ExcludesAny []string `json:"excludes_any,omitempty"`
|
||||
Patterns []string `json:"patterns,omitempty"`
|
||||
CaseSensitive bool `json:"case_sensitive,omitempty"`
|
||||
Boundary bool `json:"boundary,omitempty"`
|
||||
}
|
||||
|
||||
// SlotSpec 槽位抽取规则。group 未填默认取 1(第一个捕获组)。
|
||||
@@ -56,8 +51,7 @@ type SlotSpec struct {
|
||||
Format string `json:"format,omitempty"`
|
||||
// Normalize 归一化步骤,按序应用:trim | lower | upper。
|
||||
Normalize []string `json:"normalize,omitempty"`
|
||||
// Validator 槽位校验器名。优先解析 Options.SlotValidators,查不到再回落
|
||||
// 已弃用的全局表(RegisterSlotValidator)。
|
||||
// Validator 宿主经 RegisterSlotValidator 注册的校验器名。
|
||||
Validator string `json:"validator,omitempty"`
|
||||
}
|
||||
|
||||
@@ -85,13 +79,6 @@ type IntentSpec struct {
|
||||
RequiresConfirm bool `json:"requires_confirm,omitempty"`
|
||||
Handler string `json:"handler,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
// Action 为显式动作声明(I1 WP2 四键统一的首选来源):
|
||||
// 声明后优先于 Handler 与 Metadata["blueprint"] 被 ActionRef 采用。
|
||||
// kind 必须是 command/workflow/blueprint/handler 之一。
|
||||
Action *ActionRef `json:"action,omitempty"`
|
||||
// Examples 示例句(I2 语义端口):歧义时语义打分器按它们路由
|
||||
// (semantic-router 借鉴;向量模型由宿主注入,库不做 I/O)。
|
||||
Examples []string `json:"examples,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -108,8 +95,7 @@ type limits struct {
|
||||
}
|
||||
|
||||
// validateSpec 校验单条 spec 的结构合法性,错误可定位到字段。limits 为零值时跳过数量上限。
|
||||
// scoped 为 Options.SlotValidators;查不到名字时回落已弃用的全局表。
|
||||
func validateSpec(spec IntentSpec, lim limits, scoped map[string]SlotValidator) error {
|
||||
func validateSpec(spec IntentSpec, lim limits) error {
|
||||
if spec.Domain == "" {
|
||||
return newError(InvalidSpec, "domain 为空", "field", "domain")
|
||||
}
|
||||
@@ -140,16 +126,6 @@ func validateSpec(spec IntentSpec, lim limits, scoped map[string]SlotValidator)
|
||||
default:
|
||||
return newError(InvalidSpec, "risk 只能是 low/medium/high 或留空", "field", "risk", "risk", spec.Risk)
|
||||
}
|
||||
if spec.Action != nil {
|
||||
switch spec.Action.Kind {
|
||||
case ActionCommand, ActionWorkflow, ActionBlueprint, ActionHandler:
|
||||
default:
|
||||
return newError(InvalidSpec, "action.kind 必须是 command/workflow/blueprint/handler", "field", "action.kind", "action.kind", spec.Action.Kind)
|
||||
}
|
||||
if strings.TrimSpace(spec.Action.ID) == "" {
|
||||
return newError(InvalidSpec, "action.id 为空", "field", "action.id", "id", spec.ID)
|
||||
}
|
||||
}
|
||||
|
||||
m := spec.Matcher
|
||||
for _, terms := range []struct {
|
||||
@@ -205,8 +181,8 @@ func validateSpec(spec IntentSpec, lim limits, scoped map[string]SlotValidator)
|
||||
}
|
||||
}
|
||||
if s.Validator != "" {
|
||||
if _, ok := resolveSlotValidator(scoped, s.Validator); !ok {
|
||||
return newError(InvalidSpec, "slot validator 未注册", "field", fmt.Sprintf("slots[%d].validator", i), "key", s.Key, "validator", s.Validator, "hint", "在 Options.SlotValidators 登记,或调用已弃用的 intent.RegisterSlotValidator")
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -16,14 +16,14 @@ func validSpec() IntentSpec {
|
||||
|
||||
func TestValidateSpecAcceptsValid(t *testing.T) {
|
||||
lim := Options{}.normalized().limits()
|
||||
if err := validateSpec(validSpec(), lim, nil); err != nil {
|
||||
if err := validateSpec(validSpec(), lim); err != nil {
|
||||
t.Fatalf("valid spec rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSpecDefaultsNoLimits(t *testing.T) {
|
||||
// 零值 limits 跳过数量上限(供意图较少的宿主直接校验)。
|
||||
if err := validateSpec(validSpec(), limits{}, nil); err != nil {
|
||||
if err := validateSpec(validSpec(), limits{}); err != nil {
|
||||
t.Fatalf("valid spec rejected without limits: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func TestValidateSpecRejections(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
spec := validSpec()
|
||||
tc.mut(&spec)
|
||||
err := validateSpec(spec, lim, nil)
|
||||
err := validateSpec(spec, lim)
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection")
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestValidateSpecIncludesAllOnly(t *testing.T) {
|
||||
// 仅 includes_all 也是合法正向条件。
|
||||
spec := validSpec()
|
||||
spec.Matcher = MatchSpec{IncludesAll: []string{"任务", "进度"}}
|
||||
if err := validateSpec(spec, Options{}.normalized().limits(), nil); err != nil {
|
||||
if err := validateSpec(spec, Options{}.normalized().limits()); err != nil {
|
||||
t.Fatalf("includes_all-only spec rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+11
-92
@@ -1,95 +1,14 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"name": "status_with_slot",
|
||||
"input": "查询任务 42 的进度",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.status_query",
|
||||
"expect_slots": {
|
||||
"run_id": "42"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "status_term_only",
|
||||
"input": "帮我看下进度",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.status_query"
|
||||
},
|
||||
{
|
||||
"name": "guard_config_query",
|
||||
"input": "查询系统配置",
|
||||
"expect_branch": "guard_hit",
|
||||
"expect_top": "guard.config_change"
|
||||
},
|
||||
{
|
||||
"name": "negated_write_readonly",
|
||||
"input": "怎么配置查询进度",
|
||||
"expect_branch": "fallback"
|
||||
},
|
||||
{
|
||||
"name": "restart_task",
|
||||
"input": "把任务重启一下",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.restart"
|
||||
},
|
||||
{
|
||||
"name": "greet_tiebreak_priority",
|
||||
"input": "你好呀",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.greet.hello"
|
||||
},
|
||||
{
|
||||
"name": "cancel_missing_required_slot",
|
||||
"input": "取消一下",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.cancel",
|
||||
"expect_missing_slots": [
|
||||
"run_id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "case_fold_deploy",
|
||||
"input": "please DEPLOY it",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.misc.deploy"
|
||||
},
|
||||
{
|
||||
"name": "casual_fallback",
|
||||
"input": "今天天气不错",
|
||||
"expect_branch": "fallback"
|
||||
},
|
||||
{
|
||||
"name": "blank_fallback",
|
||||
"input": " ",
|
||||
"expect_branch": "fallback"
|
||||
},
|
||||
{
|
||||
"name": "tie_ambiguous",
|
||||
"input": "打结",
|
||||
"expect_branch": "ambiguous"
|
||||
}
|
||||
],
|
||||
"guard_domains": [
|
||||
"guard"
|
||||
],
|
||||
"protected_domains": [
|
||||
"guard"
|
||||
],
|
||||
"builtin_specs": [
|
||||
{
|
||||
"id": "guard.config_change",
|
||||
"domain": "guard",
|
||||
"version": 1,
|
||||
"priority": 1000,
|
||||
"matcher": {
|
||||
"includes_any": [
|
||||
"系统配置",
|
||||
"网关",
|
||||
"改密码"
|
||||
]
|
||||
},
|
||||
"risk": "high",
|
||||
"handler": "refuse.and_guide"
|
||||
}
|
||||
{"name": "status_with_slot", "input": "查询任务 42 的进度", "expect_branch": "matched", "expect_top": "demo.run.status_query", "expect_slots": {"run_id": "42"}},
|
||||
{"name": "status_term_only", "input": "帮我看下进度", "expect_branch": "matched", "expect_top": "demo.run.status_query"},
|
||||
{"name": "guard_config_query", "input": "查询系统配置", "expect_branch": "guard_hit", "expect_top": "guard.config_change"},
|
||||
{"name": "negated_write_readonly", "input": "怎么配置查询进度", "expect_branch": "fallback"},
|
||||
{"name": "restart_task", "input": "把任务重启一下", "expect_branch": "matched", "expect_top": "demo.run.restart"},
|
||||
{"name": "greet_ambiguous", "input": "你好呀", "expect_branch": "ambiguous"},
|
||||
{"name": "cancel_missing_required_slot", "input": "取消一下", "expect_branch": "matched", "expect_top": "demo.run.cancel", "expect_missing_slots": ["run_id"]},
|
||||
{"name": "case_fold_deploy", "input": "please DEPLOY it", "expect_branch": "matched", "expect_top": "demo.misc.deploy"},
|
||||
{"name": "casual_fallback", "input": "今天天气不错", "expect_branch": "fallback"},
|
||||
{"name": "blank_fallback", "input": " ", "expect_branch": "fallback"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
-238
@@ -1,238 +0,0 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"name": "report_fill_slot_two_turns",
|
||||
"turns": [
|
||||
{
|
||||
"input": "帮我生成报告",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "report.generate",
|
||||
"expect_commands": [
|
||||
{
|
||||
"kind": "start",
|
||||
"action_kind": "handler",
|
||||
"action_id": "reportHandler"
|
||||
},
|
||||
{
|
||||
"kind": "clarify",
|
||||
"missing": [
|
||||
"topic"
|
||||
],
|
||||
"question": "要查询哪个主题?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "生成报告,主题是电机",
|
||||
"expect_branch": "expectation_hit",
|
||||
"expect_top": "report.generate",
|
||||
"expect_commands": [
|
||||
{
|
||||
"kind": "set_slot",
|
||||
"name": "topic",
|
||||
"value": "电机"
|
||||
},
|
||||
{
|
||||
"kind": "start",
|
||||
"action_kind": "handler",
|
||||
"action_id": "reportHandler",
|
||||
"slots": {
|
||||
"topic": "电机"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ambiguous_then_tiebreak_match",
|
||||
"turns": [
|
||||
{
|
||||
"input": "打结",
|
||||
"expect_branch": "ambiguous",
|
||||
"expect_commands": [
|
||||
{
|
||||
"kind": "clarify",
|
||||
"candidates": [
|
||||
"demo.tie.alpha",
|
||||
"demo.tie.beta"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "你好呀",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.greet.hello"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "single_matched_full_slots",
|
||||
"turns": [
|
||||
{
|
||||
"input": "查询任务 42 的进度",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.status_query",
|
||||
"expect_commands": [
|
||||
{
|
||||
"kind": "start",
|
||||
"action_kind": "handler",
|
||||
"action_id": "taskrun.query_status",
|
||||
"slots": {
|
||||
"run_id": "42"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "single_fallback",
|
||||
"turns": [
|
||||
{
|
||||
"input": "天气不错",
|
||||
"expect_branch": "fallback",
|
||||
"expect_commands": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "single_ambiguous_tie",
|
||||
"turns": [
|
||||
{
|
||||
"input": "打结",
|
||||
"expect_branch": "ambiguous",
|
||||
"expect_commands": [
|
||||
{
|
||||
"kind": "clarify",
|
||||
"candidates": [
|
||||
"demo.tie.alpha",
|
||||
"demo.tie.beta"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "single_tiebreak_priority",
|
||||
"turns": [
|
||||
{
|
||||
"input": "你好呀",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.greet.hello"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "single_matched_handler_action",
|
||||
"turns": [
|
||||
{
|
||||
"input": "查询任务 7 的进度",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.status_query",
|
||||
"expect_commands": [
|
||||
{
|
||||
"kind": "start",
|
||||
"action_kind": "handler",
|
||||
"action_id": "taskrun.query_status"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "two_turn_complete_then_fallback",
|
||||
"turns": [
|
||||
{
|
||||
"input": "生成报告,主题是电机",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "report.generate",
|
||||
"expect_commands": [
|
||||
{
|
||||
"kind": "start",
|
||||
"action_kind": "handler",
|
||||
"action_id": "reportHandler",
|
||||
"slots": {
|
||||
"topic": "电机"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "天气不错",
|
||||
"expect_branch": "fallback",
|
||||
"expect_commands": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "two_turn_missing_then_other_intent",
|
||||
"turns": [
|
||||
{
|
||||
"input": "帮我生成报告",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "report.generate"
|
||||
},
|
||||
{
|
||||
"input": "查询任务 42 的进度",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.status_query"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "single_clarify_missing_with_question",
|
||||
"turns": [
|
||||
{
|
||||
"input": "帮我生成报告",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "report.generate",
|
||||
"expect_commands": [
|
||||
{
|
||||
"kind": "start"
|
||||
},
|
||||
{
|
||||
"kind": "clarify",
|
||||
"missing": [
|
||||
"topic"
|
||||
],
|
||||
"question": "要查询哪个主题?"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "two_turn_set_slot_pump",
|
||||
"turns": [
|
||||
{
|
||||
"input": "帮我生成报告",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "report.generate"
|
||||
},
|
||||
{
|
||||
"input": "生成报告,主题是水泵",
|
||||
"expect_branch": "expectation_hit",
|
||||
"expect_top": "report.generate",
|
||||
"expect_commands": [
|
||||
{
|
||||
"kind": "set_slot",
|
||||
"name": "topic",
|
||||
"value": "水泵"
|
||||
},
|
||||
{
|
||||
"kind": "start",
|
||||
"action_kind": "handler",
|
||||
"action_id": "reportHandler",
|
||||
"slots": {
|
||||
"topic": "水泵"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+13
-103
@@ -6,49 +6,24 @@
|
||||
"version": 1,
|
||||
"description": "查询任务运行进度",
|
||||
"matcher": {
|
||||
"includes_any": [
|
||||
"进度",
|
||||
"状态"
|
||||
],
|
||||
"excludes_any": [
|
||||
"配置"
|
||||
]
|
||||
"includes_any": ["进度", "状态"],
|
||||
"excludes_any": ["配置"]
|
||||
},
|
||||
"slots": [
|
||||
{
|
||||
"key": "run_id",
|
||||
"required": false,
|
||||
"pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)",
|
||||
"group": 1
|
||||
}
|
||||
{"key": "run_id", "required": false, "pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)", "group": 1}
|
||||
],
|
||||
"confidence": {
|
||||
"base": 0.8,
|
||||
"threshold": 0.7
|
||||
},
|
||||
"confidence": {"base": 0.8, "threshold": 0.7},
|
||||
"risk": "low",
|
||||
"handler": "taskrun.query_status",
|
||||
"metadata": {
|
||||
"channel": "dialogue"
|
||||
}
|
||||
"metadata": {"channel": "dialogue"}
|
||||
},
|
||||
{
|
||||
"id": "demo.run.restart",
|
||||
"domain": "demo",
|
||||
"version": 1,
|
||||
"description": "重启任务",
|
||||
"matcher": {
|
||||
"includes_any": [
|
||||
"重启"
|
||||
],
|
||||
"includes_all": [
|
||||
"任务"
|
||||
]
|
||||
},
|
||||
"confidence": {
|
||||
"base": 0.75,
|
||||
"threshold": 0.7
|
||||
},
|
||||
"matcher": {"includes_any": ["重启"], "includes_all": ["任务"]},
|
||||
"confidence": {"base": 0.75, "threshold": 0.7},
|
||||
"risk": "medium",
|
||||
"requires_confirm": true,
|
||||
"handler": "taskrun.restart"
|
||||
@@ -58,18 +33,9 @@
|
||||
"domain": "demo",
|
||||
"version": 1,
|
||||
"description": "取消任务",
|
||||
"matcher": {
|
||||
"includes_any": [
|
||||
"取消"
|
||||
]
|
||||
},
|
||||
"matcher": {"includes_any": ["取消"]},
|
||||
"slots": [
|
||||
{
|
||||
"key": "run_id",
|
||||
"required": true,
|
||||
"pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)",
|
||||
"group": 1
|
||||
}
|
||||
{"key": "run_id", "required": true, "pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)", "group": 1}
|
||||
],
|
||||
"handler": "taskrun.cancel"
|
||||
},
|
||||
@@ -77,79 +43,23 @@
|
||||
"id": "demo.greet.hello",
|
||||
"domain": "demo",
|
||||
"version": 1,
|
||||
"matcher": {
|
||||
"includes_any": [
|
||||
"你好"
|
||||
]
|
||||
},
|
||||
"matcher": {"includes_any": ["你好"]},
|
||||
"priority": 10
|
||||
},
|
||||
{
|
||||
"id": "demo.greet.hi",
|
||||
"domain": "demo",
|
||||
"version": 1,
|
||||
"matcher": {
|
||||
"includes_any": [
|
||||
"你好"
|
||||
]
|
||||
},
|
||||
"matcher": {"includes_any": ["你好"]},
|
||||
"priority": 5
|
||||
},
|
||||
{
|
||||
"id": "demo.misc.deploy",
|
||||
"domain": "demo",
|
||||
"version": 1,
|
||||
"matcher": {
|
||||
"includes_any": [
|
||||
"deploy",
|
||||
"发布"
|
||||
],
|
||||
"case_sensitive": false
|
||||
},
|
||||
"matcher": {"includes_any": ["deploy", "发布"], "case_sensitive": false},
|
||||
"handler": "deploy.run"
|
||||
},
|
||||
{
|
||||
"id": "demo.tie.alpha",
|
||||
"domain": "demo",
|
||||
"version": 1,
|
||||
"matcher": {
|
||||
"includes_any": [
|
||||
"打结"
|
||||
]
|
||||
},
|
||||
"priority": 5
|
||||
},
|
||||
{
|
||||
"id": "demo.tie.beta",
|
||||
"domain": "demo",
|
||||
"version": 1,
|
||||
"matcher": {
|
||||
"includes_any": [
|
||||
"打结"
|
||||
]
|
||||
},
|
||||
"priority": 5
|
||||
},
|
||||
{
|
||||
"id": "report.generate",
|
||||
"domain": "report",
|
||||
"version": 1,
|
||||
"matcher": {
|
||||
"includes_any": [
|
||||
"生成报告"
|
||||
]
|
||||
},
|
||||
"slots": [
|
||||
{
|
||||
"key": "topic",
|
||||
"required": true,
|
||||
"pattern": "主题是(.+)",
|
||||
"question": "要查询哪个主题?",
|
||||
"label": "主题"
|
||||
}
|
||||
],
|
||||
"handler": "reportHandler"
|
||||
}
|
||||
],
|
||||
"disable": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"id":"demo.write","domain":"demo","version":1,"matcher":{"includes_any":["写"]}}
|
||||
@@ -1,34 +0,0 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"agent-intent/intent"
|
||||
)
|
||||
|
||||
func TestIntentSpecRoundTrip(t *testing.T) {
|
||||
raw, err := os.ReadFile("intentspec.sample.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var spec intent.IntentSpec
|
||||
if err := json.Unmarshal(raw, &spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.ID != "demo.write" || spec.Matcher.IncludesAny[0] != "写" {
|
||||
t.Fatalf("%+v", spec)
|
||||
}
|
||||
back, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var again intent.IntentSpec
|
||||
if err := json.Unmarshal(back, &again); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.ID != spec.ID || again.Version != 1 {
|
||||
t.Fatalf("%+v", again)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user