diff --git a/README.md b/README.md index 820bbcc..5d9b4f4 100644 --- a/README.md +++ b/README.md @@ -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/`。 diff --git a/catalogcheck/align_test.go b/catalogcheck/align_test.go new file mode 100644 index 0000000..16d5200 --- /dev/null +++ b/catalogcheck/align_test.go @@ -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)) + } + } +} diff --git a/cmd/intentctl/main.go b/cmd/intentctl/main.go new file mode 100644 index 0000000..67a511a --- /dev/null +++ b/cmd/intentctl/main.go @@ -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 ...\n lint -root [-protected a,b] [-guard a,b]\n explain -root [-protected a,b] [-guard a,b] \n eval -root [-protected a,b] [-guard a,b] ") +} + +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 +} diff --git a/cmd/intentctl/main_test.go b/cmd/intentctl/main_test.go new file mode 100644 index 0000000..faa14bc --- /dev/null +++ b/cmd/intentctl/main_test.go @@ -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") + } +} diff --git a/conformance/conformance.go b/conformance/conformance.go new file mode 100644 index 0000000..ba9e776 --- /dev/null +++ b/conformance/conformance.go @@ -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() + }) +} diff --git a/conformance/conformance_test.go b/conformance/conformance_test.go new file mode 100644 index 0000000..4bfa39a --- /dev/null +++ b/conformance/conformance_test.go @@ -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}, + }) +} diff --git a/docs/records/2026-09-29-implementation-verification.md b/docs/records/2026-09-29-implementation-verification.md new file mode 100644 index 0000000..a265caf --- /dev/null +++ b/docs/records/2026-09-29-implementation-verification.md @@ -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。 diff --git a/examples/example_test.go b/examples/example_test.go new file mode 100644 index 0000000..e09f659 --- /dev/null +++ b/examples/example_test.go @@ -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) + } +} diff --git a/examples/specs/demo.json b/examples/specs/demo.json new file mode 100644 index 0000000..70ab68c --- /dev/null +++ b/examples/specs/demo.json @@ -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" + } + ] +} diff --git a/intent/classifier.go b/intent/classifier.go new file mode 100644 index 0000000..cb47665 --- /dev/null +++ b/intent/classifier.go @@ -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) +} diff --git a/intent/errors.go b/intent/errors.go new file mode 100644 index 0000000..9d749ae --- /dev/null +++ b/intent/errors.go @@ -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 +} diff --git a/intent/eval_test.go b/intent/eval_test.go new file mode 100644 index 0000000..bc75b2a --- /dev/null +++ b/intent/eval_test.go @@ -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) +} diff --git a/intent/expectation.go b/intent/expectation.go new file mode 100644 index 0000000..2689c58 --- /dev/null +++ b/intent/expectation.go @@ -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 +} diff --git a/intent/loader.go b/intent/loader.go new file mode 100644 index 0000000..c049d95 --- /dev/null +++ b/intent/loader.go @@ -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 +} diff --git a/intent/loader_test.go b/intent/loader_test.go new file mode 100644 index 0000000..a6fee5e --- /dev/null +++ b/intent/loader_test.go @@ -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) + } +} diff --git a/intent/race_test.go b/intent/race_test.go new file mode 100644 index 0000000..8863dc1 --- /dev/null +++ b/intent/race_test.go @@ -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) + } +} diff --git a/intent/recognizer.go b/intent/recognizer.go new file mode 100644 index 0000000..5d708da --- /dev/null +++ b/intent/recognizer.go @@ -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 }) +} diff --git a/intent/recognizer_test.go b/intent/recognizer_test.go new file mode 100644 index 0000000..27d21ee --- /dev/null +++ b/intent/recognizer_test.go @@ -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) + } +} diff --git a/intent/registry.go b/intent/registry.go new file mode 100644 index 0000000..acfd10c --- /dev/null +++ b/intent/registry.go @@ -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" 或 "#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 +} diff --git a/intent/registry_test.go b/intent/registry_test.go new file mode 100644 index 0000000..8813770 --- /dev/null +++ b/intent/registry_test.go @@ -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) + } +} diff --git a/intent/result.go b/intent/result.go new file mode 100644 index 0000000..48aef0a --- /dev/null +++ b/intent/result.go @@ -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 +} diff --git a/intent/segment.go b/intent/segment.go new file mode 100644 index 0000000..34e11de --- /dev/null +++ b/intent/segment.go @@ -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 +} diff --git a/intent/spec.go b/intent/spec.go new file mode 100644 index 0000000..c56f440 --- /dev/null +++ b/intent/spec.go @@ -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 形如 .,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 +} diff --git a/intent/spec_test.go b/intent/spec_test.go new file mode 100644 index 0000000..665bf9e --- /dev/null +++ b/intent/spec_test.go @@ -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") + } +} diff --git a/intent/testdata/corpus.json b/intent/testdata/corpus.json new file mode 100644 index 0000000..f498fa3 --- /dev/null +++ b/intent/testdata/corpus.json @@ -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"} + ] +} diff --git a/intent/testdata/specs/base.json b/intent/testdata/specs/base.json new file mode 100644 index 0000000..8b9d042 --- /dev/null +++ b/intent/testdata/specs/base.json @@ -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": [] +}