Implement the intent core
Details (original message): Implement the intent core, conformance suite, catalog alignment, examples, and the intentctl diagnostics CLI.
This commit is contained in:
@@ -24,15 +24,104 @@
|
||||
## 识别流水线
|
||||
|
||||
```
|
||||
Stage 0 Guard 守卫域规则(宿主声明 GuardDomains;命中 → GuardHit,可表达"识别即拒绝")
|
||||
Stage 1 Expectation 会话挂起预期(可选注入;命中 → ExpectationHit,支持多轮澄清)
|
||||
Stage 2 Rules 规则计分:includes_any / includes_all / excludes_any / patterns 对快照内启用 spec 计分
|
||||
Stage 3 Adjudicate 置信度裁决:唯一过阈 → Matched;多候选分数贴近 → Ambiguous;全不过 → Fallback
|
||||
Stage 0 Guard 守卫域规则(Options.GuardDomains;命中 → GuardHit,可表达"识别即拒绝")
|
||||
Stage 1 Expectation 会话挂起预期(可选注入 ExpectationProvider;命中 → ExpectationHit)
|
||||
Stage 2 Rules 规则计分:includes_any / includes_all / excludes_any / patterns
|
||||
Stage 3 Adjudicate 置信度裁决:唯一过阈 → Matched;前两名分差 < AmbiguityMargin → Ambiguous;全不过 → Fallback
|
||||
```
|
||||
|
||||
意图定义(`IntentSpec`)支持 Go 注册与 JSON overlay(新增 / 覆盖 / 禁用),合并产物为带 revision 的不可变快照;保护域(`ProtectedDomains`)内的 spec 不接受 overlay 覆盖或禁用。
|
||||
计分与裁决规则:
|
||||
|
||||
详细字段说明、接入代码与 `intentctl` 用法在核心实现合入后补全于本 README 与 `docs/`。
|
||||
- 单 spec 得分 `score = base + (任一 pattern 命中 ? pattern_bonus : 0)`,上限 1.0,四位小数舍入;`score ≥ 该 spec 的 threshold` 才进入候选。
|
||||
- 命中条件:`excludes_any` 全不命中 且 `includes_all` 全命中 且(`includes_any` 至少一词命中 或 任一 `pattern` 命中;仅配置 `includes_all` 时以全命中为准)。
|
||||
- 候选排序:置信度降序 → `priority` 降序 → 合并注册序 → id 字典序,全程确定性。
|
||||
- Ambiguous 分支可注入 `Classifier`(典型为宿主的 LLM 客户端适配器)重排;分类器失败保持 Ambiguous,不会使识别失败。
|
||||
- 槽位:Top 候选才抽取槽位;必填槽位未命中不撤销匹配,记录在 `Top.MissingRequiredSlots`,由宿主决定澄清(配合挂起预期)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```go
|
||||
b := intent.NewRegistryBuilder(intent.Options{
|
||||
ProtectedDomains: []string{"guard"}, // overlay 不得覆盖/禁用
|
||||
GuardDomains: []string{"guard"}, // Stage 0 演练的域
|
||||
})
|
||||
// 守卫内容宿主内置;业务意图走 JSON overlay(embed 或目录均可)。
|
||||
if err := b.Register(guardSpecs...); err != nil { ... }
|
||||
reg, err := b.BuildFS(ctx, specFS, "specs") // fs.FS + roots,root 为目录或单文件
|
||||
if err != nil { ... } // fail-closed:配置坏则构建失败
|
||||
|
||||
rec := intent.NewRecognizer(intent.Options{}, nil, nil) // 可选挂起预期 / 分类器
|
||||
res, err := rec.Recognize(ctx, "查询任务 42 的进度", reg.Snapshot())
|
||||
// res.Branch == intent.BranchMatched
|
||||
// res.Top.ID == "demo.run.status_query"; res.Top.Slots["run_id"] == "42"
|
||||
// res.Top.Handler / Risk / RequiresConfirm / Metadata 由宿主解释
|
||||
|
||||
if err := reg.Reload(ctx); err != nil { ... } // 显式重读配置;失败保留旧快照
|
||||
```
|
||||
|
||||
多意图输入用 `intent.NewRuleSegmenter().Segment(input)` 先分句再逐句识别。
|
||||
|
||||
## 意图定义(IntentSpec)
|
||||
|
||||
Go 与 JSON 共用同一结构;JSON overlay 文档形如:
|
||||
|
||||
```json
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"id": "demo.run.status_query",
|
||||
"domain": "demo",
|
||||
"version": 1,
|
||||
"description": "查询任务运行进度",
|
||||
"matcher": {
|
||||
"includes_any": ["进度", "状态"],
|
||||
"includes_all": ["任务"],
|
||||
"excludes_any": ["配置"],
|
||||
"patterns": ["(?:查询|看).{0,6}(?:进度|状态)"],
|
||||
"case_sensitive": false
|
||||
},
|
||||
"slots": [
|
||||
{"key": "run_id", "required": false, "pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)", "group": 1}
|
||||
],
|
||||
"confidence": {"base": 0.8, "pattern_bonus": 0.2, "threshold": 0.7},
|
||||
"priority": 100,
|
||||
"risk": "low",
|
||||
"requires_confirm": false,
|
||||
"handler": "taskrun.query_status",
|
||||
"metadata": {"channel": "dialogue"}
|
||||
}
|
||||
],
|
||||
"disable": ["demo.run.restart"]
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `id` | 全局唯一;首段必须等于 `domain`,各段 `^[a-z][a-z0-9_-]{0,63}$`,可多级(`demo.run.status_query`) |
|
||||
| `version` | ≥1;overlay 覆盖同 id 时必须递增 |
|
||||
| `matcher` | 见上文计分规则;至少一个正向条件;`case_sensitive` 只作用于词表,pattern 用 `(?i)` |
|
||||
| `slots` | `group` 默认 1;`required` 缺失记录到 `MissingRequiredSlots` |
|
||||
| `confidence` | 缺省 `base=0.7 / pattern_bonus=0.2 / threshold=0.6` |
|
||||
| `risk` / `requires_confirm` / `handler` / `metadata` | 模块不解释,透传给宿主做授权、确认与路由 |
|
||||
| `disabled` | 定义但停用(灰度暂存) |
|
||||
|
||||
合并语义:Go 内置为基线;目录 root 内 `*.json` 按文件名字典序、roots 按给定顺序应用;同 id 覆盖需版本递增;`disable` 按 id 禁用(保护域整批失败);未知字段拒绝并定位到文件。数量上限见 `Options`(spec 总数、单 spec 正则/槽位数、输入长度),防止配置膨胀。
|
||||
|
||||
## 诊断(intentctl)
|
||||
|
||||
```bash
|
||||
go run ./cmd/intentctl lint -root configs/intents # 校验 + 冲突提示
|
||||
go run ./cmd/intentctl explain -root configs/intents "查询任务进度" # 分支/候选/槽位/裁决轨迹
|
||||
go run ./cmd/intentctl eval -root configs/intents evals/corpus.json
|
||||
```
|
||||
|
||||
评测集格式见 `intent/testdata/corpus.json`(`name / input / expect_branch / expect_top / expect_slots / expect_missing_slots`)。评测集放在配置目录之外。注意:宿主以 Go 内置注册守卫时,守卫用例只能在宿主自己的测试(或 conformance.Run)中验证;intentctl 只加载 JSON 配置。
|
||||
|
||||
## 错误与日志
|
||||
|
||||
错误常量与 Error-Code-System 对齐(`intent.invalid_spec` / `intent.spec_protected` / `intent.load_failed`,调用方错误复用 `sys.invalid_argument`),`catalogcheck` 测试防双权威;映射表见 [Error-Code-System 的 agent-intent 映射](deps/error-code-system/docs/agent-intent-mapping.md)。错误类型实现 `ErrorCode()` / `ErrorFields()`,logsys 自动展开码与字段。日志为可选注入(`Options.Log`,nil→Nop),事件:`intent recognized`(branch/top/confidence/revision/duration)、`intent snapshot reloaded`、`intent config rejected`;原始输入不写入日志。
|
||||
|
||||
## 作为 submodule 使用
|
||||
|
||||
@@ -47,13 +136,14 @@ require agent-intent v0.0.0
|
||||
replace agent-intent => ./deps/agent-intent
|
||||
```
|
||||
|
||||
依赖模块内部的 replace 在上层构建中不生效:消费方须自带 `error-code-system` 与 `logging-system` 的 require + replace,并保持与本仓 gitlink 相同的组合。
|
||||
依赖模块内部的 replace 在上层构建中不生效:消费方须自带 `error-code-system` 与 `logging-system` 的 require + replace,并保持与本仓 gitlink 相同的组合。接入后建议在消费方测试里用 `conformance.Run` 跑自己的用例集。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...
|
||||
GOWORK=off go test -count=1 -race ./...
|
||||
GOWORK=off go list -m all # 仅标准库 + 两个生态模块
|
||||
```
|
||||
|
||||
需要 Go 1.27。规则入口见 [AGENTS.md](AGENTS.md),项目事实见 [PROJECT.md](PROJECT.md)。
|
||||
需要 Go 1.27。规则入口见 [AGENTS.md](AGENTS.md),项目事实见 [PROJECT.md](PROJECT.md),设计与验证记录见 `docs/records/`。
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package catalogcheck 保证本仓错误常量与 Error-Code-System 的 intent 域种子码
|
||||
// 逐个相等,防止出现第二套平行权威(仅测试,无生产代码)。
|
||||
package catalogcheck
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
_ "error-code-system/domains" // 填充 errcode.Default 种子码
|
||||
"error-code-system/errcode"
|
||||
|
||||
"agent-intent/intent"
|
||||
)
|
||||
|
||||
func TestIntentCodesAlignWithCatalog(t *testing.T) {
|
||||
pairs := []struct {
|
||||
local string
|
||||
canonical errcode.Code
|
||||
}{
|
||||
{intent.InvalidSpec, errcode.IntentInvalidSpec},
|
||||
{intent.SpecProtected, errcode.IntentSpecProtected},
|
||||
{intent.LoadFailed, errcode.IntentLoadFailed},
|
||||
{intent.InvalidArgument, errcode.SysInvalidArgument},
|
||||
}
|
||||
for _, p := range pairs {
|
||||
if string(p.canonical) != p.local {
|
||||
t.Fatalf("常量漂移:intent=%q catalog=%q", p.local, string(p.canonical))
|
||||
}
|
||||
// 规范码必须已在目录注册;未注册码 Resolve 会回落 sys.unknown_code,借此检出。
|
||||
def := errcode.Default.Resolve(p.local)
|
||||
if string(def.Code) != p.local {
|
||||
t.Fatalf("码未注册或被回落:%q -> %q", p.local, string(def.Code))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// intentctl 是意图配置的只读诊断 CLI:lint 校验配置并报告冲突,
|
||||
// explain 输出单条输入的裁决轨迹,eval 跑评测集。所有命令不改配置、不写文件。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"agent-intent/intent"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "intentctl:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
if len(args) < 1 {
|
||||
return usage()
|
||||
}
|
||||
var err error
|
||||
switch args[0] {
|
||||
case "lint":
|
||||
err = cmdLint(args[1:])
|
||||
case "explain":
|
||||
err = cmdExplain(args[1:])
|
||||
case "eval":
|
||||
err = cmdEval(args[1:])
|
||||
default:
|
||||
err = usage()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func usage() error {
|
||||
return fmt.Errorf("用法:intentctl <lint|explain|eval> ...\n lint -root <dir> [-protected a,b] [-guard a,b]\n explain -root <dir> [-protected a,b] [-guard a,b] <input>\n eval -root <dir> [-protected a,b] [-guard a,b] <corpus.json>")
|
||||
}
|
||||
|
||||
type commonOpts struct {
|
||||
root string
|
||||
protected string
|
||||
guard string
|
||||
}
|
||||
|
||||
func parseCommon(fs *flag.FlagSet) *commonOpts {
|
||||
c := &commonOpts{}
|
||||
fs.StringVar(&c.root, "root", "", "配置目录(*.json,必填)")
|
||||
fs.StringVar(&c.protected, "protected", "guard", "保护域,逗号分隔")
|
||||
fs.StringVar(&c.guard, "guard", "guard", "守卫域,逗号分隔")
|
||||
return c
|
||||
}
|
||||
|
||||
func buildRegistry(c *commonOpts) (*intent.Registry, error) {
|
||||
if c.root == "" {
|
||||
return nil, fmt.Errorf("-root 必填")
|
||||
}
|
||||
b := intent.NewRegistryBuilder(intent.Options{
|
||||
ProtectedDomains: splitDomains(c.protected),
|
||||
GuardDomains: splitDomains(c.guard),
|
||||
})
|
||||
return b.BuildFS(context.Background(), os.DirFS(c.root), ".")
|
||||
}
|
||||
|
||||
func splitDomains(s string) []string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cmdLint(args []string) error {
|
||||
fs := flag.NewFlagSet("lint", flag.ContinueOnError)
|
||||
c := parseCommon(fs)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
reg, err := buildRegistry(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("配置校验失败:%w", err)
|
||||
}
|
||||
snap := reg.Snapshot()
|
||||
fmt.Printf("快照 revision=%d 启用=%d 禁用=%d\n", snap.Revision(), len(snap.SpecIDs()), len(snap.DisabledIDs()))
|
||||
for _, id := range snap.SpecIDs() {
|
||||
src, _ := snap.Source(id)
|
||||
fmt.Printf(" spec %s (%s)\n", id, src)
|
||||
}
|
||||
for _, id := range snap.DisabledIDs() {
|
||||
src, _ := snap.Source(id)
|
||||
fmt.Printf(" disabled %s (%s)\n", id, src)
|
||||
}
|
||||
conflicts := snap.Conflicts()
|
||||
if len(conflicts) == 0 {
|
||||
fmt.Println("共享词条提示:无")
|
||||
return nil
|
||||
}
|
||||
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 cmdExplain(args []string) error {
|
||||
fs := flag.NewFlagSet("explain", flag.ContinueOnError)
|
||||
c := parseCommon(fs)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() < 1 {
|
||||
return fmt.Errorf("explain 需要一个输入参数")
|
||||
}
|
||||
input := fs.Arg(0)
|
||||
reg, err := buildRegistry(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("配置校验失败:%w", err)
|
||||
}
|
||||
snap := reg.Snapshot()
|
||||
rec := intent.NewRecognizer(intent.Options{}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), input, snap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("输入:%q(%d 字节)\n分支:%s\n", input, len(input), res.Branch)
|
||||
if res.Top != nil {
|
||||
fmt.Printf("Top:%s 置信度=%.4f 风险=%s 确认=%v handler=%s\n", res.Top.ID, res.Top.Confidence, res.Top.Risk, res.Top.RequiresConfirm, res.Top.Handler)
|
||||
if len(res.Top.Slots) > 0 {
|
||||
keys := make([]string, 0, len(res.Top.Slots))
|
||||
for k := range res.Top.Slots {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
fmt.Printf(" 槽位 %s=%q\n", k, res.Top.Slots[k])
|
||||
}
|
||||
}
|
||||
if len(res.Top.MissingRequiredSlots) > 0 {
|
||||
fmt.Printf(" 缺失必填槽位:%s\n", strings.Join(res.Top.MissingRequiredSlots, ","))
|
||||
}
|
||||
}
|
||||
if len(res.Candidates) > 0 {
|
||||
fmt.Printf("候选(%d):\n", len(res.Candidates))
|
||||
for _, cand := range res.Candidates {
|
||||
fmt.Printf(" %.4f %-28s %s\n", cand.Confidence, cand.ID, cand.Reason)
|
||||
}
|
||||
}
|
||||
fmt.Println("裁决轨迹:")
|
||||
trace := snap.Explain(input)
|
||||
fmt.Printf(" margin=%.4f\n", trace.AmbiguityMargin)
|
||||
printStage(" 守卫", trace.Guard, trace.GuardRejected)
|
||||
if trace.Expectation != nil {
|
||||
fmt.Printf(" 挂起预期:%v 命中=%s\n", trace.Expectation.PendingIDs, trace.Expectation.HitID)
|
||||
}
|
||||
printStage(" 规则", trace.Rules, trace.RulesRejected)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printStage(name string, scored []intent.ScoredSpec, rejected []intent.RejectedSpec) {
|
||||
if len(scored) == 0 && len(rejected) == 0 {
|
||||
fmt.Printf("%s:未评估\n", name)
|
||||
return
|
||||
}
|
||||
for _, s := range scored {
|
||||
fmt.Printf("%s 命中 %-28s %.4f %s %s\n", name, s.ID, s.Score, strings.Join(s.HitTerms, ","), strings.Join(s.HitPatterns, ","))
|
||||
}
|
||||
for _, r := range rejected {
|
||||
fmt.Printf("%s 排除 %-28s %s\n", name, r.ID, r.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 []evalCase `json:"cases"`
|
||||
}
|
||||
|
||||
func cmdEval(args []string) error {
|
||||
fs := flag.NewFlagSet("eval", flag.ContinueOnError)
|
||||
c := parseCommon(fs)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() < 1 {
|
||||
return fmt.Errorf("eval 需要评测集路径参数")
|
||||
}
|
||||
data, err := os.ReadFile(fs.Arg(0))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var corpus evalCorpus
|
||||
if err := json.Unmarshal(data, &corpus); err != nil {
|
||||
return fmt.Errorf("评测集解析失败:%w", err)
|
||||
}
|
||||
if len(corpus.Cases) == 0 {
|
||||
return fmt.Errorf("评测集为空")
|
||||
}
|
||||
reg, err := buildRegistry(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("配置校验失败:%w", err)
|
||||
}
|
||||
snap := reg.Snapshot()
|
||||
rec := intent.NewRecognizer(intent.Options{}, nil, nil)
|
||||
|
||||
failed := 0
|
||||
for _, tc := range corpus.Cases {
|
||||
res, err := rec.Recognize(context.Background(), tc.Input, snap)
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL %-28s 识别错误:%v\n", tc.Name, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
if string(res.Branch) != tc.ExpectBranch || (tc.ExpectTop != "" && (res.Top == nil || res.Top.ID != tc.ExpectTop)) {
|
||||
gotTop := "(none)"
|
||||
if res.Top != nil {
|
||||
gotTop = res.Top.ID
|
||||
}
|
||||
fmt.Printf("FAIL %-28s 输入=%q 分支=%s(期望 %s) top=%s(期望 %s)\n", tc.Name, tc.Input, res.Branch, tc.ExpectBranch, gotTop, tc.ExpectTop)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
slotOK := true
|
||||
for k, v := range tc.ExpectSlots {
|
||||
if res.Top == nil || res.Top.Slots[k] != v {
|
||||
slotOK = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !slotOK {
|
||||
fmt.Printf("FAIL %-28s 槽位不符:%v\n", tc.Name, res.Top.Slots)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
fmt.Printf("PASS %-28s %s\n", tc.Name, res.Branch)
|
||||
}
|
||||
fmt.Printf("总计 %d 用例:%d 通过,%d 失败\n", len(corpus.Cases), len(corpus.Cases)-failed, failed)
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("%d/%d 用例失败", failed, len(corpus.Cases))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testSpecJSON = `{
|
||||
"intents": [
|
||||
{"id": "demo.run.status_query", "domain": "demo", "version": 1,
|
||||
"matcher": {"includes_any": ["进度"], "excludes_any": ["配置"]},
|
||||
"slots": [{"key": "run_id", "pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)"}],
|
||||
"handler": "taskrun.query_status"},
|
||||
{"id": "demo.greet.hello", "domain": "demo", "version": 1, "matcher": {"includes_any": ["你好"]}},
|
||||
{"id": "demo.greet.hi", "domain": "demo", "version": 1, "matcher": {"includes_any": ["你好"]}}
|
||||
]
|
||||
}`
|
||||
|
||||
const testCorpusJSON = `{
|
||||
"cases": [
|
||||
{"name": "status", "input": "查询任务 42 的进度", "expect_branch": "matched", "expect_top": "demo.run.status_query", "expect_slots": {"run_id": "42"}},
|
||||
{"name": "ambiguous", "input": "你好", "expect_branch": "ambiguous"},
|
||||
{"name": "fallback", "input": "天气不错", "expect_branch": "fallback"}
|
||||
]
|
||||
}`
|
||||
|
||||
func writeTemp(t *testing.T, name, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestLintExplainEval(t *testing.T) {
|
||||
dir := writeTemp(t, "specs.json", testSpecJSON)
|
||||
// 评测集放在配置目录之外:lint 的目录 glob 只认配置 overlay。
|
||||
corpusDir := t.TempDir()
|
||||
corpus := filepath.Join(corpusDir, "corpus.json")
|
||||
if err := os.WriteFile(corpus, []byte(testCorpusJSON), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := run([]string{"lint", "-root", dir, "-protected", ""}); err != nil {
|
||||
t.Fatalf("lint: %v", err)
|
||||
}
|
||||
if err := run([]string{"explain", "-root", dir, "-protected", "", "查询任务 42 的进度"}); err != nil {
|
||||
t.Fatalf("explain: %v", err)
|
||||
}
|
||||
if err := run([]string{"eval", "-root", dir, "-protected", "", corpus}); err != nil {
|
||||
t.Fatalf("eval: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLintRejectsInvalidConfig(t *testing.T) {
|
||||
dir := writeTemp(t, "specs.json", `{"intents": [{"id": "demo.x.one", "domain": "demo", "version": 1, "matcher": {}}]}`)
|
||||
if err := run([]string{"lint", "-root", dir}); err == nil {
|
||||
t.Fatal("expected lint failure on invalid spec")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalFailsOnWrongExpectation(t *testing.T) {
|
||||
dir := writeTemp(t, "specs.json", testSpecJSON)
|
||||
corpus := filepath.Join(t.TempDir(), "corpus.json")
|
||||
if err := os.WriteFile(corpus, []byte(`{"cases": [{"name": "wrong", "input": "你好", "expect_branch": "matched"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := run([]string{"eval", "-root", dir, "-protected", "", corpus}); err == nil {
|
||||
t.Fatal("expected eval failure on wrong expectation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Package conformance 提供 Recognizer 公开契约的行为验收套件。
|
||||
// 消费方在自己的测试里用真实配置组装 Registry 与 Recognizer 后调用 Run,
|
||||
// 断言分支、Top、槽位,并附带确定性与并发冒烟检查。
|
||||
package conformance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"agent-intent/intent"
|
||||
)
|
||||
|
||||
// Case 单条行为验收用例。ExpectTopID 为空断言 Top 为 nil;
|
||||
// ExpectSlots / ExpectMissingSlots 只对 Top 断言。
|
||||
type Case struct {
|
||||
Name string
|
||||
Input string
|
||||
ExpectBranch intent.Branch
|
||||
ExpectTopID string
|
||||
ExpectSlots map[string]string
|
||||
ExpectMissingSlots []string
|
||||
}
|
||||
|
||||
// Run 逐条执行用例并输出标准断言失败信息;任何用例失败使 t 失败。
|
||||
func Run(t *testing.T, rec intent.Recognizer, snap *intent.Snapshot, cases []Case) {
|
||||
t.Helper()
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
res, err := rec.Recognize(context.Background(), tc.Input, snap)
|
||||
if err != nil {
|
||||
t.Fatalf("recognize %q: %v", tc.Input, err)
|
||||
}
|
||||
if res.Branch != tc.ExpectBranch {
|
||||
t.Fatalf("branch = %s, want %s", res.Branch, tc.ExpectBranch)
|
||||
}
|
||||
switch {
|
||||
case tc.ExpectTopID == "" && res.Top != nil:
|
||||
t.Fatalf("expected no top, got %+v", res.Top)
|
||||
case tc.ExpectTopID != "" && res.Top == nil:
|
||||
t.Fatalf("expected top %s, got nil", tc.ExpectTopID)
|
||||
case tc.ExpectTopID != "" && res.Top.ID != tc.ExpectTopID:
|
||||
t.Fatalf("top = %s, want %s", res.Top.ID, tc.ExpectTopID)
|
||||
}
|
||||
if res.Top != nil {
|
||||
for k, v := range tc.ExpectSlots {
|
||||
if res.Top.Slots[k] != v {
|
||||
t.Fatalf("slot %s = %q, want %q", k, res.Top.Slots[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(tc.ExpectMissingSlots) > 0 && (res.Top == nil || !reflect.DeepEqual(res.Top.MissingRequiredSlots, tc.ExpectMissingSlots)) {
|
||||
t.Fatalf("missing slots = %+v, want %v", res.Top, tc.ExpectMissingSlots)
|
||||
}
|
||||
// 确定性:同输入 + 同快照 → 同结果。
|
||||
again, err := rec.Recognize(context.Background(), tc.Input, snap)
|
||||
if err != nil {
|
||||
t.Fatalf("second recognize: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(res, again) {
|
||||
t.Fatalf("nondeterministic result for %q", tc.Input)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 并发冒烟:同一快照并行识别互不影响(配合 -race)。
|
||||
t.Run("parallel-smoke", func(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
for _, tc := range cases {
|
||||
wg.Add(1)
|
||||
go func(input string) {
|
||||
defer wg.Done()
|
||||
if _, err := rec.Recognize(context.Background(), input, snap); err != nil {
|
||||
t.Errorf("parallel recognize %q: %v", input, err)
|
||||
}
|
||||
}(tc.Input)
|
||||
}
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package conformance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"agent-intent/intent"
|
||||
)
|
||||
|
||||
func TestRunAcceptsCompliantRecognizer(t *testing.T) {
|
||||
fsys := fstest.MapFS{"specs/demo.json": &fstest.MapFile{Data: []byte(`{
|
||||
"intents": [
|
||||
{"id": "demo.run.status_query", "domain": "demo", "version": 1,
|
||||
"matcher": {"includes_any": ["进度"]},
|
||||
"slots": [{"key": "run_id", "pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)"}],
|
||||
"handler": "taskrun.query_status"},
|
||||
{"id": "demo.greet.hello", "domain": "demo", "version": 1, "matcher": {"includes_any": ["你好"]}},
|
||||
{"id": "demo.greet.hi", "domain": "demo", "version": 1, "matcher": {"includes_any": ["你好"]}}
|
||||
]
|
||||
}`)}}
|
||||
b := intent.NewRegistryBuilder(intent.Options{})
|
||||
reg, err := b.BuildFS(context.Background(), fsys, "specs")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := intent.NewRecognizer(intent.Options{}, nil, nil)
|
||||
Run(t, rec, reg.Snapshot(), []Case{
|
||||
{Name: "matched", Input: "查询任务 42 的进度", ExpectBranch: intent.BranchMatched, ExpectTopID: "demo.run.status_query", ExpectSlots: map[string]string{"run_id": "42"}},
|
||||
{Name: "ambiguous", Input: "你好", ExpectBranch: intent.BranchAmbiguous},
|
||||
{Name: "fallback", Input: "今天天气不错", ExpectBranch: intent.BranchFallback},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# Agent-Intent v0.1.0 实现与验证记录
|
||||
|
||||
对应设计方案:[2026-09-29-agent-intent-design.md](2026-09-29-agent-intent-design.md)(决策 D1–D7 按推荐执行,经用户批准)。
|
||||
范围:P0–P3 全部完成;P4(消费方接入 PR)按方案另立任务,不在本仓。
|
||||
|
||||
## 交付内容
|
||||
|
||||
- `intent` 核心包:spec 定义与校验(id 分段格式、正则/槽位/数量上限)、RegistryBuilder / Registry / 不可变 Snapshot(atomic.Pointer 原子替换)、JSON overlay 加载合并(新增/覆盖需版本递增/禁用/保护域拒绝/未知字段拒绝)、四级漏斗识别器(guard → expectation → rules → adjudicate)、槽位抽取(必填缺失记录于 `MissingRequiredSlots`)、`Snapshot.Explain` 裁决轨迹、`Snapshot.Conflicts` 共享词条诊断、`RuleSegmenter` 规则分句、`Classifier` / `ExpectationProvider` 扩展接口、错误码鸭子方法 + logsys 可选注入。
|
||||
- `conformance`:消费方行为验收套件(分支/Top/槽位断言 + 确定性 + 并发冒烟)。
|
||||
- `catalogcheck`:`intent.*` / 复用的 `sys.invalid_argument` 常量与错误码库目录逐个相等。
|
||||
- `cmd/intentctl`:`lint`(校验 + 冲突报告 + 来源清单)、`explain`(分支/候选/槽位/两阶段轨迹)、`eval`(评测集跑批,退出码承载失败)。
|
||||
- `examples`:守卫 Go 内置(保护域)+ 业务 JSON embed 的端到端示例。
|
||||
- 测试:spec 校验表测、loader 合并语义与来源定位、registry 快照/Reload 保留旧快照、识别器全分支(守卫短路、挂起预期、模式加成、歧义、分类器成败、必填槽位缺失、大小写折叠、空输入、超长输入、nil 快照)、确定性深比较、`-race` 并发识别 + Reload、评测集回归。
|
||||
|
||||
## 与方案的偏差
|
||||
|
||||
1. **执行顺序**:P1(Error-Code-System 上游 `intent` 域)先于 P0 完成,使本仓首个提交直接 pin ECS 新提交 `28fa06ea`,避免一次 pin 漂移。ECS 变更为追加式(新域 3 码 + 目录 JSON + 映射文档),不影响既有消费方。
|
||||
2. **id 格式**:实现采用点分段 `^[a-z][a-z0-9_-]{0,63}$`(首段=domain),支持多级记忆名(`demo.run.status_query`);方案原文的单段记忆名正则不含层级,属笔误级修正。
|
||||
3. **计分舍入**:得分固定四位小数舍入,避免浮点噪声进入结果与 margin 比较(方案未规定,实现补齐并写入 README)。
|
||||
4. **intentctl eval 的守卫用例**:CLI 只加载 JSON 配置,宿主 Go 内置守卫的用例只能在宿主测试/conformance 中验证(README 已说明)。模块自带评测集的 guard 用例由 Go 测试路径覆盖。
|
||||
5. **Result 顶层 Slots**:方案便捷字段未实现,槽位在 `Top.Slots`;语义等价,避免双份状态。
|
||||
|
||||
## 验证证据
|
||||
|
||||
| 验收条件(方案验收表) | 结果 |
|
||||
|---|---|
|
||||
| 同输入+同快照→同结果(含 Explain) | 通过:`TestRecognizeDeterministic` 深比较 |
|
||||
| guard 保护域不可被 overlay 禁用/覆盖 | 通过:`TestBuildFSProtectedDomain`(两种尝试均 `intent.spec_protected`) |
|
||||
| 配置错误定位到字段与来源 | 通过:`TestValidateSpecRejections`、`TestLoadOverlaysUnknownField`、`TestBuildFSOverrideMustIncreaseVersion`(fields 含 file/source/field) |
|
||||
| 合并语义(新增/覆盖/禁用/版本递增/位置保持) | 通过:`TestBuildFSMergeSemantics`、`TestLoadOverlaysFileOrder` |
|
||||
| 无匹配=Fallback 非错误 | 通过:`TestRecognizeExcludedTermFallsBack`、`TestRecognizeEmptyInputFallsBack` |
|
||||
| Reload 失败保留旧快照;并发识别不受影响 | 通过:`TestReloadSwapsAndKeepsOldOnFailure`、`TestConcurrentRecognizeAndReload`(-race) |
|
||||
| 硬负例语料(否定写+只读、排除词、近义混淆) | 通过:`TestEvalCorpus`(10 用例,含 `negated_write_readonly`) |
|
||||
| 错误码与上游 intent 域逐个相等 | 通过:`catalogcheck.TestIntentCodesAlignWithCatalog` |
|
||||
| 零第三方依赖 | 通过:`go list -m all` 仅 `error-code-system` / `logging-system`(本地 replace) |
|
||||
| 消费方可按生态范式接入 | 通过:`examples.TestEndToEnd`(embed 配置 → 守卫/匹配/槽位/元数据/诊断) |
|
||||
| intentctl 只读诊断 | 通过:`cmd/intentctl` 三个命令单测 + 手工冒烟(lint/explain/eval 输出见会话) |
|
||||
|
||||
## 命令与结果(2026-09-29,本机 go1.27.1 linux/amd64)
|
||||
|
||||
```bash
|
||||
GOWORK=off go vet ./... # 通过,无输出
|
||||
GOWORK=off go test -count=1 ./... # ok:catalogcheck / cmd/intentctl / conformance / examples / intent
|
||||
GOWORK=off go test -count=1 -race ./... # 同上,全部 ok
|
||||
GOWORK=off go list -m all # agent-intent + 两个生态子模块
|
||||
scripts/submodule-consistency.sh # 见提交时输出(三个 ok)
|
||||
```
|
||||
|
||||
## 组合与回退
|
||||
|
||||
- 本仓 pin:development-guidelines `26b562d2`、error-code-system `28fa06ea`(含 intent 域)、logging-system `2c5716fd`;与 Agent-Squad / Agent-Native 当前组合一致(ECS 为追加式前进)。
|
||||
- 消费方回退:普通 PR 恢复旧 gitlink 即回退本模块;ECS 的 `intent` 域为纯追加,回退本仓不需要回退 ECS。
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package examples 演示消费方最小接入:守卫内容以 Go 内置注册并放入保护域,
|
||||
// 业务意图来自嵌入的 JSON overlay;识别、槽位与诊断全部走公开 API。
|
||||
package examples
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"testing"
|
||||
|
||||
"agent-intent/conformance"
|
||||
"agent-intent/intent"
|
||||
)
|
||||
|
||||
//go:embed specs/*.json
|
||||
var specFS embed.FS
|
||||
|
||||
// guardSpec 守卫内容属于宿主内置资产:放进保护域后 overlay 无法覆盖或禁用。
|
||||
func guardSpec() intent.IntentSpec {
|
||||
return intent.IntentSpec{
|
||||
ID: "guard.config_change",
|
||||
Domain: "guard",
|
||||
Version: 1,
|
||||
Priority: 1000,
|
||||
Matcher: intent.MatchSpec{IncludesAny: []string{"系统配置", "网关", "改密码"}},
|
||||
Risk: intent.RiskHigh,
|
||||
Handler: "refuse.and_guide",
|
||||
}
|
||||
}
|
||||
|
||||
func build(t *testing.T) (*intent.Registry, intent.Recognizer) {
|
||||
t.Helper()
|
||||
b := intent.NewRegistryBuilder(intent.Options{
|
||||
ProtectedDomains: []string{"guard"},
|
||||
GuardDomains: []string{"guard"},
|
||||
})
|
||||
if err := b.Register(guardSpec()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.BuildFS(context.Background(), specFS, "specs")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reg, intent.NewRecognizer(intent.Options{}, nil, nil)
|
||||
}
|
||||
|
||||
func TestEndToEnd(t *testing.T) {
|
||||
reg, rec := build(t)
|
||||
snap := reg.Snapshot()
|
||||
|
||||
conformance.Run(t, rec, snap, []conformance.Case{
|
||||
{Name: "guard_intercepts", Input: "帮我改一下系统网关地址", ExpectBranch: intent.BranchGuardHit, ExpectTopID: "guard.config_change"},
|
||||
{Name: "matched_with_slot", Input: "查询任务 42 的进度", ExpectBranch: intent.BranchMatched, ExpectTopID: "demo.run.status_query", ExpectSlots: map[string]string{"run_id": "42"}},
|
||||
{Name: "restart_requires_confirm", Input: "把任务重启一下", ExpectBranch: intent.BranchMatched, ExpectTopID: "demo.run.restart"},
|
||||
{Name: "free_falls_back", Input: "帮我写一份周报", ExpectBranch: intent.BranchFallback},
|
||||
})
|
||||
|
||||
// 宿主用 Top 的元数据决定后续动作(通道、确认策略、动作键)。
|
||||
res, err := rec.Recognize(context.Background(), "把任务重启一下", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Top.RequiresConfirm || res.Top.Handler != "taskrun.restart" || res.Top.Risk != intent.RiskMedium {
|
||||
t.Fatalf("top metadata: %+v", res.Top)
|
||||
}
|
||||
|
||||
// 诊断:Explain 给出守卫与规则两阶段的完整裁决轨迹。
|
||||
trace := snap.Explain("怎么配置查询进度")
|
||||
if trace.Branch != intent.BranchFallback {
|
||||
t.Fatalf("explain branch: %+v", trace)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"id": "demo.run.status_query",
|
||||
"domain": "demo",
|
||||
"version": 1,
|
||||
"description": "查询任务运行进度",
|
||||
"matcher": {"includes_any": ["进度", "状态"], "excludes_any": ["配置"]},
|
||||
"slots": [
|
||||
{"key": "run_id", "required": false, "pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)", "group": 1}
|
||||
],
|
||||
"confidence": {"base": 0.8, "threshold": 0.7},
|
||||
"handler": "taskrun.query_status",
|
||||
"metadata": {"channel": "dialogue"}
|
||||
},
|
||||
{
|
||||
"id": "demo.run.restart",
|
||||
"domain": "demo",
|
||||
"version": 1,
|
||||
"description": "重启任务",
|
||||
"matcher": {"includes_any": ["重启"], "includes_all": ["任务"]},
|
||||
"confidence": {"base": 0.75, "threshold": 0.7},
|
||||
"risk": "medium",
|
||||
"requires_confirm": true,
|
||||
"handler": "taskrun.restart"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package intent
|
||||
|
||||
import "context"
|
||||
|
||||
// Classifier 是可选的 L2 重打分扩展点,典型由宿主用其 LLM 客户端实现。
|
||||
// 只在规则裁决为 Ambiguous 时被调用;实现自担延迟与成本。
|
||||
// 返回错误时识别保持 Ambiguous,不会使 Recognize 失败(识别确定性不受实现影响)。
|
||||
// 宿主应保证 Refine 幂等且无副作用:同样的输入与候选应得到同样的重排结果。
|
||||
type Classifier interface {
|
||||
// Refine 对候选重打分/重排。返回的切片应包含与输入相同的一组候选
|
||||
// (可更新 Confidence 与顺序);快照信息已随候选 ID 携带。
|
||||
Refine(ctx context.Context, input string, candidates []Candidate) ([]Candidate, error)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package intent 提供通用的意图识别机制:意图定义(spec)、注册表与配置合并、
|
||||
// 四级漏斗识别器(guard → expectation → rules → adjudicate)、槽位抽取与诊断。
|
||||
//
|
||||
// 本包不含任何业务意图内容;词表、正则与守卫规则一律由宿主以 Go 注册或 JSON 配置提供。
|
||||
// 识别路径纯内存、确定性:同输入 + 同快照 → 同结果。「无匹配」(fallback)是正常
|
||||
// 识别结果而非错误;错误只表示系统级失败(配置损坏、调用方参数不合法)。
|
||||
package intent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 规范码常量。字符串值与 Error-Code-System 的 intent 域 / sys 域种子码一致,
|
||||
// 由 catalogcheck 包的对齐测试防止两处漂移。
|
||||
const (
|
||||
// InvalidSpec 表示意图定义或配置校验失败:id 冲突、正则不合法、未知字段、
|
||||
// matcher 缺少正向条件、槽位 group 越界、覆盖版本未递增等。整批加载已原子拒绝。
|
||||
InvalidSpec = "intent.invalid_spec"
|
||||
// SpecProtected 表示 overlay 试图覆盖或禁用保护域内的 spec。保护域由宿主构建时声明。
|
||||
SpecProtected = "intent.spec_protected"
|
||||
// LoadFailed 表示配置读取或解析失败。失败时保留上一生效快照。
|
||||
LoadFailed = "intent.load_failed"
|
||||
// InvalidArgument 复用框架域,表示调用方错误:nil 快照、输入超长。
|
||||
InvalidArgument = "sys.invalid_argument"
|
||||
)
|
||||
|
||||
// Error 是本模块的系统级错误。实现 ErrorCode / ErrorFields 鸭子方法,
|
||||
// logging-system 的 logsys 识别后自动把码写入 code、字段并入 fields,无需互相依赖。
|
||||
type Error struct {
|
||||
Code string
|
||||
Message string
|
||||
Fields map[string]any
|
||||
}
|
||||
|
||||
// Error 实现error接口。
|
||||
func (e *Error) Error() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("intent: ")
|
||||
b.WriteString(e.Code)
|
||||
b.WriteString(": ")
|
||||
b.WriteString(e.Message)
|
||||
keys := make([]string, 0, len(e.Fields))
|
||||
for k := range e.Fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(&b, " %s=%v", k, e.Fields[k])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ErrorCode 返回规范码,供日志与错误信封对齐。
|
||||
func (e *Error) ErrorCode() string { return e.Code }
|
||||
|
||||
// ErrorFields 返回结构化定位字段(spec id、来源文件、字段路径等)。
|
||||
func (e *Error) ErrorFields() map[string]any { return e.Fields }
|
||||
|
||||
func newError(code, message string, kv ...any) *Error {
|
||||
return &Error{Code: code, Message: message, Fields: fieldsFrom(kv)}
|
||||
}
|
||||
|
||||
func (e *Error) with(kv ...any) *Error {
|
||||
merged := make(map[string]any, len(e.Fields)+len(kv)/2)
|
||||
for k, v := range e.Fields {
|
||||
merged[k] = v
|
||||
}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
if k, ok := kv[i].(string); ok {
|
||||
merged[k] = kv[i+1]
|
||||
}
|
||||
}
|
||||
return &Error{Code: e.Code, Message: e.Message, Fields: merged}
|
||||
}
|
||||
|
||||
func fieldsFrom(kv []any) map[string]any {
|
||||
m := make(map[string]any, len(kv)/2)
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
if k, ok := kv[i].(string); ok {
|
||||
m[k] = kv[i+1]
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// corpusCase 与 intentctl eval 共用同一份评测集格式(testdata/corpus.json)。
|
||||
type corpusCase 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 corpusDoc struct {
|
||||
Cases []corpusCase `json:"cases"`
|
||||
}
|
||||
|
||||
func runCorpus(t *testing.T, rec Recognizer, snap *Snapshot, cases []corpusCase) {
|
||||
t.Helper()
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
res, err := rec.Recognize(context.Background(), tc.Input, snap)
|
||||
if err != nil {
|
||||
t.Fatalf("recognize: %v", err)
|
||||
}
|
||||
if string(res.Branch) != tc.ExpectBranch {
|
||||
t.Fatalf("branch = %s, want %s (top=%+v)", res.Branch, tc.ExpectBranch, res.Top)
|
||||
}
|
||||
if tc.ExpectTop == "" {
|
||||
if res.Top != nil {
|
||||
t.Fatalf("expected no top, got %+v", res.Top)
|
||||
}
|
||||
} else if res.Top == nil || res.Top.ID != tc.ExpectTop {
|
||||
t.Fatalf("top = %+v, want %s", res.Top, tc.ExpectTop)
|
||||
}
|
||||
for k, v := range tc.ExpectSlots {
|
||||
if res.Top == nil || res.Top.Slots[k] != v {
|
||||
t.Fatalf("slot %s = %v, want %v (top=%+v)", k, res.Top.Slots, v, res.Top)
|
||||
}
|
||||
}
|
||||
if len(tc.ExpectMissingSlots) > 0 {
|
||||
if res.Top == nil || len(res.Top.MissingRequiredSlots) != len(tc.ExpectMissingSlots) {
|
||||
t.Fatalf("missing slots = %+v, want %v", res.Top, tc.ExpectMissingSlots)
|
||||
}
|
||||
for i, k := range tc.ExpectMissingSlots {
|
||||
if res.Top.MissingRequiredSlots[i] != k {
|
||||
t.Fatalf("missing slots = %+v, want %v", res.Top.MissingRequiredSlots, tc.ExpectMissingSlots)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvalCorpus 是模块自带的评测集回归:守护内容 Go 内置 + 业务意图 JSON 配置,
|
||||
// 覆盖正常路径、守卫拦截、否定写硬负例、必填槽位缺失与大小写折叠。
|
||||
func TestEvalCorpus(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/corpus.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc corpusDoc
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(doc.Cases) == 0 {
|
||||
t.Fatal("empty corpus")
|
||||
}
|
||||
_, snap := buildDemoRegistry(t)
|
||||
rec := NewRecognizer(Options{}, nil, nil)
|
||||
runCorpus(t, rec, snap, doc.Cases)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package intent
|
||||
|
||||
import "context"
|
||||
|
||||
// PendingExpectation 宿主在上一轮对话结束时声明的预期后续意图。
|
||||
// 典型场景:宿主向用户发起澄清提问后,预期下一轮命中澄清选项对应的意图。
|
||||
type PendingExpectation struct {
|
||||
// IntentIDs 预期的意图 id 列表(须为快照内已启用的 spec)。
|
||||
IntentIDs []string
|
||||
// Reason 预期由来,供日志与诊断;模块不解释。
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ExpectationProvider 提供当前会话的挂起预期(Stage 1 扩展点)。
|
||||
// 返回 nil 或空 IntentIDs 表示本轮没有挂起预期,识别直接进入规则阶段。
|
||||
// 由宿主实现:模块不持有会话状态。
|
||||
type ExpectationProvider interface {
|
||||
Pending(ctx context.Context) *PendingExpectation
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// overlayDoc 是 JSON 配置文件的顶层结构。
|
||||
//
|
||||
// {
|
||||
// "intents": [ { ...IntentSpec... } ],
|
||||
// "disable": [ "demo.run.status_query" ]
|
||||
// }
|
||||
//
|
||||
// intents 内同 id 条目覆盖内置或先前定义(version 必须递增);disable 按id禁用
|
||||
// (保护域内的 id 会整批失败)。目录 root 内按文件名字典序加载,roots 按给定顺序应用。
|
||||
type overlayDoc struct {
|
||||
Intents []IntentSpec `json:"intents"`
|
||||
Disable []string `json:"disable"`
|
||||
}
|
||||
|
||||
// overlayEntry 单条 overlay 指令,source 定位到文件与下标,供错误与诊断使用。
|
||||
type overlayEntry struct {
|
||||
disable bool
|
||||
id string
|
||||
spec IntentSpec
|
||||
source string
|
||||
}
|
||||
|
||||
// loadOverlays 从 fsys 读取全部 roots。root 可以是目录(加载其中 *.json,字典序)
|
||||
// 或单个文件路径。任何读取、解析或未知字段错误都会带来源返回。
|
||||
func loadOverlays(fsys fs.FS, roots []string) ([]overlayEntry, error) {
|
||||
var entries []overlayEntry
|
||||
for _, root := range roots {
|
||||
root = path.Clean(root)
|
||||
files, err := overlayFiles(fsys, root)
|
||||
if err != nil {
|
||||
return nil, newError(LoadFailed, "读取配置 root 失败", "root", root, "error", err.Error())
|
||||
}
|
||||
for _, f := range files {
|
||||
data, err := fs.ReadFile(fsys, f)
|
||||
if err != nil {
|
||||
return nil, newError(LoadFailed, "读取配置文件失败", "file", f, "error", err.Error())
|
||||
}
|
||||
var doc overlayDoc
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&doc); err != nil {
|
||||
return nil, newError(InvalidSpec, "配置解析失败(含未知字段)", "file", f, "error", err.Error())
|
||||
}
|
||||
for i := range doc.Intents {
|
||||
entries = append(entries, overlayEntry{spec: doc.Intents[i], source: fmt.Sprintf("%s#intents[%d]", f, i)})
|
||||
}
|
||||
for i, id := range doc.Disable {
|
||||
entries = append(entries, overlayEntry{disable: true, id: id, source: fmt.Sprintf("%s#disable[%d]", f, i)})
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func overlayFiles(fsys fs.FS, root string) ([]string, error) {
|
||||
st, err := fs.Stat(fsys, root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !st.IsDir() {
|
||||
return []string{root}, nil
|
||||
}
|
||||
matches, err := fs.Glob(fsys, path.Join(root, "*.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(matches)
|
||||
return matches, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func mapFS(files map[string]string) fstest.MapFS {
|
||||
m := make(fstest.MapFS, len(files))
|
||||
for k, v := range files {
|
||||
m[k] = &fstest.MapFile{Data: []byte(v)}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
const overlayAddJSON = `{
|
||||
"intents": [
|
||||
{"id": "demo.misc.added", "domain": "demo", "version": 1, "matcher": {"includes_any": ["新增"]}}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestLoadOverlaysOrderAndSources(t *testing.T) {
|
||||
fsys := mapFS(map[string]string{
|
||||
"specs/a.json": overlayAddJSON,
|
||||
"specs/b.json": `{"disable": ["demo.misc.added"]}`,
|
||||
})
|
||||
entries, err := loadOverlays(fsys, []string{"specs"})
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
if entries[0].spec.ID != "demo.misc.added" || entries[0].source != "specs/a.json#intents[0]" {
|
||||
t.Fatalf("entry0 mismatch: %+v", entries[0])
|
||||
}
|
||||
if !entries[1].disable || entries[1].id != "demo.misc.added" || entries[1].source != "specs/b.json#disable[0]" {
|
||||
t.Fatalf("entry1 mismatch: %+v", entries[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOverlaysFileOrder(t *testing.T) {
|
||||
// 同目录按文件名字典序:a.json 先应用,b.json 后应用。
|
||||
fsys := mapFS(map[string]string{
|
||||
"specs/a.json": `{"intents": [{"id": "demo.x.one", "domain": "demo", "version": 1, "matcher": {"includes_any": ["a"]}}]}`,
|
||||
"specs/b.json": `{"intents": [{"id": "demo.x.one", "domain": "demo", "version": 2, "matcher": {"includes_any": ["b"]}}]}`,
|
||||
})
|
||||
entries, err := loadOverlays(fsys, []string{"specs"})
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(entries) != 2 || entries[0].spec.Version != 1 || entries[1].spec.Version != 2 {
|
||||
t.Fatalf("file order not applied: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOverlaysUnknownField(t *testing.T) {
|
||||
fsys := mapFS(map[string]string{
|
||||
"specs/bad.json": `{"intents": [{"id": "demo.x.one", "domain": "demo", "version": 1, "matcher": {"includes_any": ["a"]}, "unknown_key": 1}]}`,
|
||||
})
|
||||
_, err := loadOverlays(fsys, []string{"specs"})
|
||||
var e *Error
|
||||
if !errors.As(err, &e) || e.Code != InvalidSpec {
|
||||
t.Fatalf("expected intent.invalid_spec, got %v", err)
|
||||
}
|
||||
if e.Fields["file"] != "specs/bad.json" {
|
||||
t.Fatalf("error missing file source: %v", e.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOverlaysBadJSON(t *testing.T) {
|
||||
fsys := mapFS(map[string]string{"specs/broken.json": `{"intents": [}`})
|
||||
_, err := loadOverlays(fsys, []string{"specs"})
|
||||
var e *Error
|
||||
if !errors.As(err, &e) || e.Code != InvalidSpec {
|
||||
t.Fatalf("expected intent.invalid_spec for broken json, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOverlaysMissingRoot(t *testing.T) {
|
||||
fsys := mapFS(map[string]string{})
|
||||
_, err := loadOverlays(fsys, []string{"specs"})
|
||||
var e *Error
|
||||
if !errors.As(err, &e) || e.Code != LoadFailed {
|
||||
t.Fatalf("expected intent.load_failed, got %v", err)
|
||||
}
|
||||
if e.Fields["root"] != "specs" {
|
||||
t.Fatalf("error missing root: %v", e.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOverlaysSingleFileRoot(t *testing.T) {
|
||||
fsys := mapFS(map[string]string{"single.json": overlayAddJSON})
|
||||
entries, err := loadOverlays(fsys, []string{"single.json"})
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].source != "single.json#intents[0]" {
|
||||
t.Fatalf("single file root mismatch: %+v", entries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
// TestConcurrentRecognizeAndReload 验证快照原子替换:识别与 Reload 并发时,
|
||||
// 每次 Recognize 都拿到完整一致的快照(-race 下运行)。
|
||||
func TestConcurrentRecognizeAndReload(t *testing.T) {
|
||||
fsys := fstest.MapFS{"specs/a.json": &fstest.MapFile{Data: []byte(
|
||||
`{"intents": [
|
||||
{"id": "demo.run.status_query", "domain": "demo", "version": 1, "matcher": {"includes_any": ["进度"]}}
|
||||
]}`)}}
|
||||
b := newTestBuilder()
|
||||
if err := b.Register(demoGuardSpec()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.BuildFS(context.Background(), fsys, "specs")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := NewRecognizer(Options{}, nil, nil)
|
||||
inputs := []string{"查询任务进度", "你好", "帮我改网关", ""}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func(seed int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 200; j++ {
|
||||
in := inputs[(seed+j)%len(inputs)]
|
||||
if _, err := rec.Recognize(context.Background(), in, reg.Snapshot()); err != nil {
|
||||
t.Errorf("recognize: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 50; j++ {
|
||||
if _, err := reg.Reload(context.Background()); err != nil {
|
||||
t.Errorf("reload: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
if got := reg.Snapshot().Revision(); got < 51 {
|
||||
t.Fatalf("expected revision >= 51, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"logging-system/logsys"
|
||||
)
|
||||
|
||||
// Recognizer 是识别的公开端口(Query 语义:纯函数式、无副作用)。
|
||||
// error 只表示系统级失败(nil 快照、输入超长);BranchFallback 是正常结果。
|
||||
type Recognizer interface {
|
||||
Recognize(ctx context.Context, input string, snap *Snapshot) (Result, error)
|
||||
}
|
||||
|
||||
// FunnelRecognizer 四级漏斗实现:guard → expectation → rules → adjudicate,
|
||||
// 可选注入挂起预期提供者与 Ambiguous 分支的 L2 分类器。
|
||||
type FunnelRecognizer struct {
|
||||
log *logsys.Logger
|
||||
exp ExpectationProvider
|
||||
cls Classifier
|
||||
}
|
||||
|
||||
// NewRecognizer 构建漏斗识别器。opts 仅使用 Log 字段(阈值类参数在快照内);
|
||||
// exp / cls 可为 nil(对应阶段不启用)。
|
||||
func NewRecognizer(opts Options, exp ExpectationProvider, cls Classifier) *FunnelRecognizer {
|
||||
o := opts.normalized()
|
||||
return &FunnelRecognizer{log: o.Log, exp: exp, cls: cls}
|
||||
}
|
||||
|
||||
// Recognize 识别一次输入。确定性:同输入 + 同快照 → 同结果(日志中的耗时除外)。
|
||||
func (r *FunnelRecognizer) Recognize(ctx context.Context, input string, snap *Snapshot) (Result, error) {
|
||||
if snap == nil {
|
||||
err := newError(InvalidArgument, "snapshot 为 nil")
|
||||
r.log.Module("intent").WithContext(ctx).Error("intent recognize rejected", err)
|
||||
return Result{}, err
|
||||
}
|
||||
if len(input) > snap.maxInput {
|
||||
err := newError(InvalidArgument, "输入超过长度上限", "actual_len", len(input), "max_len", snap.maxInput)
|
||||
r.log.Module("intent").WithContext(ctx).Error("intent recognize rejected", err)
|
||||
return Result{}, err
|
||||
}
|
||||
start := time.Now()
|
||||
res := r.recognize(ctx, input, snap)
|
||||
topID, conf := "", 0.0
|
||||
if res.Top != nil {
|
||||
topID, conf = res.Top.ID, res.Top.Confidence
|
||||
}
|
||||
r.log.Module("intent").WithContext(ctx).Info("intent recognized",
|
||||
"branch", string(res.Branch),
|
||||
"top_id", topID,
|
||||
"confidence", conf,
|
||||
"snapshot_revision", res.SnapshotRevision,
|
||||
"duration_ms", time.Since(start).Milliseconds())
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Snapshot) Result {
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return Result{Input: input, Branch: BranchFallback, SnapshotRevision: snap.rev}
|
||||
}
|
||||
folded := strings.ToLower(input)
|
||||
|
||||
// Stage 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}
|
||||
}
|
||||
|
||||
// Stage 1:会话挂起预期优先于一般规则(宿主可选注入)。
|
||||
if r.exp != nil {
|
||||
if pending := r.exp.Pending(ctx); pending != nil && len(pending.IntentIDs) > 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}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 2 + 3:规则计分与置信度裁决。
|
||||
hits := snap.evalRules(input, folded)
|
||||
switch {
|
||||
case len(hits) == 0:
|
||||
return Result{Input: input, Branch: BranchFallback, SnapshotRevision: snap.rev}
|
||||
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:可选 L2 分类器重排;失败保持 Ambiguous,不影响识别确定性承诺的失败语义。
|
||||
cands := candidatesOf(trimHits(hits, snap.topN), input, false)
|
||||
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}
|
||||
}
|
||||
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: cands, SnapshotRevision: snap.rev}
|
||||
}
|
||||
cands = refined
|
||||
}
|
||||
return Result{Input: input, Branch: BranchAmbiguous, Candidates: cands, SnapshotRevision: snap.rev}
|
||||
}
|
||||
|
||||
// adjudicateRefined 对分类器结果重排后应用同一裁决规则;分差仍贴近则返回 false。
|
||||
func adjudicateRefined(refined []Candidate, snap *Snapshot) (Candidate, bool) {
|
||||
if len(refined) == 0 {
|
||||
return Candidate{}, false
|
||||
}
|
||||
sorted := append([]Candidate(nil), refined...)
|
||||
sortCandidates(sorted)
|
||||
if len(sorted) > 1 && sorted[0].Confidence-sorted[1].Confidence < snap.margin {
|
||||
return Candidate{}, false
|
||||
}
|
||||
return sorted[0], true
|
||||
}
|
||||
|
||||
func sortCandidates(cands []Candidate) {
|
||||
sort.SliceStable(cands, func(i, j int) bool {
|
||||
a, b := cands[i], cands[j]
|
||||
if a.Confidence != b.Confidence {
|
||||
return a.Confidence > b.Confidence
|
||||
}
|
||||
if a.Priority != b.Priority {
|
||||
return a.Priority > b.Priority
|
||||
}
|
||||
return a.ID < b.ID
|
||||
})
|
||||
}
|
||||
|
||||
func trimHits(hits []specHit, n int) []specHit {
|
||||
if len(hits) > n {
|
||||
return hits[:n]
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// Explain 输出单条输入的裁决轨迹(guard 与 rules 全量、确定性、无识别器状态)。
|
||||
// 这是配置规范要求的只读诊断;intentctl explain 在此之上构建。
|
||||
func (s *Snapshot) Explain(input string) Trace {
|
||||
trace := Trace{InputLen: len(input), AmbiguityMargin: s.margin}
|
||||
if strings.TrimSpace(input) == "" {
|
||||
trace.Branch = BranchFallback
|
||||
return trace
|
||||
}
|
||||
folded := strings.ToLower(input)
|
||||
|
||||
for _, idx := range s.guardIdx {
|
||||
cs := s.specs[idx]
|
||||
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 {
|
||||
trace.GuardRejected = append(trace.GuardRejected, RejectedSpec{ID: cs.spec.ID, Reason: o.reject})
|
||||
}
|
||||
}
|
||||
sortScored(trace.Guard)
|
||||
sortRejected(trace.GuardRejected)
|
||||
if len(trace.Guard) > 0 {
|
||||
trace.Branch = BranchGuardHit
|
||||
trace.TopID = trace.Guard[0].ID
|
||||
return trace
|
||||
}
|
||||
|
||||
var hits []specHit
|
||||
for _, cs := range s.specs {
|
||||
if cs.guard {
|
||||
continue
|
||||
}
|
||||
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})
|
||||
} else {
|
||||
trace.RulesRejected = append(trace.RulesRejected, RejectedSpec{ID: cs.spec.ID, Reason: o.reject})
|
||||
}
|
||||
}
|
||||
sortHits(hits)
|
||||
sortScored(trace.Rules)
|
||||
sortRejected(trace.RulesRejected)
|
||||
|
||||
switch {
|
||||
case len(hits) == 0:
|
||||
trace.Branch = BranchFallback
|
||||
case len(hits) == 1 || hits[0].score-hits[1].score >= s.margin:
|
||||
trace.Branch = BranchMatched
|
||||
trace.TopID = hits[0].cs.spec.ID
|
||||
default:
|
||||
trace.Branch = BranchAmbiguous
|
||||
}
|
||||
return trace
|
||||
}
|
||||
|
||||
func sortScored(ss []ScoredSpec) {
|
||||
sort.SliceStable(ss, func(i, j int) bool { return ss[i].ID < ss[j].ID })
|
||||
}
|
||||
|
||||
func sortRejected(rs []RejectedSpec) {
|
||||
sort.SliceStable(rs, func(i, j int) bool { return rs[i].ID < rs[j].ID })
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// buildDemoRegistry 按消费方典型形态组装:守卫内容 Go 内置(受保护域),
|
||||
// 业务意图来自 JSON 配置目录。
|
||||
func buildDemoRegistry(t *testing.T) (*Registry, *Snapshot) {
|
||||
t.Helper()
|
||||
b := newTestBuilder()
|
||||
if err := b.Register(demoGuardSpec()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.BuildFS(context.Background(), os.DirFS("testdata"), "specs")
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
return reg, reg.Snapshot()
|
||||
}
|
||||
|
||||
func TestRecognizeGuardHit(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
rec := NewRecognizer(Options{}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "帮我改一下系统网关地址", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchGuardHit || res.Top == nil || res.Top.ID != "guard.config_change" {
|
||||
t.Fatalf("guard result: %+v", res)
|
||||
}
|
||||
if res.Top.Risk != RiskHigh || res.Top.Handler != "refuse.and_guide" {
|
||||
t.Fatalf("guard candidate: %+v", res.Top)
|
||||
}
|
||||
if len(res.Candidates) == 0 || res.Candidates[0].ID != "guard.config_change" {
|
||||
t.Fatalf("guard candidates: %+v", res.Candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeMatchedWithSlot(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
rec := NewRecognizer(Options{}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "查询任务 42 的进度", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.run.status_query" {
|
||||
t.Fatalf("matched result: %+v", res)
|
||||
}
|
||||
if res.Top.Slots["run_id"] != "42" {
|
||||
t.Fatalf("slots: %+v", res.Top.Slots)
|
||||
}
|
||||
if res.Top.Handler != "taskrun.query_status" || res.Top.Risk != RiskLow {
|
||||
t.Fatalf("candidate metadata: %+v", res.Top)
|
||||
}
|
||||
if res.Top.Reason == "" {
|
||||
t.Fatal("reason empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeExcludedTermFallsBack(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
rec := NewRecognizer(Options{}, nil, nil)
|
||||
// 硬负例:否定写 + 只读式提问不得误触(含"配置"排除词,也不是守卫词)。
|
||||
res, err := rec.Recognize(context.Background(), "怎么配置查询进度", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchFallback || res.Top != nil {
|
||||
t.Fatalf("expected fallback, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizePatternBonus(t *testing.T) {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(IntentSpec{
|
||||
ID: "demo.deploy.check",
|
||||
Domain: "demo",
|
||||
Version: 1,
|
||||
Matcher: MatchSpec{
|
||||
IncludesAny: []string{"部署"},
|
||||
Patterns: []string{"(?:执行|跑).{0,4}部署"},
|
||||
},
|
||||
Confidence: ConfidenceSpec{Base: 0.7, PatternBonus: 0.2, Threshold: 0.75},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := NewRecognizer(Options{}, nil, nil)
|
||||
|
||||
// 仅词条命中:base 0.7 < threshold 0.75,不过阈。
|
||||
res, _ := rec.Recognize(context.Background(), "部署", reg.Snapshot())
|
||||
if res.Branch != BranchFallback {
|
||||
t.Fatalf("term-only should stay below threshold: %+v", res)
|
||||
}
|
||||
// 词条 + 模式命中:0.7 + 0.2 = 0.9 过阈。
|
||||
res, _ = rec.Recognize(context.Background(), "帮我执行一下部署", reg.Snapshot())
|
||||
if res.Branch != BranchMatched || res.Top.ID != "demo.deploy.check" {
|
||||
t.Fatalf("pattern bonus should pass threshold: %+v", res)
|
||||
}
|
||||
if res.Top.Confidence != 0.9 {
|
||||
t.Fatalf("confidence: %v", res.Top.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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) 前。
|
||||
if res.Candidates[0].ID != "demo.greet.hello" || res.Candidates[1].ID != "demo.greet.hi" {
|
||||
t.Fatalf("priority order: %+v", res.Candidates)
|
||||
}
|
||||
}
|
||||
|
||||
type stubClassifier struct {
|
||||
scores map[string]float64
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubClassifier) Refine(ctx context.Context, input string, cands []Candidate) ([]Candidate, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
out := append([]Candidate(nil), cands...)
|
||||
for i := range out {
|
||||
if v, ok := s.scores[out[i].ID]; ok {
|
||||
out[i].Confidence = v
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestRecognizeClassifierResolvesAmbiguity(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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") {
|
||||
t.Fatalf("reason missing refine marker: %q", res.Top.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("classifier failure must not fail recognition: %v", err)
|
||||
}
|
||||
if res.Branch != BranchAmbiguous || res.Top != nil {
|
||||
t.Fatalf("expected ambiguous kept: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
type stubExpectation struct {
|
||||
pending *PendingExpectation
|
||||
}
|
||||
|
||||
func (s stubExpectation) Pending(ctx context.Context) *PendingExpectation { return s.pending }
|
||||
|
||||
func TestRecognizeExpectationHit(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
|
||||
// 无挂起预期时该输入落在规则歧义(0.8 vs 0.75,差 0.05 < margin 0.1)。
|
||||
plain := NewRecognizer(Options{}, nil, nil)
|
||||
res, _ := plain.Recognize(context.Background(), "任务重启 进度", snap)
|
||||
if res.Branch != BranchAmbiguous {
|
||||
t.Fatalf("expected ambiguous without expectation: %+v", res)
|
||||
}
|
||||
|
||||
// 宿主声明挂起预期后,同输入确定命中预期意图。
|
||||
exp := stubExpectation{pending: &PendingExpectation{IntentIDs: []string{"demo.run.restart"}, Reason: "确认重启"}}
|
||||
rec := NewRecognizer(Options{}, exp, nil)
|
||||
res, err := rec.Recognize(context.Background(), "任务重启 进度", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchExpectationHit || res.Top == nil || res.Top.ID != "demo.run.restart" {
|
||||
t.Fatalf("expectation result: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeRequiredSlotMissing(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
rec := NewRecognizer(Options{}, 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.run.cancel" {
|
||||
t.Fatalf("matched: %+v", res)
|
||||
}
|
||||
if len(res.Top.MissingRequiredSlots) != 1 || res.Top.MissingRequiredSlots[0] != "run_id" {
|
||||
t.Fatalf("missing slots: %+v", res.Top.MissingRequiredSlots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeCaseFolding(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
rec := NewRecognizer(Options{}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "please DEPLOY it", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchMatched || res.Top.ID != "demo.misc.deploy" {
|
||||
t.Fatalf("case fold: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeEmptyInputFallsBack(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
rec := NewRecognizer(Options{}, nil, nil)
|
||||
for _, in := range []string{"", " ", "\t\n"} {
|
||||
res, err := rec.Recognize(context.Background(), in, snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchFallback || res.Top != nil {
|
||||
t.Fatalf("input %q: %+v", in, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeNilSnapshot(t *testing.T) {
|
||||
rec := NewRecognizer(Options{}, nil, nil)
|
||||
_, err := rec.Recognize(context.Background(), "x", nil)
|
||||
var e *Error
|
||||
if !errors.As(err, &e) || e.Code != InvalidArgument {
|
||||
t.Fatalf("expected sys.invalid_argument, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeOverlongInput(t *testing.T) {
|
||||
// 输入上限随快照构建(阈值类参数在快照内),识别器不单独限制。
|
||||
b := NewRegistryBuilder(Options{MaxInputLen: 16})
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := NewRecognizer(Options{}, nil, nil)
|
||||
_, err = rec.Recognize(context.Background(), "这一段输入超过了十六字节的长度上限用于验证", reg.Snapshot())
|
||||
var e *Error
|
||||
if !errors.As(err, &e) || e.Code != InvalidArgument {
|
||||
t.Fatalf("expected sys.invalid_argument, got %v", err)
|
||||
}
|
||||
if e.Fields["max_len"] != 16 {
|
||||
t.Fatalf("fields: %v", e.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeDeterministic(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
rec := NewRecognizer(Options{}, nil, nil)
|
||||
for _, in := range []string{"查询任务 42 的进度", "你好呀", "怎么配置查询进度", "帮我改一下系统网关地址"} {
|
||||
first, err := rec.Recognize(context.Background(), in, snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := rec.Recognize(context.Background(), in, snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(first, second) {
|
||||
t.Fatalf("nondeterministic result for %q", in)
|
||||
}
|
||||
trace1, trace2 := snap.Explain(in), snap.Explain(in)
|
||||
if !reflect.DeepEqual(trace1, trace2) {
|
||||
t.Fatalf("nondeterministic explain for %q", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplainTrace(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
|
||||
trace := snap.Explain("怎么配置查询进度")
|
||||
if trace.Branch != BranchFallback {
|
||||
t.Fatalf("branch: %+v", trace)
|
||||
}
|
||||
found := false
|
||||
for _, r := range trace.RulesRejected {
|
||||
if r.ID == "demo.run.status_query" && len(r.Reason) > len("excluded_term:") && r.Reason[:len("excluded_term:")] == "excluded_term:" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("excluded rejection missing: %+v", trace.RulesRejected)
|
||||
}
|
||||
|
||||
trace = snap.Explain("帮我改一下系统网关地址")
|
||||
if trace.Branch != BranchGuardHit || trace.TopID != "guard.config_change" || len(trace.Guard) != 1 {
|
||||
t.Fatalf("guard trace: %+v", trace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleSegmenter(t *testing.T) {
|
||||
seg := NewRuleSegmenter()
|
||||
got := seg.Segment("查询任务进度,然后把结果发给我。谢谢!")
|
||||
want := []string{"查询任务进度", "然后把结果发给我", "谢谢"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("segment: %v", got)
|
||||
}
|
||||
if got := seg.Segment(" "); len(got) != 1 || got[0] != " " {
|
||||
t.Fatalf("blank input should return as-is: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"logging-system/logsys"
|
||||
)
|
||||
|
||||
// Options 注册表与识别器的共享选项。零值字段在构建时填入默认值;
|
||||
// GuardDomains / ProtectedDomains 决定快照的阶段归属与 overlay 保护范围。
|
||||
type Options struct {
|
||||
// ProtectedDomains 保护域:overlay 覆盖或禁用这些域内的 spec 会整批失败(fail-closed)。
|
||||
ProtectedDomains []string
|
||||
// GuardDomains 守卫域:这些域内的 spec 在 Stage 0 演练,命中即短路返回 GuardHit。
|
||||
GuardDomains []string
|
||||
// TopN 结果候选数上限(默认 5)。
|
||||
TopN int
|
||||
// AmbiguityMargin 前两名分差小于该值判 Ambiguous(默认 0.1)。
|
||||
AmbiguityMargin float64
|
||||
// MaxInputLen 输入字节长度上限(默认 4096),超过返回 InvalidArgument。
|
||||
MaxInputLen int
|
||||
// MaxSpecs 快照内 spec 总数上限,含禁用(默认 2048)。
|
||||
MaxSpecs int
|
||||
// MaxPatternsPerSpec 单 spec 正则数量上限(默认 32)。
|
||||
MaxPatternsPerSpec int
|
||||
// MaxSlotsPerSpec 单 spec 槽位数量上限(默认 16)。
|
||||
MaxSlotsPerSpec int
|
||||
// Log 可选日志器;nil 时使用 Nop。识别日志不记录原始输入。
|
||||
Log *logsys.Logger
|
||||
}
|
||||
|
||||
// Options 字段默认值。
|
||||
const (
|
||||
DefaultTopN = 5
|
||||
DefaultAmbiguityMargin = 0.1
|
||||
DefaultMaxInputLen = 4096
|
||||
DefaultMaxSpecs = 2048
|
||||
DefaultMaxPatterns = 32
|
||||
DefaultMaxSlots = 16
|
||||
)
|
||||
|
||||
func (o Options) normalized() Options {
|
||||
n := o
|
||||
if n.TopN <= 0 {
|
||||
n.TopN = DefaultTopN
|
||||
}
|
||||
if n.AmbiguityMargin <= 0 {
|
||||
n.AmbiguityMargin = DefaultAmbiguityMargin
|
||||
}
|
||||
if n.MaxInputLen <= 0 {
|
||||
n.MaxInputLen = DefaultMaxInputLen
|
||||
}
|
||||
if n.MaxSpecs <= 0 {
|
||||
n.MaxSpecs = DefaultMaxSpecs
|
||||
}
|
||||
if n.MaxPatternsPerSpec <= 0 {
|
||||
n.MaxPatternsPerSpec = DefaultMaxPatterns
|
||||
}
|
||||
if n.MaxSlotsPerSpec <= 0 {
|
||||
n.MaxSlotsPerSpec = DefaultMaxSlots
|
||||
}
|
||||
if n.Log == nil {
|
||||
n.Log = logsys.Nop()
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (o Options) protectedSet() map[string]bool {
|
||||
return domainSet(o.ProtectedDomains)
|
||||
}
|
||||
|
||||
func (o Options) guardSet() map[string]bool {
|
||||
return domainSet(o.GuardDomains)
|
||||
}
|
||||
|
||||
func domainSet(domains []string) map[string]bool {
|
||||
m := make(map[string]bool, len(domains))
|
||||
for _, d := range domains {
|
||||
m[d] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (o Options) limits() limits {
|
||||
return limits{maxSpecs: o.MaxSpecs, maxPatternsPerSpec: o.MaxPatternsPerSpec, maxSlotsPerSpec: o.MaxSlotsPerSpec}
|
||||
}
|
||||
|
||||
// ---- 编译产物 ----
|
||||
|
||||
type compiledSlot struct {
|
||||
key string
|
||||
required bool
|
||||
re *regexp.Regexp
|
||||
group int
|
||||
}
|
||||
|
||||
type compiledSpec struct {
|
||||
spec IntentSpec
|
||||
termsAny []string // 已按 case_sensitive 折叠
|
||||
termsAll []string
|
||||
termsEx []string
|
||||
patterns []*regexp.Regexp
|
||||
slots []compiledSlot
|
||||
guard bool
|
||||
index int // 合并序,确定性次序裁决用
|
||||
base float64
|
||||
bonus float64
|
||||
threshold float64
|
||||
}
|
||||
|
||||
// matchOutcome 单个 spec 对一次输入的评估结果。
|
||||
type matchOutcome struct {
|
||||
score float64
|
||||
passed bool
|
||||
hitTerms []string
|
||||
hitPat []string
|
||||
reject string // 非 passed 时的排除原因
|
||||
}
|
||||
|
||||
// evaluate 按文档化的计分规则评估。input 为原文,folded 为小写折叠后文本。
|
||||
func (cs *compiledSpec) evaluate(input, folded string) matchOutcome {
|
||||
hay := input
|
||||
if !cs.spec.Matcher.CaseSensitive {
|
||||
hay = folded
|
||||
}
|
||||
for _, t := range cs.termsEx {
|
||||
if strings.Contains(hay, t) {
|
||||
return matchOutcome{reject: "excluded_term:" + t}
|
||||
}
|
||||
}
|
||||
for _, t := range cs.termsAll {
|
||||
if !strings.Contains(hay, t) {
|
||||
return matchOutcome{reject: "missing_includes_all:" + t}
|
||||
}
|
||||
}
|
||||
var hitTerms, hitPat []string
|
||||
positive := false
|
||||
for _, t := range cs.termsAny {
|
||||
if strings.Contains(hay, t) {
|
||||
positive = true
|
||||
hitTerms = append(hitTerms, t)
|
||||
}
|
||||
}
|
||||
for i, p := range cs.patterns {
|
||||
if p.MatchString(input) {
|
||||
positive = true
|
||||
hitPat = append(hitPat, cs.spec.Matcher.Patterns[i])
|
||||
}
|
||||
}
|
||||
if !positive && (len(cs.termsAny) > 0 || len(cs.patterns) > 0) {
|
||||
return matchOutcome{reject: "no_positive_match"}
|
||||
}
|
||||
score := cs.base
|
||||
if len(hitPat) > 0 {
|
||||
score += cs.bonus
|
||||
}
|
||||
if score > 1 {
|
||||
score = 1
|
||||
}
|
||||
// 固定精度舍入:避免浮点噪声进入结果与 margin 比较。
|
||||
return matchOutcome{score: math.Round(score*10000) / 10000, passed: score >= cs.threshold, hitTerms: hitTerms, hitPat: hitPat}
|
||||
}
|
||||
|
||||
// extractSlots 从原文抽取槽位;返回缺失的必填槽位键。
|
||||
func (cs *compiledSpec) extractSlots(input string) (map[string]string, []string) {
|
||||
if len(cs.slots) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
slots := make(map[string]string, len(cs.slots))
|
||||
var missing []string
|
||||
for _, s := range cs.slots {
|
||||
m := s.re.FindStringSubmatch(input)
|
||||
if m != nil && s.group < len(m) && m[s.group] != "" {
|
||||
slots[s.key] = m[s.group]
|
||||
continue
|
||||
}
|
||||
if s.required {
|
||||
missing = append(missing, s.key)
|
||||
}
|
||||
}
|
||||
return slots, missing
|
||||
}
|
||||
|
||||
func compileSpec(spec IntentSpec, guard bool, index int) (*compiledSpec, error) {
|
||||
m := spec.Matcher
|
||||
fold := func(terms []string) []string {
|
||||
out := make([]string, len(terms))
|
||||
for i, t := range terms {
|
||||
if m.CaseSensitive {
|
||||
out[i] = t
|
||||
} else {
|
||||
out[i] = strings.ToLower(t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
cs := &compiledSpec{
|
||||
spec: spec,
|
||||
termsAny: fold(m.IncludesAny),
|
||||
termsAll: fold(m.IncludesAll),
|
||||
termsEx: fold(m.ExcludesAny),
|
||||
patterns: make([]*regexp.Regexp, len(m.Patterns)),
|
||||
guard: guard,
|
||||
index: index,
|
||||
base: spec.Confidence.Base,
|
||||
bonus: spec.Confidence.PatternBonus,
|
||||
threshold: spec.Confidence.Threshold,
|
||||
}
|
||||
if cs.base == 0 {
|
||||
cs.base = DefaultBase
|
||||
}
|
||||
if cs.bonus == 0 {
|
||||
cs.bonus = DefaultPatternBonus
|
||||
}
|
||||
if cs.threshold == 0 {
|
||||
cs.threshold = DefaultThreshold
|
||||
}
|
||||
for i, p := range m.Patterns {
|
||||
re, err := regexp.Compile(p)
|
||||
if err != nil {
|
||||
return nil, newError(InvalidSpec, "pattern 编译失败", "field", fmt.Sprintf("matcher.patterns[%d]", i), "id", spec.ID, "error", err.Error())
|
||||
}
|
||||
cs.patterns[i] = re
|
||||
}
|
||||
for _, s := range spec.Slots {
|
||||
re, err := regexp.Compile(s.Pattern)
|
||||
if err != nil {
|
||||
return nil, newError(InvalidSpec, "slot pattern 编译失败", "field", "slots", "key", s.Key, "id", spec.ID, "error", err.Error())
|
||||
}
|
||||
cs.slots = append(cs.slots, compiledSlot{key: s.Key, required: s.Required, re: re, group: effectiveGroup(s.Group)})
|
||||
}
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
// ---- 快照 ----
|
||||
|
||||
// Snapshot 是合并产物的不可变快照:全部 matcher 已编译,识别全程无锁读。
|
||||
// 同一快照对同样输入给出同样结果;revision 单调递增。
|
||||
type Snapshot struct {
|
||||
rev int64
|
||||
margin float64
|
||||
topN int
|
||||
maxInput int
|
||||
specs []*compiledSpec // 仅启用 spec,按合并序
|
||||
byID map[string]int
|
||||
guardIdx []int
|
||||
disabled []string // 已禁用 id(overlay disable 与 spec.disabled 两个入口)
|
||||
sources map[string]string
|
||||
total int // 含禁用
|
||||
}
|
||||
|
||||
// Revision 返回快照版本号,随每次成功 Reload 递增。
|
||||
func (s *Snapshot) Revision() int64 { return s.rev }
|
||||
|
||||
// SpecIDs 返回启用中的 spec id,按合并序(确定性次序裁决的注册序)。
|
||||
func (s *Snapshot) SpecIDs() []string {
|
||||
ids := make([]string, len(s.specs))
|
||||
for i, cs := range s.specs {
|
||||
ids[i] = cs.spec.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// DisabledIDs 返回被禁用的 spec id,字典序。
|
||||
func (s *Snapshot) DisabledIDs() []string {
|
||||
out := make([]string, len(s.disabled))
|
||||
copy(out, s.disabled)
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Source 返回 spec 的来源描述("builtin" 或 "<file>#intents[i]")。
|
||||
func (s *Snapshot) Source(id string) (string, bool) {
|
||||
src, ok := s.sources[id]
|
||||
return src, ok
|
||||
}
|
||||
|
||||
// SpecByID 返回指定 id 的启用 spec 定义副本。不存在或已禁用时第二个返回值为 false。
|
||||
func (s *Snapshot) SpecByID(id string) (IntentSpec, bool) {
|
||||
if idx, ok := s.byID[id]; ok {
|
||||
return s.specs[idx].spec, true
|
||||
}
|
||||
return IntentSpec{}, false
|
||||
}
|
||||
|
||||
// Conflict 诊断信息:两个启用 spec 共享同一 includes_any 词条。
|
||||
// 共享不一定是错误(计分与确定性次序会裁决),用于配置评审提示。
|
||||
type Conflict struct {
|
||||
Kind string
|
||||
A string
|
||||
B string
|
||||
Term string
|
||||
}
|
||||
|
||||
// Conflicts 报告非守卫 spec 之间共享的 includes_any 词条,确定性排序。
|
||||
func (s *Snapshot) Conflicts() []Conflict {
|
||||
termOwners := make(map[string][]string)
|
||||
for _, cs := range s.specs {
|
||||
if cs.guard {
|
||||
continue
|
||||
}
|
||||
for _, t := range cs.spec.Matcher.IncludesAny {
|
||||
termOwners[t] = append(termOwners[t], cs.spec.ID)
|
||||
}
|
||||
}
|
||||
terms := make([]string, 0, len(termOwners))
|
||||
for t, owners := range termOwners {
|
||||
if len(owners) > 1 {
|
||||
terms = append(terms, t)
|
||||
}
|
||||
}
|
||||
sort.Strings(terms)
|
||||
var out []Conflict
|
||||
for _, t := range terms {
|
||||
owners := append([]string(nil), termOwners[t]...)
|
||||
sort.Strings(owners)
|
||||
for i := 0; i < len(owners); i++ {
|
||||
for j := i + 1; j < len(owners); j++ {
|
||||
out = append(out, Conflict{Kind: "shared_term", A: owners[i], B: owners[j], Term: t})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// specHit 规则阶段的内部命中记录。
|
||||
type specHit struct {
|
||||
cs *compiledSpec
|
||||
score float64
|
||||
terms []string
|
||||
pats []string
|
||||
}
|
||||
|
||||
func lessHit(a, b specHit) bool {
|
||||
if a.score != b.score {
|
||||
return a.score > b.score
|
||||
}
|
||||
if a.cs.spec.Priority != b.cs.spec.Priority {
|
||||
return a.cs.spec.Priority > b.cs.spec.Priority
|
||||
}
|
||||
return a.cs.index < b.cs.index
|
||||
}
|
||||
|
||||
func sortHits(hits []specHit) {
|
||||
sort.SliceStable(hits, func(i, j int) bool { return lessHit(hits[i], hits[j]) })
|
||||
}
|
||||
|
||||
// evalGuard 评估守卫域 spec,返回过阈命中(已排序)。
|
||||
func (s *Snapshot) evalGuard(input, folded string) []specHit {
|
||||
var hits []specHit
|
||||
for _, idx := range s.guardIdx {
|
||||
cs := s.specs[idx]
|
||||
if o := cs.evaluate(input, folded); o.passed {
|
||||
hits = append(hits, specHit{cs: cs, score: o.score, terms: o.hitTerms, pats: o.hitPat})
|
||||
}
|
||||
}
|
||||
sortHits(hits)
|
||||
return hits
|
||||
}
|
||||
|
||||
// evalIDs 按给定 id 列表评估(挂起预期阶段用),返回过阈命中(已排序)。
|
||||
func (s *Snapshot) evalIDs(ids []string, input, folded string) []specHit {
|
||||
var hits []specHit
|
||||
for _, id := range ids {
|
||||
idx, ok := s.byID[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cs := s.specs[idx]
|
||||
if o := cs.evaluate(input, folded); o.passed {
|
||||
hits = append(hits, specHit{cs: cs, score: o.score, terms: o.hitTerms, pats: o.hitPat})
|
||||
}
|
||||
}
|
||||
sortHits(hits)
|
||||
return hits
|
||||
}
|
||||
|
||||
// evalRules 评估全部非守卫 spec,返回过阈命中(已排序)。
|
||||
func (s *Snapshot) evalRules(input, folded string) []specHit {
|
||||
var hits []specHit
|
||||
for _, cs := range s.specs {
|
||||
if cs.guard {
|
||||
continue
|
||||
}
|
||||
if o := cs.evaluate(input, folded); o.passed {
|
||||
hits = append(hits, specHit{cs: cs, score: o.score, terms: o.hitTerms, pats: o.hitPat})
|
||||
}
|
||||
}
|
||||
sortHits(hits)
|
||||
return hits
|
||||
}
|
||||
|
||||
func (h specHit) reason() string {
|
||||
var parts []string
|
||||
if len(h.terms) > 0 {
|
||||
parts = append(parts, "terms="+strings.Join(h.terms, ","))
|
||||
}
|
||||
if len(h.pats) > 0 {
|
||||
parts = append(parts, "patterns="+strings.Join(h.pats, ","))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func (h specHit) candidate(input string, withSlots bool) Candidate {
|
||||
cs := h.cs
|
||||
c := Candidate{
|
||||
ID: cs.spec.ID,
|
||||
Domain: cs.spec.Domain,
|
||||
Version: cs.spec.Version,
|
||||
Confidence: h.score,
|
||||
Priority: cs.spec.Priority,
|
||||
Risk: cs.spec.Risk,
|
||||
RequiresConfirm: cs.spec.RequiresConfirm,
|
||||
Handler: cs.spec.Handler,
|
||||
Reason: h.reason(),
|
||||
}
|
||||
if c.Risk == "" {
|
||||
c.Risk = RiskLow
|
||||
}
|
||||
if len(cs.spec.Metadata) > 0 {
|
||||
c.Metadata = make(map[string]string, len(cs.spec.Metadata))
|
||||
for k, v := range cs.spec.Metadata {
|
||||
c.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
if withSlots {
|
||||
c.Slots, c.MissingRequiredSlots = cs.extractSlots(input)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func candidatesOf(hits []specHit, input string, topSlots bool) []Candidate {
|
||||
out := make([]Candidate, 0, len(hits))
|
||||
for i, h := range hits {
|
||||
out = append(out, h.candidate(input, topSlots && i == 0))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- 构建与合并 ----
|
||||
|
||||
// RegistryBuilder 收集 Go 内置基线 spec 并构建 Registry。
|
||||
type RegistryBuilder struct {
|
||||
opts Options
|
||||
builtin []IntentSpec
|
||||
seen map[string]bool
|
||||
}
|
||||
|
||||
// NewRegistryBuilder 创建构建器。opts 零值字段填默认。
|
||||
func NewRegistryBuilder(opts Options) *RegistryBuilder {
|
||||
return &RegistryBuilder{opts: opts.normalized(), seen: make(map[string]bool)}
|
||||
}
|
||||
|
||||
// Register 登记内置基线 spec,立即校验;重复 id 报 InvalidSpec。
|
||||
func (b *RegistryBuilder) Register(specs ...IntentSpec) error {
|
||||
lim := b.opts.limits()
|
||||
for _, spec := range specs {
|
||||
if b.seen[spec.ID] {
|
||||
return newError(InvalidSpec, "内置 spec id 重复", "id", spec.ID)
|
||||
}
|
||||
if err := validateSpec(spec, lim); err != nil {
|
||||
return err
|
||||
}
|
||||
b.seen[spec.ID] = true
|
||||
b.builtin = append(b.builtin, spec)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build 仅用内置基线构建(rev 1)。此后 Reload 会报错(无配置来源)。
|
||||
func (b *RegistryBuilder) Build() (*Registry, error) {
|
||||
return b.buildWithSource(nil, nil)
|
||||
}
|
||||
|
||||
// BuildFS 用内置基线 + JSON overlay 来源构建,并记住来源供 Reload 重读。
|
||||
// roots 按给定顺序应用;目录 root 内按文件名字典序加载 *.json。
|
||||
func (b *RegistryBuilder) BuildFS(ctx context.Context, fsys fs.FS, roots ...string) (*Registry, error) {
|
||||
if fsys == nil {
|
||||
return nil, newError(InvalidArgument, "fsys 为 nil")
|
||||
}
|
||||
if len(roots) == 0 {
|
||||
return nil, newError(InvalidArgument, "至少提供一个配置 root")
|
||||
}
|
||||
return b.buildWithSource(fsys, roots)
|
||||
}
|
||||
|
||||
func (b *RegistryBuilder) buildWithSource(fsys fs.FS, roots []string) (*Registry, error) {
|
||||
var entries []overlayEntry
|
||||
if fsys != nil {
|
||||
var err error
|
||||
entries, err = loadOverlays(fsys, roots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
snap, err := buildSnapshot(b.opts, b.builtin, entries, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &Registry{
|
||||
opts: b.opts,
|
||||
builtin: append([]IntentSpec(nil), b.builtin...),
|
||||
fsys: fsys,
|
||||
roots: append([]string(nil), roots...),
|
||||
hasSource: fsys != nil,
|
||||
log: b.opts.Log,
|
||||
}
|
||||
r.snap.Store(snap)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Registry 持有当前快照与配置来源,支持原子 Reload。
|
||||
type Registry struct {
|
||||
opts Options
|
||||
builtin []IntentSpec
|
||||
fsys fs.FS
|
||||
roots []string
|
||||
hasSource bool
|
||||
mu sync.Mutex // 串行化 Reload
|
||||
snap atomic.Pointer[Snapshot]
|
||||
log *logsys.Logger
|
||||
}
|
||||
|
||||
// Snapshot 返回当前生效快照。永不为 nil。
|
||||
func (r *Registry) Snapshot() *Snapshot { return r.snap.Load() }
|
||||
|
||||
// Options 返回生效选项(已填默认值)。
|
||||
func (r *Registry) Options() Options { return r.opts }
|
||||
|
||||
// Reload 重读配置来源并构建新快照;成功后原子替换并返回新快照。
|
||||
// 任一步失败保留旧快照,revision 不变,返回带来源与原因的错误。
|
||||
func (r *Registry) Reload(ctx context.Context) (*Snapshot, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
old := r.snap.Load()
|
||||
if !r.hasSource {
|
||||
err := newError(LoadFailed, "没有登记配置来源;用 BuildFS 构建后才能 Reload", "revision", old.rev)
|
||||
r.log.Module("intent").Error("intent config rejected", err)
|
||||
return nil, err
|
||||
}
|
||||
entries, err := loadOverlays(r.fsys, r.roots)
|
||||
if err != nil {
|
||||
r.log.Module("intent").Error("intent config rejected", err)
|
||||
return nil, err
|
||||
}
|
||||
ns, err := buildSnapshot(r.opts, r.builtin, entries, old.rev+1)
|
||||
if err != nil {
|
||||
r.log.Module("intent").Error("intent config rejected", err)
|
||||
return nil, err
|
||||
}
|
||||
r.snap.Store(ns)
|
||||
r.log.Module("intent").Info("intent snapshot reloaded",
|
||||
"revision", ns.rev, "specs", len(ns.specs), "disabled", len(ns.disabled))
|
||||
return ns, nil
|
||||
}
|
||||
|
||||
// buildSnapshot 执行双源合并与编译。任一错误整批失败,不产生半套快照。
|
||||
func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry, rev int64) (*Snapshot, error) {
|
||||
opts = opts.normalized()
|
||||
protected := opts.protectedSet()
|
||||
guards := opts.guardSet()
|
||||
lim := opts.limits()
|
||||
|
||||
ordered := append([]IntentSpec(nil), baseline...)
|
||||
pos := make(map[string]int, len(ordered))
|
||||
sources := make(map[string]string, len(ordered))
|
||||
for i, spec := range ordered {
|
||||
pos[spec.ID] = i
|
||||
sources[spec.ID] = "builtin"
|
||||
}
|
||||
var disabledList []string
|
||||
|
||||
for _, e := range entries {
|
||||
if e.disable {
|
||||
idx, ok := pos[e.id]
|
||||
if !ok {
|
||||
return nil, newError(InvalidSpec, "disable 目标不存在", "id", e.id, "source", e.source)
|
||||
}
|
||||
if protected[ordered[idx].Domain] {
|
||||
return nil, newError(SpecProtected, "overlay 试图禁用保护域 spec",
|
||||
"id", e.id, "domain", ordered[idx].Domain, "source", e.source)
|
||||
}
|
||||
ordered = append(ordered[:idx], ordered[idx+1:]...)
|
||||
for id, p := range pos {
|
||||
if p > idx {
|
||||
pos[id] = p - 1
|
||||
}
|
||||
}
|
||||
delete(pos, e.id)
|
||||
disabledList = append(disabledList, e.id)
|
||||
sources[e.id] = e.source + " (disabled)"
|
||||
continue
|
||||
}
|
||||
spec := e.spec
|
||||
if err := validateSpec(spec, lim); err != nil {
|
||||
return nil, err.(*Error).with("source", e.source)
|
||||
}
|
||||
if idx, ok := pos[spec.ID]; ok {
|
||||
old := ordered[idx]
|
||||
if protected[old.Domain] {
|
||||
return nil, newError(SpecProtected, "overlay 试图覆盖保护域 spec",
|
||||
"id", spec.ID, "domain", old.Domain, "source", e.source)
|
||||
}
|
||||
if spec.Version <= old.Version {
|
||||
return nil, newError(InvalidSpec, "覆盖必须递增 version",
|
||||
"id", spec.ID, "old_version", old.Version, "new_version", spec.Version, "source", e.source)
|
||||
}
|
||||
ordered[idx] = spec
|
||||
} else {
|
||||
ordered = append(ordered, spec)
|
||||
pos[spec.ID] = len(ordered) - 1
|
||||
}
|
||||
sources[spec.ID] = e.source
|
||||
}
|
||||
|
||||
total := len(ordered) + len(disabledList)
|
||||
if total > lim.maxSpecs {
|
||||
return nil, newError(InvalidSpec, "spec 总数超过上限", "total", total, "max", lim.maxSpecs)
|
||||
}
|
||||
|
||||
snap := &Snapshot{
|
||||
rev: rev,
|
||||
margin: opts.AmbiguityMargin,
|
||||
topN: opts.TopN,
|
||||
maxInput: opts.MaxInputLen,
|
||||
byID: make(map[string]int, len(ordered)),
|
||||
disabled: disabledList,
|
||||
sources: sources,
|
||||
total: total,
|
||||
}
|
||||
nextIndex := 0
|
||||
for _, spec := range ordered {
|
||||
if spec.Disabled {
|
||||
snap.disabled = append(snap.disabled, spec.ID)
|
||||
continue
|
||||
}
|
||||
cs, err := compileSpec(spec, guards[spec.Domain], nextIndex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap.specs = append(snap.specs, cs)
|
||||
snap.byID[spec.ID] = len(snap.specs) - 1
|
||||
if cs.guard {
|
||||
snap.guardIdx = append(snap.guardIdx, len(snap.specs)-1)
|
||||
}
|
||||
nextIndex++
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func demoGuardSpec() IntentSpec {
|
||||
return IntentSpec{
|
||||
ID: "guard.config_change",
|
||||
Domain: "guard",
|
||||
Version: 1,
|
||||
Priority: 1000,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"系统配置", "网关", "改密码"}},
|
||||
Risk: RiskHigh,
|
||||
Handler: "refuse.and_guide",
|
||||
}
|
||||
}
|
||||
|
||||
func newTestBuilder() *RegistryBuilder {
|
||||
return NewRegistryBuilder(Options{
|
||||
ProtectedDomains: []string{"guard"},
|
||||
GuardDomains: []string{"guard"},
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuilderRejectsDuplicateID(t *testing.T) {
|
||||
b := newTestBuilder()
|
||||
if err := b.Register(demoGuardSpec()); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
err := b.Register(demoGuardSpec())
|
||||
var e *Error
|
||||
if !errors.As(err, &e) || e.Code != InvalidSpec {
|
||||
t.Fatalf("expected intent.invalid_spec for duplicate id, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilderRejectsInvalidSpec(t *testing.T) {
|
||||
b := newTestBuilder()
|
||||
bad := demoGuardSpec()
|
||||
bad.Domain = "Guard"
|
||||
err := b.Register(bad)
|
||||
var e *Error
|
||||
if !errors.As(err, &e) || e.Code != InvalidSpec {
|
||||
t.Fatalf("expected intent.invalid_spec, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBaselineOnly(t *testing.T) {
|
||||
b := newTestBuilder()
|
||||
if err := b.Register(demoGuardSpec()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := reg.Snapshot()
|
||||
if snap.Revision() != 1 {
|
||||
t.Fatalf("expected rev 1, got %d", snap.Revision())
|
||||
}
|
||||
if got := snap.SpecIDs(); len(got) != 1 || got[0] != "guard.config_change" {
|
||||
t.Fatalf("spec ids: %v", got)
|
||||
}
|
||||
if src, ok := snap.Source("guard.config_change"); !ok || src != "builtin" {
|
||||
t.Fatalf("source: %q %v", src, ok)
|
||||
}
|
||||
// 无配置来源的 Registry 不能 Reload。
|
||||
if _, err := reg.Reload(context.Background()); err == nil {
|
||||
t.Fatal("expected reload error without source")
|
||||
} else {
|
||||
var e *Error
|
||||
if !errors.As(err, &e) || e.Code != LoadFailed {
|
||||
t.Fatalf("expected intent.load_failed, got %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFSMergeSemantics(t *testing.T) {
|
||||
fsys := fstest.MapFS{"specs/overlay.json": &fstest.MapFile{Data: []byte(`{
|
||||
"intents": [
|
||||
{"id": "demo.run.added", "domain": "demo", "version": 1, "matcher": {"includes_any": ["新增"]}},
|
||||
{"id": "demo.base.existing", "domain": "demo", "version": 2, "matcher": {"includes_any": ["覆盖后"]}}
|
||||
],
|
||||
"disable": ["demo.base.other"]
|
||||
}`)}}
|
||||
|
||||
b := newTestBuilder()
|
||||
if err := b.Register(
|
||||
demoGuardSpec(),
|
||||
IntentSpec{ID: "demo.base.existing", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"覆盖前"}}},
|
||||
IntentSpec{ID: "demo.base.other", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"其它"}}},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.BuildFS(context.Background(), fsys, "specs")
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
snap := reg.Snapshot()
|
||||
|
||||
ids := snap.SpecIDs()
|
||||
want := []string{"guard.config_change", "demo.base.existing", "demo.run.added"}
|
||||
if len(ids) != len(want) {
|
||||
t.Fatalf("spec ids: %v", ids)
|
||||
}
|
||||
for i := range want {
|
||||
if ids[i] != want[i] {
|
||||
t.Fatalf("spec ids order: %v", ids)
|
||||
}
|
||||
}
|
||||
// 覆盖保持原位(合并序不变),来源指向 overlay。
|
||||
if spec, ok := snap.SpecByID("demo.base.existing"); !ok || spec.Version != 2 {
|
||||
t.Fatalf("override not applied: %+v %v", spec, ok)
|
||||
}
|
||||
if src, _ := snap.Source("demo.base.existing"); src != "specs/overlay.json#intents[1]" {
|
||||
t.Fatalf("override source: %q", src)
|
||||
}
|
||||
// disable 生效并保留来源。
|
||||
if _, ok := snap.SpecByID("demo.base.other"); ok {
|
||||
t.Fatal("disabled spec still enabled")
|
||||
}
|
||||
if d := snap.DisabledIDs(); len(d) != 1 || d[0] != "demo.base.other" {
|
||||
t.Fatalf("disabled ids: %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFSProtectedDomain(t *testing.T) {
|
||||
for name, doc := range map[string]string{
|
||||
"disable_protected": `{"disable": ["guard.config_change"]}`,
|
||||
"override_protected": `{"intents": [{"id": "guard.config_change", "domain": "guard", "version": 2, "matcher": {"includes_any": ["x"]}}]}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fsys := fstest.MapFS{"specs/o.json": &fstest.MapFile{Data: []byte(doc)}}
|
||||
b := newTestBuilder()
|
||||
if err := b.Register(demoGuardSpec()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := b.BuildFS(context.Background(), fsys, "specs")
|
||||
var e *Error
|
||||
if !errors.As(err, &e) || e.Code != SpecProtected {
|
||||
t.Fatalf("expected intent.spec_protected, got %v", err)
|
||||
}
|
||||
if e.Fields["id"] != "guard.config_change" {
|
||||
t.Fatalf("fields: %v", e.Fields)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFSOverrideMustIncreaseVersion(t *testing.T) {
|
||||
fsys := fstest.MapFS{"specs/o.json": &fstest.MapFile{Data: []byte(
|
||||
`{"intents": [{"id": "demo.base.existing", "domain": "demo", "version": 1, "matcher": {"includes_any": ["same"]}}]}`)}}
|
||||
b := newTestBuilder()
|
||||
if err := b.Register(IntentSpec{ID: "demo.base.existing", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"orig"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := b.BuildFS(context.Background(), fsys, "specs")
|
||||
var e *Error
|
||||
if !errors.As(err, &e) || e.Code != InvalidSpec {
|
||||
t.Fatalf("expected intent.invalid_spec, got %v", err)
|
||||
}
|
||||
if e.Fields["source"] != "specs/o.json#intents[0]" {
|
||||
t.Fatalf("fields: %v", e.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFSUnknownDisableTarget(t *testing.T) {
|
||||
fsys := fstest.MapFS{"specs/o.json": &fstest.MapFile{Data: []byte(`{"disable": ["demo.nope.absent"]}`)}}
|
||||
b := newTestBuilder()
|
||||
_, err := b.BuildFS(context.Background(), fsys, "specs")
|
||||
var e *Error
|
||||
if !errors.As(err, &e) || e.Code != InvalidSpec {
|
||||
t.Fatalf("expected intent.invalid_spec, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotConflicts(t *testing.T) {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(
|
||||
IntentSpec{ID: "demo.greet.hello", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"你好"}}},
|
||||
IntentSpec{ID: "demo.greet.hi", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"你好"}}},
|
||||
IntentSpec{ID: "demo.other.x", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"你好"}}},
|
||||
IntentSpec{ID: "demo.solo.y", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"独占"}}},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conflicts := reg.Snapshot().Conflicts()
|
||||
// 你好 由三个 spec 共享 → C(3,2)=3 条报告;独占词条不报。
|
||||
if len(conflicts) != 3 {
|
||||
t.Fatalf("expected 3 conflicts, got %d: %+v", len(conflicts), conflicts)
|
||||
}
|
||||
for _, c := range conflicts {
|
||||
if c.Kind != "shared_term" || c.Term != "你好" {
|
||||
t.Fatalf("conflict: %+v", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadSwapsAndKeepsOldOnFailure(t *testing.T) {
|
||||
fsys := fstest.MapFS{"specs/a.json": &fstest.MapFile{Data: []byte(
|
||||
`{"intents": [{"id": "demo.x.one", "domain": "demo", "version": 1, "matcher": {"includes_any": ["a"]}}]}`)}}
|
||||
b := newTestBuilder()
|
||||
reg, err := b.BuildFS(context.Background(), fsys, "specs")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 修改配置(MapFS 可变)后 Reload:revision 递增、内容更新。
|
||||
fsys["specs/a.json"].Data = []byte(
|
||||
`{"intents": [{"id": "demo.x.two", "domain": "demo", "version": 1, "matcher": {"includes_any": ["b"]}}]}`)
|
||||
ns, err := reg.Reload(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if ns.Revision() != 2 || reg.Snapshot().Revision() != 2 {
|
||||
t.Fatalf("revision after reload: %d", ns.Revision())
|
||||
}
|
||||
if _, ok := reg.Snapshot().SpecByID("demo.x.two"); !ok {
|
||||
t.Fatal("reloaded spec missing")
|
||||
}
|
||||
|
||||
// 写坏配置再 Reload:失败且旧快照保持生效。
|
||||
fsys["specs/a.json"].Data = []byte(`{broken`)
|
||||
if _, err := reg.Reload(context.Background()); err == nil {
|
||||
t.Fatal("expected reload failure on broken config")
|
||||
}
|
||||
snap := reg.Snapshot()
|
||||
if snap.Revision() != 2 {
|
||||
t.Fatalf("revision changed on failed reload: %d", snap.Revision())
|
||||
}
|
||||
if _, ok := snap.SpecByID("demo.x.two"); !ok {
|
||||
t.Fatal("old snapshot content lost after failed reload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFSMaxSpecs(t *testing.T) {
|
||||
fsys := fstest.MapFS{"specs/a.json": &fstest.MapFile{Data: []byte(
|
||||
`{"intents": [
|
||||
{"id": "demo.x.one", "domain": "demo", "version": 1, "matcher": {"includes_any": ["a"]}},
|
||||
{"id": "demo.x.two", "domain": "demo", "version": 1, "matcher": {"includes_any": ["b"]}}
|
||||
]}`)}}
|
||||
b := NewRegistryBuilder(Options{MaxSpecs: 1})
|
||||
_, err := b.BuildFS(context.Background(), fsys, "specs")
|
||||
var e *Error
|
||||
if !errors.As(err, &e) || e.Code != InvalidSpec {
|
||||
t.Fatalf("expected intent.invalid_spec for over-limit, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package intent
|
||||
|
||||
// Branch 识别分支。前两级命中即短路返回;rules 过阈后按置信度裁决。
|
||||
type Branch string
|
||||
|
||||
const (
|
||||
// BranchGuardHit 守卫域命中(Stage 0)。典型用途是"识别即拒绝":宿主拿到
|
||||
// 守卫意图后拒绝执行并给出引导,而不是把输入交给后续通道。
|
||||
BranchGuardHit Branch = "guard_hit"
|
||||
// BranchExpectationHit 会话挂起预期命中(Stage 1)。多轮澄清场景:
|
||||
// 宿主在上一轮声明了预期意图,本轮输入命中其中一个。
|
||||
BranchExpectationHit Branch = "expectation_hit"
|
||||
// BranchMatched 唯一意图以足够置信度过阈(Stage 3)。
|
||||
BranchMatched Branch = "matched"
|
||||
// BranchAmbiguous 多个候选过阈且分数贴近。宿主可澄清、交由 Classifier 或回落自由通道。
|
||||
BranchAmbiguous Branch = "ambiguous"
|
||||
// BranchFallback 无意图过阈。正常结果:交给宿主默认通道(如 agent 自由任务)。
|
||||
BranchFallback Branch = "fallback"
|
||||
)
|
||||
|
||||
// Candidate 单个意图候选。Slots 与 MissingRequiredSlots 只在构成
|
||||
// Top 候选时填充(含槽位抽取成本控制);Candidates 列表内其余候选不抽槽位。
|
||||
type Candidate struct {
|
||||
ID string
|
||||
Domain string
|
||||
Version int
|
||||
Confidence float64
|
||||
Priority int
|
||||
Risk string
|
||||
RequiresConfirm bool
|
||||
Handler string
|
||||
Metadata map[string]string
|
||||
Slots map[string]string
|
||||
MissingRequiredSlots []string
|
||||
// Reason 命中解释:命中词与模式,供宿主审计与 intentctl explain 复用。
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Result 识别结果。Query 语义:纯函数式、无副作用;error 只表示系统级失败,
|
||||
// BranchFallback 是正常识别结果。
|
||||
type Result struct {
|
||||
Input string
|
||||
Branch Branch
|
||||
Top *Candidate
|
||||
Candidates []Candidate
|
||||
SnapshotRevision int64
|
||||
}
|
||||
|
||||
// ScoredSpec 诊断用:单个 spec 在某个阶段的得分与命中明细。
|
||||
type ScoredSpec struct {
|
||||
ID string
|
||||
Score float64
|
||||
HitTerms []string
|
||||
HitPatterns []string
|
||||
}
|
||||
|
||||
// RejectedSpec 诊断用:被排除的 spec 与原因。
|
||||
type RejectedSpec struct {
|
||||
ID string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ExpectationTrace 诊断用:挂起预期阶段的输入与结果。
|
||||
type ExpectationTrace struct {
|
||||
PendingIDs []string
|
||||
HitID string
|
||||
}
|
||||
|
||||
// Trace 是 Snapshot.Explain 的输出:各阶段裁决轨迹。确定性、不含时间信息。
|
||||
type Trace struct {
|
||||
InputLen int
|
||||
Branch Branch
|
||||
TopID string
|
||||
Guard []ScoredSpec
|
||||
GuardRejected []RejectedSpec
|
||||
Expectation *ExpectationTrace
|
||||
Rules []ScoredSpec
|
||||
RulesRejected []RejectedSpec
|
||||
AmbiguityMargin float64
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package intent
|
||||
|
||||
import "strings"
|
||||
|
||||
// Segmenter 多意图分句扩展点:把一段输入拆成可独立识别的子句。
|
||||
// 模块不自动调用;宿主在需要多意图处理时先分句、再逐句识别。
|
||||
type Segmenter interface {
|
||||
Segment(input string) []string
|
||||
}
|
||||
|
||||
// RuleSegmenter 规则分句:按分隔符切分。确定性、无 I/O。
|
||||
type RuleSegmenter struct {
|
||||
// Delims 分隔符 rune 集合。零值使用默认中英文句读与换行。
|
||||
Delims []rune
|
||||
}
|
||||
|
||||
// NewRuleSegmenter 返回使用默认分隔符(,。;!?、,.!?; 与换行)的分句器。
|
||||
func NewRuleSegmenter() *RuleSegmenter {
|
||||
return &RuleSegmenter{Delims: []rune(",。;!?、,.!?;\n")}
|
||||
}
|
||||
|
||||
// Segment 按分隔符切分并去除空白子句;无有效子句时原样返回单元素。
|
||||
func (s *RuleSegmenter) Segment(input string) []string {
|
||||
delimSet := s.Delims
|
||||
if len(delimSet) == 0 {
|
||||
delimSet = NewRuleSegmenter().Delims
|
||||
}
|
||||
set := make(map[rune]bool, len(delimSet))
|
||||
for _, r := range delimSet {
|
||||
set[r] = true
|
||||
}
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
for _, r := range input {
|
||||
if set[r] {
|
||||
if seg := strings.TrimSpace(cur.String()); seg != "" {
|
||||
out = append(out, seg)
|
||||
}
|
||||
cur.Reset()
|
||||
continue
|
||||
}
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
if seg := strings.TrimSpace(cur.String()); seg != "" {
|
||||
out = append(out, seg)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{input}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 风险等级。透传给宿主做授权与确认判断,本模块不解释。
|
||||
const (
|
||||
RiskLow = "low"
|
||||
RiskMedium = "medium"
|
||||
RiskHigh = "high"
|
||||
)
|
||||
|
||||
// ConfidenceSpec 各字段为零值时使用包默认值。
|
||||
const (
|
||||
DefaultBase = 0.7
|
||||
DefaultPatternBonus = 0.2
|
||||
DefaultThreshold = 0.6
|
||||
)
|
||||
|
||||
// MatchSpec 声明式匹配规则。
|
||||
// 计分:score = base + (任一 pattern 命中 ? pattern_bonus : 0),上限 1.0。
|
||||
// 命中条件:excludes_any 全不命中 && includes_all 全命中 &&(includes_any 至少一词命中
|
||||
// 或任一 pattern 命中;仅配置 includes_all 时以全命中为准)。
|
||||
// case_sensitive 只作用于 includes/excludes 词表;pattern 自行用 (?i) 表达大小写。
|
||||
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"`
|
||||
}
|
||||
|
||||
// SlotSpec 槽位抽取规则。group 未填默认取 1(第一个捕获组)。
|
||||
type SlotSpec struct {
|
||||
Key string `json:"key"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Pattern string `json:"pattern"`
|
||||
Group int `json:"group,omitempty"`
|
||||
}
|
||||
|
||||
// ConfidenceSpec 置信度参数。Base 初始分;PatternBonus 模式命中加成;
|
||||
// Threshold 为该 spec 自身的过阈线,score ≥ threshold 才进入候选。
|
||||
type ConfidenceSpec struct {
|
||||
Base float64 `json:"base,omitempty"`
|
||||
PatternBonus float64 `json:"pattern_bonus,omitempty"`
|
||||
Threshold float64 `json:"threshold,omitempty"`
|
||||
}
|
||||
|
||||
// IntentSpec 意图定义,Go 注册与 JSON overlay 共用同一结构。
|
||||
// Handler 与 Metadata 由宿主解释(路由通道、动作键等),本模块只透传。
|
||||
type IntentSpec struct {
|
||||
ID string `json:"id"`
|
||||
Domain string `json:"domain"`
|
||||
Version int `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
Matcher MatchSpec `json:"matcher"`
|
||||
Slots []SlotSpec `json:"slots,omitempty"`
|
||||
Confidence ConfidenceSpec `json:"confidence,omitempty"`
|
||||
Risk string `json:"risk,omitempty"`
|
||||
RequiresConfirm bool `json:"requires_confirm,omitempty"`
|
||||
Handler string `json:"handler,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
domainRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,31}$`)
|
||||
segmentRe = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,63}$`)
|
||||
slotKeyRe = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
||||
)
|
||||
|
||||
// limits 在 Options 中声明,防止配置膨胀拖垮识别与构建。
|
||||
type limits struct {
|
||||
maxSpecs int
|
||||
maxPatternsPerSpec int
|
||||
maxSlotsPerSpec int
|
||||
}
|
||||
|
||||
// validateSpec 校验单条 spec 的结构合法性,错误可定位到字段。limits 为零值时跳过数量上限。
|
||||
func validateSpec(spec IntentSpec, lim limits) error {
|
||||
if spec.Domain == "" {
|
||||
return newError(InvalidSpec, "domain 为空", "field", "domain")
|
||||
}
|
||||
if !domainRe.MatchString(spec.Domain) {
|
||||
return newError(InvalidSpec, "domain 格式不合法(^[a-z][a-z0-9-]{1,31}$)", "field", "domain", "domain", spec.Domain)
|
||||
}
|
||||
if spec.ID == "" {
|
||||
return newError(InvalidSpec, "id 为空", "field", "id")
|
||||
}
|
||||
// id 形如 <domain>.<mnemonic>,mnemonic 可继续分级(demo.run.status_query)。
|
||||
segments := strings.Split(spec.ID, ".")
|
||||
if len(segments) < 2 {
|
||||
return newError(InvalidSpec, "id 至少包含 domain 与记忆名两段", "field", "id", "id", spec.ID)
|
||||
}
|
||||
if segments[0] != spec.Domain {
|
||||
return newError(InvalidSpec, "id 首段必须等于 domain", "field", "id", "id", spec.ID, "domain", spec.Domain)
|
||||
}
|
||||
for _, seg := range segments[1:] {
|
||||
if !segmentRe.MatchString(seg) {
|
||||
return newError(InvalidSpec, "id 段格式不合法(^[a-z][a-z0-9_-]{0,63}$)", "field", "id", "id", spec.ID)
|
||||
}
|
||||
}
|
||||
if spec.Version < 1 {
|
||||
return newError(InvalidSpec, "version 必须 ≥ 1", "field", "version", "version", spec.Version)
|
||||
}
|
||||
switch spec.Risk {
|
||||
case "", RiskLow, RiskMedium, RiskHigh:
|
||||
default:
|
||||
return newError(InvalidSpec, "risk 只能是 low/medium/high 或留空", "field", "risk", "risk", spec.Risk)
|
||||
}
|
||||
|
||||
m := spec.Matcher
|
||||
for _, terms := range []struct {
|
||||
name string
|
||||
value []string
|
||||
}{{"includes_any", m.IncludesAny}, {"includes_all", m.IncludesAll}, {"excludes_any", m.ExcludesAny}} {
|
||||
for _, t := range terms.value {
|
||||
if strings.TrimSpace(t) == "" {
|
||||
return newError(InvalidSpec, "matcher 词条为空", "field", "matcher."+terms.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(m.IncludesAny) == 0 && len(m.IncludesAll) == 0 && len(m.Patterns) == 0 {
|
||||
return newError(InvalidSpec, "matcher 缺少正向条件(includes_any / includes_all / patterns 至少一项)", "field", "matcher", "id", spec.ID)
|
||||
}
|
||||
if lim.maxPatternsPerSpec > 0 && len(m.Patterns) > lim.maxPatternsPerSpec {
|
||||
return newError(InvalidSpec, "patterns 数量超过上限", "field", "matcher.patterns", "count", len(m.Patterns), "max", lim.maxPatternsPerSpec, "id", spec.ID)
|
||||
}
|
||||
for i, p := range m.Patterns {
|
||||
if _, err := regexp.Compile(p); err != nil {
|
||||
return newError(InvalidSpec, "pattern 编译失败", "field", fmt.Sprintf("matcher.patterns[%d]", i), "pattern", p, "id", spec.ID, "error", err.Error())
|
||||
}
|
||||
}
|
||||
if lim.maxSlotsPerSpec > 0 && len(spec.Slots) > lim.maxSlotsPerSpec {
|
||||
return newError(InvalidSpec, "slots 数量超过上限", "field", "slots", "count", len(spec.Slots), "max", lim.maxSlotsPerSpec, "id", spec.ID)
|
||||
}
|
||||
for i, s := range spec.Slots {
|
||||
if !slotKeyRe.MatchString(s.Key) {
|
||||
return newError(InvalidSpec, "slot key 格式不合法(^[a-z][a-z0-9_]{0,63}$)", "field", fmt.Sprintf("slots[%d].key", i), "key", s.Key)
|
||||
}
|
||||
re, err := regexp.Compile(s.Pattern)
|
||||
if err != nil {
|
||||
return newError(InvalidSpec, "slot pattern 编译失败", "field", fmt.Sprintf("slots[%d].pattern", i), "key", s.Key, "error", err.Error())
|
||||
}
|
||||
// group 约定:未填默认取 1(第一个捕获组);需整段匹配时在 pattern 里写捕获组即可。
|
||||
g := effectiveGroup(s.Group)
|
||||
if g < 1 || g > re.NumSubexp() {
|
||||
return newError(InvalidSpec, "slot group 超出捕获组范围", "field", fmt.Sprintf("slots[%d].group", i), "key", s.Key, "group", s.Group, "groups", re.NumSubexp())
|
||||
}
|
||||
}
|
||||
|
||||
c := spec.Confidence
|
||||
if c.Base < 0 || c.Base > 1 {
|
||||
return newError(InvalidSpec, "confidence.base 须在 (0,1],0/留空取默认", "field", "confidence.base", "id", spec.ID)
|
||||
}
|
||||
if c.PatternBonus < 0 || c.PatternBonus >= 1 {
|
||||
return newError(InvalidSpec, "confidence.pattern_bonus 须在 [0,1),留空取默认", "field", "confidence.pattern_bonus", "id", spec.ID)
|
||||
}
|
||||
if c.Threshold < 0 || c.Threshold > 1 {
|
||||
return newError(InvalidSpec, "confidence.threshold 须在 (0,1],0/留空取默认", "field", "confidence.threshold", "id", spec.ID)
|
||||
}
|
||||
for k := range spec.Metadata {
|
||||
if strings.TrimSpace(k) == "" {
|
||||
return newError(InvalidSpec, "metadata 键为空", "field", "metadata", "id", spec.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// effectiveGroup 槽位 group 的缺省规则:未填(0)默认取 1。
|
||||
func effectiveGroup(g int) int {
|
||||
if g == 0 {
|
||||
return 1
|
||||
}
|
||||
return g
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validSpec() IntentSpec {
|
||||
return IntentSpec{
|
||||
ID: "demo.run.status_query",
|
||||
Domain: "demo",
|
||||
Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"进度"}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSpecAcceptsValid(t *testing.T) {
|
||||
lim := Options{}.normalized().limits()
|
||||
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{}); err != nil {
|
||||
t.Fatalf("valid spec rejected without limits: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSpecRejections(t *testing.T) {
|
||||
lim := Options{}.normalized().limits()
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(*IntentSpec)
|
||||
field string
|
||||
}{
|
||||
{"empty_domain", func(s *IntentSpec) { s.Domain = "" }, "domain"},
|
||||
{"bad_domain_case", func(s *IntentSpec) { s.Domain = "Demo" }, "domain"},
|
||||
{"id_prefix_mismatch", func(s *IntentSpec) { s.ID = "other.status_query" }, "id"},
|
||||
{"bad_mnemonic", func(s *IntentSpec) { s.ID = "demo.Status-Query" }, "id"},
|
||||
{"zero_version", func(s *IntentSpec) { s.Version = 0 }, "version"},
|
||||
{"bad_risk", func(s *IntentSpec) { s.Risk = "extreme" }, "risk"},
|
||||
{"empty_matcher", func(s *IntentSpec) { s.Matcher = MatchSpec{} }, "matcher"},
|
||||
{"empty_term", func(s *IntentSpec) { s.Matcher = MatchSpec{IncludesAny: []string{" "}} }, "matcher.includes_any"},
|
||||
{"bad_pattern", func(s *IntentSpec) { s.Matcher = MatchSpec{IncludesAny: []string{"a"}, Patterns: []string{"["}} }, "matcher.patterns[0]"},
|
||||
{"too_many_patterns", func(s *IntentSpec) {
|
||||
s.Matcher = MatchSpec{IncludesAny: []string{"a"}, Patterns: make([]string, lim.maxPatternsPerSpec+1)}
|
||||
for i := range s.Matcher.Patterns {
|
||||
s.Matcher.Patterns[i] = "x"
|
||||
}
|
||||
}, "matcher.patterns"},
|
||||
{"bad_slot_key", func(s *IntentSpec) {
|
||||
s.Slots = []SlotSpec{{Key: "RunID", Pattern: "(x)"}}
|
||||
}, "slots[0].key"},
|
||||
{"bad_slot_pattern", func(s *IntentSpec) {
|
||||
s.Slots = []SlotSpec{{Key: "run_id", Pattern: "["}}
|
||||
}, "slots[0].pattern"},
|
||||
{"slot_group_out_of_range", func(s *IntentSpec) {
|
||||
s.Slots = []SlotSpec{{Key: "run_id", Pattern: "(x)", Group: 2}}
|
||||
}, "slots[0].group"},
|
||||
{"base_out_of_range", func(s *IntentSpec) {
|
||||
s.Confidence = ConfidenceSpec{Base: 1.5}
|
||||
}, "confidence.base"},
|
||||
{"bonus_out_of_range", func(s *IntentSpec) {
|
||||
s.Confidence = ConfidenceSpec{PatternBonus: 1.0}
|
||||
}, "confidence.pattern_bonus"},
|
||||
{"threshold_out_of_range", func(s *IntentSpec) {
|
||||
s.Confidence = ConfidenceSpec{Threshold: -0.1}
|
||||
}, "confidence.threshold"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
spec := validSpec()
|
||||
tc.mut(&spec)
|
||||
err := validateSpec(spec, lim)
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection")
|
||||
}
|
||||
var e *Error
|
||||
if !errors.As(err, &e) || e.Code != InvalidSpec {
|
||||
t.Fatalf("expected intent.invalid_spec, got %v", err)
|
||||
}
|
||||
if got, _ := e.Fields["field"].(string); got != tc.field && !hasPrefix(got, tc.field) {
|
||||
t.Fatalf("expected field %q, got %q (fields=%v)", tc.field, got, e.Fields)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func hasPrefix(s, p string) bool {
|
||||
return len(s) >= len(p) && s[:len(p)] == p
|
||||
}
|
||||
|
||||
func TestValidateSpecIncludesAllOnly(t *testing.T) {
|
||||
// 仅 includes_all 也是合法正向条件。
|
||||
spec := validSpec()
|
||||
spec.Matcher = MatchSpec{IncludesAll: []string{"任务", "进度"}}
|
||||
if err := validateSpec(spec, Options{}.normalized().limits()); err != nil {
|
||||
t.Fatalf("includes_all-only spec rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorCodeDuckInterface(t *testing.T) {
|
||||
err := newError(InvalidSpec, "msg", "id", "demo.x")
|
||||
if err.ErrorCode() != InvalidSpec {
|
||||
t.Fatalf("code mismatch")
|
||||
}
|
||||
fields := err.ErrorFields()
|
||||
if fields["id"] != "demo.x" {
|
||||
t.Fatalf("fields mismatch: %v", fields)
|
||||
}
|
||||
if err.Error() == "" {
|
||||
t.Fatal("empty error string")
|
||||
}
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +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_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"}
|
||||
]
|
||||
}
|
||||
Vendored
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"id": "demo.run.status_query",
|
||||
"domain": "demo",
|
||||
"version": 1,
|
||||
"description": "查询任务运行进度",
|
||||
"matcher": {
|
||||
"includes_any": ["进度", "状态"],
|
||||
"excludes_any": ["配置"]
|
||||
},
|
||||
"slots": [
|
||||
{"key": "run_id", "required": false, "pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)", "group": 1}
|
||||
],
|
||||
"confidence": {"base": 0.8, "threshold": 0.7},
|
||||
"risk": "low",
|
||||
"handler": "taskrun.query_status",
|
||||
"metadata": {"channel": "dialogue"}
|
||||
},
|
||||
{
|
||||
"id": "demo.run.restart",
|
||||
"domain": "demo",
|
||||
"version": 1,
|
||||
"description": "重启任务",
|
||||
"matcher": {"includes_any": ["重启"], "includes_all": ["任务"]},
|
||||
"confidence": {"base": 0.75, "threshold": 0.7},
|
||||
"risk": "medium",
|
||||
"requires_confirm": true,
|
||||
"handler": "taskrun.restart"
|
||||
},
|
||||
{
|
||||
"id": "demo.run.cancel",
|
||||
"domain": "demo",
|
||||
"version": 1,
|
||||
"description": "取消任务",
|
||||
"matcher": {"includes_any": ["取消"]},
|
||||
"slots": [
|
||||
{"key": "run_id", "required": true, "pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)", "group": 1}
|
||||
],
|
||||
"handler": "taskrun.cancel"
|
||||
},
|
||||
{
|
||||
"id": "demo.greet.hello",
|
||||
"domain": "demo",
|
||||
"version": 1,
|
||||
"matcher": {"includes_any": ["你好"]},
|
||||
"priority": 10
|
||||
},
|
||||
{
|
||||
"id": "demo.greet.hi",
|
||||
"domain": "demo",
|
||||
"version": 1,
|
||||
"matcher": {"includes_any": ["你好"]},
|
||||
"priority": 5
|
||||
},
|
||||
{
|
||||
"id": "demo.misc.deploy",
|
||||
"domain": "demo",
|
||||
"version": 1,
|
||||
"matcher": {"includes_any": ["deploy", "发布"], "case_sensitive": false},
|
||||
"handler": "deploy.run"
|
||||
}
|
||||
],
|
||||
"disable": []
|
||||
}
|
||||
Reference in New Issue
Block a user