Implement the intent core

Details (original message):
Implement the intent core, conformance suite, catalog alignment, examples, and the intentctl diagnostics CLI.
This commit is contained in:
weichen
2026-09-29 15:35:18 +00:00
parent b450b03638
commit b58595fb38
26 changed files with 3121 additions and 8 deletions
+98 -8
View File
@@ -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/`。
+34
View File
@@ -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))
}
}
}
+260
View File
@@ -0,0 +1,260 @@
// intentctl 是意图配置的只读诊断 CLI:lint 校验配置并报告冲突,
// explain 输出单条输入的裁决轨迹,eval 跑评测集。所有命令不改配置、不写文件。
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"sort"
"strings"
"agent-intent/intent"
)
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "intentctl:", err)
os.Exit(1)
}
}
func run(args []string) error {
if len(args) < 1 {
return usage()
}
var err error
switch args[0] {
case "lint":
err = cmdLint(args[1:])
case "explain":
err = cmdExplain(args[1:])
case "eval":
err = cmdEval(args[1:])
default:
err = usage()
}
return err
}
func usage() error {
return fmt.Errorf("用法:intentctl <lint|explain|eval> ...\n lint -root <dir> [-protected a,b] [-guard a,b]\n explain -root <dir> [-protected a,b] [-guard a,b] <input>\n eval -root <dir> [-protected a,b] [-guard a,b] <corpus.json>")
}
type commonOpts struct {
root string
protected string
guard string
}
func parseCommon(fs *flag.FlagSet) *commonOpts {
c := &commonOpts{}
fs.StringVar(&c.root, "root", "", "配置目录(*.json,必填)")
fs.StringVar(&c.protected, "protected", "guard", "保护域,逗号分隔")
fs.StringVar(&c.guard, "guard", "guard", "守卫域,逗号分隔")
return c
}
func buildRegistry(c *commonOpts) (*intent.Registry, error) {
if c.root == "" {
return nil, fmt.Errorf("-root 必填")
}
b := intent.NewRegistryBuilder(intent.Options{
ProtectedDomains: splitDomains(c.protected),
GuardDomains: splitDomains(c.guard),
})
return b.BuildFS(context.Background(), os.DirFS(c.root), ".")
}
func splitDomains(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func cmdLint(args []string) error {
fs := flag.NewFlagSet("lint", flag.ContinueOnError)
c := parseCommon(fs)
if err := fs.Parse(args); err != nil {
return err
}
reg, err := buildRegistry(c)
if err != nil {
return fmt.Errorf("配置校验失败:%w", err)
}
snap := reg.Snapshot()
fmt.Printf("快照 revision=%d 启用=%d 禁用=%d\n", snap.Revision(), len(snap.SpecIDs()), len(snap.DisabledIDs()))
for _, id := range snap.SpecIDs() {
src, _ := snap.Source(id)
fmt.Printf(" spec %s (%s)\n", id, src)
}
for _, id := range snap.DisabledIDs() {
src, _ := snap.Source(id)
fmt.Printf(" disabled %s (%s)\n", id, src)
}
conflicts := snap.Conflicts()
if len(conflicts) == 0 {
fmt.Println("共享词条提示:无")
return nil
}
fmt.Printf("共享词条提示(%d 条,非错误,计分与确定性次序会裁决):\n", len(conflicts))
for _, cf := range conflicts {
fmt.Printf(" %s 与 %s 共享词条 %q\n", cf.A, cf.B, cf.Term)
}
return nil
}
func cmdExplain(args []string) error {
fs := flag.NewFlagSet("explain", flag.ContinueOnError)
c := parseCommon(fs)
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() < 1 {
return fmt.Errorf("explain 需要一个输入参数")
}
input := fs.Arg(0)
reg, err := buildRegistry(c)
if err != nil {
return fmt.Errorf("配置校验失败:%w", err)
}
snap := reg.Snapshot()
rec := intent.NewRecognizer(intent.Options{}, nil, nil)
res, err := rec.Recognize(context.Background(), input, snap)
if err != nil {
return err
}
fmt.Printf("输入:%q(%d 字节)\n分支:%s\n", input, len(input), res.Branch)
if res.Top != nil {
fmt.Printf("Top:%s 置信度=%.4f 风险=%s 确认=%v handler=%s\n", res.Top.ID, res.Top.Confidence, res.Top.Risk, res.Top.RequiresConfirm, res.Top.Handler)
if len(res.Top.Slots) > 0 {
keys := make([]string, 0, len(res.Top.Slots))
for k := range res.Top.Slots {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf(" 槽位 %s=%q\n", k, res.Top.Slots[k])
}
}
if len(res.Top.MissingRequiredSlots) > 0 {
fmt.Printf(" 缺失必填槽位:%s\n", strings.Join(res.Top.MissingRequiredSlots, ","))
}
}
if len(res.Candidates) > 0 {
fmt.Printf("候选(%d):\n", len(res.Candidates))
for _, cand := range res.Candidates {
fmt.Printf(" %.4f %-28s %s\n", cand.Confidence, cand.ID, cand.Reason)
}
}
fmt.Println("裁决轨迹:")
trace := snap.Explain(input)
fmt.Printf(" margin=%.4f\n", trace.AmbiguityMargin)
printStage(" 守卫", trace.Guard, trace.GuardRejected)
if trace.Expectation != nil {
fmt.Printf(" 挂起预期:%v 命中=%s\n", trace.Expectation.PendingIDs, trace.Expectation.HitID)
}
printStage(" 规则", trace.Rules, trace.RulesRejected)
return nil
}
func printStage(name string, scored []intent.ScoredSpec, rejected []intent.RejectedSpec) {
if len(scored) == 0 && len(rejected) == 0 {
fmt.Printf("%s:未评估\n", name)
return
}
for _, s := range scored {
fmt.Printf("%s 命中 %-28s %.4f %s %s\n", name, s.ID, s.Score, strings.Join(s.HitTerms, ","), strings.Join(s.HitPatterns, ","))
}
for _, r := range rejected {
fmt.Printf("%s 排除 %-28s %s\n", name, r.ID, r.Reason)
}
}
// evalCase 与 intent 包评测集(testdata/corpus.json)同格式。
type evalCase struct {
Name string `json:"name"`
Input string `json:"input"`
ExpectBranch string `json:"expect_branch"`
ExpectTop string `json:"expect_top"`
ExpectSlots map[string]string `json:"expect_slots"`
ExpectMissingSlots []string `json:"expect_missing_slots"`
}
type evalCorpus struct {
Cases []evalCase `json:"cases"`
}
func cmdEval(args []string) error {
fs := flag.NewFlagSet("eval", flag.ContinueOnError)
c := parseCommon(fs)
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() < 1 {
return fmt.Errorf("eval 需要评测集路径参数")
}
data, err := os.ReadFile(fs.Arg(0))
if err != nil {
return err
}
var corpus evalCorpus
if err := json.Unmarshal(data, &corpus); err != nil {
return fmt.Errorf("评测集解析失败:%w", err)
}
if len(corpus.Cases) == 0 {
return fmt.Errorf("评测集为空")
}
reg, err := buildRegistry(c)
if err != nil {
return fmt.Errorf("配置校验失败:%w", err)
}
snap := reg.Snapshot()
rec := intent.NewRecognizer(intent.Options{}, nil, nil)
failed := 0
for _, tc := range corpus.Cases {
res, err := rec.Recognize(context.Background(), tc.Input, snap)
if err != nil {
fmt.Printf("FAIL %-28s 识别错误:%v\n", tc.Name, err)
failed++
continue
}
if string(res.Branch) != tc.ExpectBranch || (tc.ExpectTop != "" && (res.Top == nil || res.Top.ID != tc.ExpectTop)) {
gotTop := "(none)"
if res.Top != nil {
gotTop = res.Top.ID
}
fmt.Printf("FAIL %-28s 输入=%q 分支=%s(期望 %s) top=%s(期望 %s)\n", tc.Name, tc.Input, res.Branch, tc.ExpectBranch, gotTop, tc.ExpectTop)
failed++
continue
}
slotOK := true
for k, v := range tc.ExpectSlots {
if res.Top == nil || res.Top.Slots[k] != v {
slotOK = false
break
}
}
if !slotOK {
fmt.Printf("FAIL %-28s 槽位不符:%v\n", tc.Name, res.Top.Slots)
failed++
continue
}
fmt.Printf("PASS %-28s %s\n", tc.Name, res.Branch)
}
fmt.Printf("总计 %d 用例:%d 通过,%d 失败\n", len(corpus.Cases), len(corpus.Cases)-failed, failed)
if failed > 0 {
return fmt.Errorf("%d/%d 用例失败", failed, len(corpus.Cases))
}
return nil
}
+73
View File
@@ -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")
}
}
+80
View File
@@ -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()
})
}
+33
View File
@@ -0,0 +1,33 @@
package conformance
import (
"context"
"testing"
"testing/fstest"
"agent-intent/intent"
)
func TestRunAcceptsCompliantRecognizer(t *testing.T) {
fsys := fstest.MapFS{"specs/demo.json": &fstest.MapFile{Data: []byte(`{
"intents": [
{"id": "demo.run.status_query", "domain": "demo", "version": 1,
"matcher": {"includes_any": ["进度"]},
"slots": [{"key": "run_id", "pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)"}],
"handler": "taskrun.query_status"},
{"id": "demo.greet.hello", "domain": "demo", "version": 1, "matcher": {"includes_any": ["你好"]}},
{"id": "demo.greet.hi", "domain": "demo", "version": 1, "matcher": {"includes_any": ["你好"]}}
]
}`)}}
b := intent.NewRegistryBuilder(intent.Options{})
reg, err := b.BuildFS(context.Background(), fsys, "specs")
if err != nil {
t.Fatal(err)
}
rec := intent.NewRecognizer(intent.Options{}, nil, nil)
Run(t, rec, reg.Snapshot(), []Case{
{Name: "matched", Input: "查询任务 42 的进度", ExpectBranch: intent.BranchMatched, ExpectTopID: "demo.run.status_query", ExpectSlots: map[string]string{"run_id": "42"}},
{Name: "ambiguous", Input: "你好", ExpectBranch: intent.BranchAmbiguous},
{Name: "fallback", Input: "今天天气不错", ExpectBranch: intent.BranchFallback},
})
}
@@ -0,0 +1,52 @@
# Agent-Intent v0.1.0 实现与验证记录
对应设计方案:[2026-09-29-agent-intent-design.md](2026-09-29-agent-intent-design.md)(决策 D1–D7 按推荐执行,经用户批准)。
范围:P0–P3 全部完成;P4(消费方接入 PR)按方案另立任务,不在本仓。
## 交付内容
- `intent` 核心包:spec 定义与校验(id 分段格式、正则/槽位/数量上限)、RegistryBuilder / Registry / 不可变 Snapshot(atomic.Pointer 原子替换)、JSON overlay 加载合并(新增/覆盖需版本递增/禁用/保护域拒绝/未知字段拒绝)、四级漏斗识别器(guard → expectation → rules → adjudicate)、槽位抽取(必填缺失记录于 `MissingRequiredSlots`)、`Snapshot.Explain` 裁决轨迹、`Snapshot.Conflicts` 共享词条诊断、`RuleSegmenter` 规则分句、`Classifier` / `ExpectationProvider` 扩展接口、错误码鸭子方法 + logsys 可选注入。
- `conformance`:消费方行为验收套件(分支/Top/槽位断言 + 确定性 + 并发冒烟)。
- `catalogcheck`:`intent.*` / 复用的 `sys.invalid_argument` 常量与错误码库目录逐个相等。
- `cmd/intentctl`:`lint`(校验 + 冲突报告 + 来源清单)、`explain`(分支/候选/槽位/两阶段轨迹)、`eval`(评测集跑批,退出码承载失败)。
- `examples`:守卫 Go 内置(保护域)+ 业务 JSON embed 的端到端示例。
- 测试:spec 校验表测、loader 合并语义与来源定位、registry 快照/Reload 保留旧快照、识别器全分支(守卫短路、挂起预期、模式加成、歧义、分类器成败、必填槽位缺失、大小写折叠、空输入、超长输入、nil 快照)、确定性深比较、`-race` 并发识别 + Reload、评测集回归。
## 与方案的偏差
1. **执行顺序**:P1(Error-Code-System 上游 `intent` 域)先于 P0 完成,使本仓首个提交直接 pin ECS 新提交 `28fa06ea`,避免一次 pin 漂移。ECS 变更为追加式(新域 3 码 + 目录 JSON + 映射文档),不影响既有消费方。
2. **id 格式**:实现采用点分段 `^[a-z][a-z0-9_-]{0,63}$`(首段=domain),支持多级记忆名(`demo.run.status_query`);方案原文的单段记忆名正则不含层级,属笔误级修正。
3. **计分舍入**:得分固定四位小数舍入,避免浮点噪声进入结果与 margin 比较(方案未规定,实现补齐并写入 README)。
4. **intentctl eval 的守卫用例**:CLI 只加载 JSON 配置,宿主 Go 内置守卫的用例只能在宿主测试/conformance 中验证(README 已说明)。模块自带评测集的 guard 用例由 Go 测试路径覆盖。
5. **Result 顶层 Slots**:方案便捷字段未实现,槽位在 `Top.Slots`;语义等价,避免双份状态。
## 验证证据
| 验收条件(方案验收表) | 结果 |
|---|---|
| 同输入+同快照→同结果(含 Explain) | 通过:`TestRecognizeDeterministic` 深比较 |
| guard 保护域不可被 overlay 禁用/覆盖 | 通过:`TestBuildFSProtectedDomain`(两种尝试均 `intent.spec_protected`) |
| 配置错误定位到字段与来源 | 通过:`TestValidateSpecRejections`、`TestLoadOverlaysUnknownField`、`TestBuildFSOverrideMustIncreaseVersion`(fields 含 file/source/field) |
| 合并语义(新增/覆盖/禁用/版本递增/位置保持) | 通过:`TestBuildFSMergeSemantics`、`TestLoadOverlaysFileOrder` |
| 无匹配=Fallback 非错误 | 通过:`TestRecognizeExcludedTermFallsBack`、`TestRecognizeEmptyInputFallsBack` |
| Reload 失败保留旧快照;并发识别不受影响 | 通过:`TestReloadSwapsAndKeepsOldOnFailure`、`TestConcurrentRecognizeAndReload`(-race) |
| 硬负例语料(否定写+只读、排除词、近义混淆) | 通过:`TestEvalCorpus`(10 用例,含 `negated_write_readonly`) |
| 错误码与上游 intent 域逐个相等 | 通过:`catalogcheck.TestIntentCodesAlignWithCatalog` |
| 零第三方依赖 | 通过:`go list -m all` 仅 `error-code-system` / `logging-system`(本地 replace) |
| 消费方可按生态范式接入 | 通过:`examples.TestEndToEnd`(embed 配置 → 守卫/匹配/槽位/元数据/诊断) |
| intentctl 只读诊断 | 通过:`cmd/intentctl` 三个命令单测 + 手工冒烟(lint/explain/eval 输出见会话) |
## 命令与结果(2026-09-29,本机 go1.27.1 linux/amd64)
```bash
GOWORK=off go vet ./... # 通过,无输出
GOWORK=off go test -count=1 ./... # ok:catalogcheck / cmd/intentctl / conformance / examples / intent
GOWORK=off go test -count=1 -race ./... # 同上,全部 ok
GOWORK=off go list -m all # agent-intent + 两个生态子模块
scripts/submodule-consistency.sh # 见提交时输出(三个 ok)
```
## 组合与回退
- 本仓 pin:development-guidelines `26b562d2`、error-code-system `28fa06ea`(含 intent 域)、logging-system `2c5716fd`;与 Agent-Squad / Agent-Native 当前组合一致(ECS 为追加式前进)。
- 消费方回退:普通 PR 恢复旧 gitlink 即回退本模块;ECS 的 `intent` 域为纯追加,回退本仓不需要回退 ECS。
+71
View File
@@ -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)
}
}
+28
View File
@@ -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"
}
]
}
+13
View File
@@ -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)
}
+86
View File
@@ -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
}
+78
View File
@@ -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)
}
+19
View File
@@ -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
}
+80
View File
@@ -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
}
+102
View File
@@ -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)
}
}
+57
View File
@@ -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)
}
}
+208
View File
@@ -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 })
}
+330
View File
@@ -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)
}
}
+657
View File
@@ -0,0 +1,657 @@
package intent
import (
"context"
"fmt"
"io/fs"
"math"
"regexp"
"sort"
"strings"
"sync"
"sync/atomic"
"logging-system/logsys"
)
// Options 注册表与识别器的共享选项。零值字段在构建时填入默认值;
// GuardDomains / ProtectedDomains 决定快照的阶段归属与 overlay 保护范围。
type Options struct {
// ProtectedDomains 保护域:overlay 覆盖或禁用这些域内的 spec 会整批失败(fail-closed)。
ProtectedDomains []string
// GuardDomains 守卫域:这些域内的 spec 在 Stage 0 演练,命中即短路返回 GuardHit。
GuardDomains []string
// TopN 结果候选数上限(默认 5)。
TopN int
// AmbiguityMargin 前两名分差小于该值判 Ambiguous(默认 0.1)。
AmbiguityMargin float64
// MaxInputLen 输入字节长度上限(默认 4096),超过返回 InvalidArgument。
MaxInputLen int
// MaxSpecs 快照内 spec 总数上限,含禁用(默认 2048)。
MaxSpecs int
// MaxPatternsPerSpec 单 spec 正则数量上限(默认 32)。
MaxPatternsPerSpec int
// MaxSlotsPerSpec 单 spec 槽位数量上限(默认 16)。
MaxSlotsPerSpec int
// Log 可选日志器;nil 时使用 Nop。识别日志不记录原始输入。
Log *logsys.Logger
}
// Options 字段默认值。
const (
DefaultTopN = 5
DefaultAmbiguityMargin = 0.1
DefaultMaxInputLen = 4096
DefaultMaxSpecs = 2048
DefaultMaxPatterns = 32
DefaultMaxSlots = 16
)
func (o Options) normalized() Options {
n := o
if n.TopN <= 0 {
n.TopN = DefaultTopN
}
if n.AmbiguityMargin <= 0 {
n.AmbiguityMargin = DefaultAmbiguityMargin
}
if n.MaxInputLen <= 0 {
n.MaxInputLen = DefaultMaxInputLen
}
if n.MaxSpecs <= 0 {
n.MaxSpecs = DefaultMaxSpecs
}
if n.MaxPatternsPerSpec <= 0 {
n.MaxPatternsPerSpec = DefaultMaxPatterns
}
if n.MaxSlotsPerSpec <= 0 {
n.MaxSlotsPerSpec = DefaultMaxSlots
}
if n.Log == nil {
n.Log = logsys.Nop()
}
return n
}
func (o Options) protectedSet() map[string]bool {
return domainSet(o.ProtectedDomains)
}
func (o Options) guardSet() map[string]bool {
return domainSet(o.GuardDomains)
}
func domainSet(domains []string) map[string]bool {
m := make(map[string]bool, len(domains))
for _, d := range domains {
m[d] = true
}
return m
}
func (o Options) limits() limits {
return limits{maxSpecs: o.MaxSpecs, maxPatternsPerSpec: o.MaxPatternsPerSpec, maxSlotsPerSpec: o.MaxSlotsPerSpec}
}
// ---- 编译产物 ----
type compiledSlot struct {
key string
required bool
re *regexp.Regexp
group int
}
type compiledSpec struct {
spec IntentSpec
termsAny []string // 已按 case_sensitive 折叠
termsAll []string
termsEx []string
patterns []*regexp.Regexp
slots []compiledSlot
guard bool
index int // 合并序,确定性次序裁决用
base float64
bonus float64
threshold float64
}
// matchOutcome 单个 spec 对一次输入的评估结果。
type matchOutcome struct {
score float64
passed bool
hitTerms []string
hitPat []string
reject string // 非 passed 时的排除原因
}
// evaluate 按文档化的计分规则评估。input 为原文,folded 为小写折叠后文本。
func (cs *compiledSpec) evaluate(input, folded string) matchOutcome {
hay := input
if !cs.spec.Matcher.CaseSensitive {
hay = folded
}
for _, t := range cs.termsEx {
if strings.Contains(hay, t) {
return matchOutcome{reject: "excluded_term:" + t}
}
}
for _, t := range cs.termsAll {
if !strings.Contains(hay, t) {
return matchOutcome{reject: "missing_includes_all:" + t}
}
}
var hitTerms, hitPat []string
positive := false
for _, t := range cs.termsAny {
if strings.Contains(hay, t) {
positive = true
hitTerms = append(hitTerms, t)
}
}
for i, p := range cs.patterns {
if p.MatchString(input) {
positive = true
hitPat = append(hitPat, cs.spec.Matcher.Patterns[i])
}
}
if !positive && (len(cs.termsAny) > 0 || len(cs.patterns) > 0) {
return matchOutcome{reject: "no_positive_match"}
}
score := cs.base
if len(hitPat) > 0 {
score += cs.bonus
}
if score > 1 {
score = 1
}
// 固定精度舍入:避免浮点噪声进入结果与 margin 比较。
return matchOutcome{score: math.Round(score*10000) / 10000, passed: score >= cs.threshold, hitTerms: hitTerms, hitPat: hitPat}
}
// extractSlots 从原文抽取槽位;返回缺失的必填槽位键。
func (cs *compiledSpec) extractSlots(input string) (map[string]string, []string) {
if len(cs.slots) == 0 {
return nil, nil
}
slots := make(map[string]string, len(cs.slots))
var missing []string
for _, s := range cs.slots {
m := s.re.FindStringSubmatch(input)
if m != nil && s.group < len(m) && m[s.group] != "" {
slots[s.key] = m[s.group]
continue
}
if s.required {
missing = append(missing, s.key)
}
}
return slots, missing
}
func compileSpec(spec IntentSpec, guard bool, index int) (*compiledSpec, error) {
m := spec.Matcher
fold := func(terms []string) []string {
out := make([]string, len(terms))
for i, t := range terms {
if m.CaseSensitive {
out[i] = t
} else {
out[i] = strings.ToLower(t)
}
}
return out
}
cs := &compiledSpec{
spec: spec,
termsAny: fold(m.IncludesAny),
termsAll: fold(m.IncludesAll),
termsEx: fold(m.ExcludesAny),
patterns: make([]*regexp.Regexp, len(m.Patterns)),
guard: guard,
index: index,
base: spec.Confidence.Base,
bonus: spec.Confidence.PatternBonus,
threshold: spec.Confidence.Threshold,
}
if cs.base == 0 {
cs.base = DefaultBase
}
if cs.bonus == 0 {
cs.bonus = DefaultPatternBonus
}
if cs.threshold == 0 {
cs.threshold = DefaultThreshold
}
for i, p := range m.Patterns {
re, err := regexp.Compile(p)
if err != nil {
return nil, newError(InvalidSpec, "pattern 编译失败", "field", fmt.Sprintf("matcher.patterns[%d]", i), "id", spec.ID, "error", err.Error())
}
cs.patterns[i] = re
}
for _, s := range spec.Slots {
re, err := regexp.Compile(s.Pattern)
if err != nil {
return nil, newError(InvalidSpec, "slot pattern 编译失败", "field", "slots", "key", s.Key, "id", spec.ID, "error", err.Error())
}
cs.slots = append(cs.slots, compiledSlot{key: s.Key, required: s.Required, re: re, group: effectiveGroup(s.Group)})
}
return cs, nil
}
// ---- 快照 ----
// Snapshot 是合并产物的不可变快照:全部 matcher 已编译,识别全程无锁读。
// 同一快照对同样输入给出同样结果;revision 单调递增。
type Snapshot struct {
rev int64
margin float64
topN int
maxInput int
specs []*compiledSpec // 仅启用 spec,按合并序
byID map[string]int
guardIdx []int
disabled []string // 已禁用 id(overlay disable 与 spec.disabled 两个入口)
sources map[string]string
total int // 含禁用
}
// Revision 返回快照版本号,随每次成功 Reload 递增。
func (s *Snapshot) Revision() int64 { return s.rev }
// SpecIDs 返回启用中的 spec id,按合并序(确定性次序裁决的注册序)。
func (s *Snapshot) SpecIDs() []string {
ids := make([]string, len(s.specs))
for i, cs := range s.specs {
ids[i] = cs.spec.ID
}
return ids
}
// DisabledIDs 返回被禁用的 spec id,字典序。
func (s *Snapshot) DisabledIDs() []string {
out := make([]string, len(s.disabled))
copy(out, s.disabled)
sort.Strings(out)
return out
}
// Source 返回 spec 的来源描述("builtin" 或 "<file>#intents[i]")。
func (s *Snapshot) Source(id string) (string, bool) {
src, ok := s.sources[id]
return src, ok
}
// SpecByID 返回指定 id 的启用 spec 定义副本。不存在或已禁用时第二个返回值为 false。
func (s *Snapshot) SpecByID(id string) (IntentSpec, bool) {
if idx, ok := s.byID[id]; ok {
return s.specs[idx].spec, true
}
return IntentSpec{}, false
}
// Conflict 诊断信息:两个启用 spec 共享同一 includes_any 词条。
// 共享不一定是错误(计分与确定性次序会裁决),用于配置评审提示。
type Conflict struct {
Kind string
A string
B string
Term string
}
// Conflicts 报告非守卫 spec 之间共享的 includes_any 词条,确定性排序。
func (s *Snapshot) Conflicts() []Conflict {
termOwners := make(map[string][]string)
for _, cs := range s.specs {
if cs.guard {
continue
}
for _, t := range cs.spec.Matcher.IncludesAny {
termOwners[t] = append(termOwners[t], cs.spec.ID)
}
}
terms := make([]string, 0, len(termOwners))
for t, owners := range termOwners {
if len(owners) > 1 {
terms = append(terms, t)
}
}
sort.Strings(terms)
var out []Conflict
for _, t := range terms {
owners := append([]string(nil), termOwners[t]...)
sort.Strings(owners)
for i := 0; i < len(owners); i++ {
for j := i + 1; j < len(owners); j++ {
out = append(out, Conflict{Kind: "shared_term", A: owners[i], B: owners[j], Term: t})
}
}
}
return out
}
// specHit 规则阶段的内部命中记录。
type specHit struct {
cs *compiledSpec
score float64
terms []string
pats []string
}
func lessHit(a, b specHit) bool {
if a.score != b.score {
return a.score > b.score
}
if a.cs.spec.Priority != b.cs.spec.Priority {
return a.cs.spec.Priority > b.cs.spec.Priority
}
return a.cs.index < b.cs.index
}
func sortHits(hits []specHit) {
sort.SliceStable(hits, func(i, j int) bool { return lessHit(hits[i], hits[j]) })
}
// evalGuard 评估守卫域 spec,返回过阈命中(已排序)。
func (s *Snapshot) evalGuard(input, folded string) []specHit {
var hits []specHit
for _, idx := range s.guardIdx {
cs := s.specs[idx]
if o := cs.evaluate(input, folded); o.passed {
hits = append(hits, specHit{cs: cs, score: o.score, terms: o.hitTerms, pats: o.hitPat})
}
}
sortHits(hits)
return hits
}
// evalIDs 按给定 id 列表评估(挂起预期阶段用),返回过阈命中(已排序)。
func (s *Snapshot) evalIDs(ids []string, input, folded string) []specHit {
var hits []specHit
for _, id := range ids {
idx, ok := s.byID[id]
if !ok {
continue
}
cs := s.specs[idx]
if o := cs.evaluate(input, folded); o.passed {
hits = append(hits, specHit{cs: cs, score: o.score, terms: o.hitTerms, pats: o.hitPat})
}
}
sortHits(hits)
return hits
}
// evalRules 评估全部非守卫 spec,返回过阈命中(已排序)。
func (s *Snapshot) evalRules(input, folded string) []specHit {
var hits []specHit
for _, cs := range s.specs {
if cs.guard {
continue
}
if o := cs.evaluate(input, folded); o.passed {
hits = append(hits, specHit{cs: cs, score: o.score, terms: o.hitTerms, pats: o.hitPat})
}
}
sortHits(hits)
return hits
}
func (h specHit) reason() string {
var parts []string
if len(h.terms) > 0 {
parts = append(parts, "terms="+strings.Join(h.terms, ","))
}
if len(h.pats) > 0 {
parts = append(parts, "patterns="+strings.Join(h.pats, ","))
}
return strings.Join(parts, " ")
}
func (h specHit) candidate(input string, withSlots bool) Candidate {
cs := h.cs
c := Candidate{
ID: cs.spec.ID,
Domain: cs.spec.Domain,
Version: cs.spec.Version,
Confidence: h.score,
Priority: cs.spec.Priority,
Risk: cs.spec.Risk,
RequiresConfirm: cs.spec.RequiresConfirm,
Handler: cs.spec.Handler,
Reason: h.reason(),
}
if c.Risk == "" {
c.Risk = RiskLow
}
if len(cs.spec.Metadata) > 0 {
c.Metadata = make(map[string]string, len(cs.spec.Metadata))
for k, v := range cs.spec.Metadata {
c.Metadata[k] = v
}
}
if withSlots {
c.Slots, c.MissingRequiredSlots = cs.extractSlots(input)
}
return c
}
func candidatesOf(hits []specHit, input string, topSlots bool) []Candidate {
out := make([]Candidate, 0, len(hits))
for i, h := range hits {
out = append(out, h.candidate(input, topSlots && i == 0))
}
return out
}
// ---- 构建与合并 ----
// RegistryBuilder 收集 Go 内置基线 spec 并构建 Registry。
type RegistryBuilder struct {
opts Options
builtin []IntentSpec
seen map[string]bool
}
// NewRegistryBuilder 创建构建器。opts 零值字段填默认。
func NewRegistryBuilder(opts Options) *RegistryBuilder {
return &RegistryBuilder{opts: opts.normalized(), seen: make(map[string]bool)}
}
// Register 登记内置基线 spec,立即校验;重复 id 报 InvalidSpec。
func (b *RegistryBuilder) Register(specs ...IntentSpec) error {
lim := b.opts.limits()
for _, spec := range specs {
if b.seen[spec.ID] {
return newError(InvalidSpec, "内置 spec id 重复", "id", spec.ID)
}
if err := validateSpec(spec, lim); err != nil {
return err
}
b.seen[spec.ID] = true
b.builtin = append(b.builtin, spec)
}
return nil
}
// Build 仅用内置基线构建(rev 1)。此后 Reload 会报错(无配置来源)。
func (b *RegistryBuilder) Build() (*Registry, error) {
return b.buildWithSource(nil, nil)
}
// BuildFS 用内置基线 + JSON overlay 来源构建,并记住来源供 Reload 重读。
// roots 按给定顺序应用;目录 root 内按文件名字典序加载 *.json。
func (b *RegistryBuilder) BuildFS(ctx context.Context, fsys fs.FS, roots ...string) (*Registry, error) {
if fsys == nil {
return nil, newError(InvalidArgument, "fsys 为 nil")
}
if len(roots) == 0 {
return nil, newError(InvalidArgument, "至少提供一个配置 root")
}
return b.buildWithSource(fsys, roots)
}
func (b *RegistryBuilder) buildWithSource(fsys fs.FS, roots []string) (*Registry, error) {
var entries []overlayEntry
if fsys != nil {
var err error
entries, err = loadOverlays(fsys, roots)
if err != nil {
return nil, err
}
}
snap, err := buildSnapshot(b.opts, b.builtin, entries, 1)
if err != nil {
return nil, err
}
r := &Registry{
opts: b.opts,
builtin: append([]IntentSpec(nil), b.builtin...),
fsys: fsys,
roots: append([]string(nil), roots...),
hasSource: fsys != nil,
log: b.opts.Log,
}
r.snap.Store(snap)
return r, nil
}
// Registry 持有当前快照与配置来源,支持原子 Reload。
type Registry struct {
opts Options
builtin []IntentSpec
fsys fs.FS
roots []string
hasSource bool
mu sync.Mutex // 串行化 Reload
snap atomic.Pointer[Snapshot]
log *logsys.Logger
}
// Snapshot 返回当前生效快照。永不为 nil。
func (r *Registry) Snapshot() *Snapshot { return r.snap.Load() }
// Options 返回生效选项(已填默认值)。
func (r *Registry) Options() Options { return r.opts }
// Reload 重读配置来源并构建新快照;成功后原子替换并返回新快照。
// 任一步失败保留旧快照,revision 不变,返回带来源与原因的错误。
func (r *Registry) Reload(ctx context.Context) (*Snapshot, error) {
r.mu.Lock()
defer r.mu.Unlock()
old := r.snap.Load()
if !r.hasSource {
err := newError(LoadFailed, "没有登记配置来源;用 BuildFS 构建后才能 Reload", "revision", old.rev)
r.log.Module("intent").Error("intent config rejected", err)
return nil, err
}
entries, err := loadOverlays(r.fsys, r.roots)
if err != nil {
r.log.Module("intent").Error("intent config rejected", err)
return nil, err
}
ns, err := buildSnapshot(r.opts, r.builtin, entries, old.rev+1)
if err != nil {
r.log.Module("intent").Error("intent config rejected", err)
return nil, err
}
r.snap.Store(ns)
r.log.Module("intent").Info("intent snapshot reloaded",
"revision", ns.rev, "specs", len(ns.specs), "disabled", len(ns.disabled))
return ns, nil
}
// buildSnapshot 执行双源合并与编译。任一错误整批失败,不产生半套快照。
func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry, rev int64) (*Snapshot, error) {
opts = opts.normalized()
protected := opts.protectedSet()
guards := opts.guardSet()
lim := opts.limits()
ordered := append([]IntentSpec(nil), baseline...)
pos := make(map[string]int, len(ordered))
sources := make(map[string]string, len(ordered))
for i, spec := range ordered {
pos[spec.ID] = i
sources[spec.ID] = "builtin"
}
var disabledList []string
for _, e := range entries {
if e.disable {
idx, ok := pos[e.id]
if !ok {
return nil, newError(InvalidSpec, "disable 目标不存在", "id", e.id, "source", e.source)
}
if protected[ordered[idx].Domain] {
return nil, newError(SpecProtected, "overlay 试图禁用保护域 spec",
"id", e.id, "domain", ordered[idx].Domain, "source", e.source)
}
ordered = append(ordered[:idx], ordered[idx+1:]...)
for id, p := range pos {
if p > idx {
pos[id] = p - 1
}
}
delete(pos, e.id)
disabledList = append(disabledList, e.id)
sources[e.id] = e.source + " (disabled)"
continue
}
spec := e.spec
if err := validateSpec(spec, lim); err != nil {
return nil, err.(*Error).with("source", e.source)
}
if idx, ok := pos[spec.ID]; ok {
old := ordered[idx]
if protected[old.Domain] {
return nil, newError(SpecProtected, "overlay 试图覆盖保护域 spec",
"id", spec.ID, "domain", old.Domain, "source", e.source)
}
if spec.Version <= old.Version {
return nil, newError(InvalidSpec, "覆盖必须递增 version",
"id", spec.ID, "old_version", old.Version, "new_version", spec.Version, "source", e.source)
}
ordered[idx] = spec
} else {
ordered = append(ordered, spec)
pos[spec.ID] = len(ordered) - 1
}
sources[spec.ID] = e.source
}
total := len(ordered) + len(disabledList)
if total > lim.maxSpecs {
return nil, newError(InvalidSpec, "spec 总数超过上限", "total", total, "max", lim.maxSpecs)
}
snap := &Snapshot{
rev: rev,
margin: opts.AmbiguityMargin,
topN: opts.TopN,
maxInput: opts.MaxInputLen,
byID: make(map[string]int, len(ordered)),
disabled: disabledList,
sources: sources,
total: total,
}
nextIndex := 0
for _, spec := range ordered {
if spec.Disabled {
snap.disabled = append(snap.disabled, spec.ID)
continue
}
cs, err := compileSpec(spec, guards[spec.Domain], nextIndex)
if err != nil {
return nil, err
}
snap.specs = append(snap.specs, cs)
snap.byID[spec.ID] = len(snap.specs) - 1
if cs.guard {
snap.guardIdx = append(snap.guardIdx, len(snap.specs)-1)
}
nextIndex++
}
return snap, nil
}
+256
View File
@@ -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)
}
}
+80
View File
@@ -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
}
+51
View File
@@ -0,0 +1,51 @@
package intent
import "strings"
// Segmenter 多意图分句扩展点:把一段输入拆成可独立识别的子句。
// 模块不自动调用;宿主在需要多意图处理时先分句、再逐句识别。
type Segmenter interface {
Segment(input string) []string
}
// RuleSegmenter 规则分句:按分隔符切分。确定性、无 I/O。
type RuleSegmenter struct {
// Delims 分隔符 rune 集合。零值使用默认中英文句读与换行。
Delims []rune
}
// NewRuleSegmenter 返回使用默认分隔符(,。;!?、,.!?; 与换行)的分句器。
func NewRuleSegmenter() *RuleSegmenter {
return &RuleSegmenter{Delims: []rune(",。;!?、,.!?;\n")}
}
// Segment 按分隔符切分并去除空白子句;无有效子句时原样返回单元素。
func (s *RuleSegmenter) Segment(input string) []string {
delimSet := s.Delims
if len(delimSet) == 0 {
delimSet = NewRuleSegmenter().Delims
}
set := make(map[rune]bool, len(delimSet))
for _, r := range delimSet {
set[r] = true
}
var out []string
var cur strings.Builder
for _, r := range input {
if set[r] {
if seg := strings.TrimSpace(cur.String()); seg != "" {
out = append(out, seg)
}
cur.Reset()
continue
}
cur.WriteRune(r)
}
if seg := strings.TrimSpace(cur.String()); seg != "" {
out = append(out, seg)
}
if len(out) == 0 {
return []string{input}
}
return out
}
+180
View File
@@ -0,0 +1,180 @@
package intent
import (
"fmt"
"regexp"
"strings"
)
// 风险等级。透传给宿主做授权与确认判断,本模块不解释。
const (
RiskLow = "low"
RiskMedium = "medium"
RiskHigh = "high"
)
// ConfidenceSpec 各字段为零值时使用包默认值。
const (
DefaultBase = 0.7
DefaultPatternBonus = 0.2
DefaultThreshold = 0.6
)
// MatchSpec 声明式匹配规则。
// 计分:score = base + (任一 pattern 命中 ? pattern_bonus : 0),上限 1.0。
// 命中条件:excludes_any 全不命中 && includes_all 全命中 &&(includes_any 至少一词命中
// 或任一 pattern 命中;仅配置 includes_all 时以全命中为准)。
// case_sensitive 只作用于 includes/excludes 词表;pattern 自行用 (?i) 表达大小写。
type MatchSpec struct {
IncludesAny []string `json:"includes_any,omitempty"`
IncludesAll []string `json:"includes_all,omitempty"`
ExcludesAny []string `json:"excludes_any,omitempty"`
Patterns []string `json:"patterns,omitempty"`
CaseSensitive bool `json:"case_sensitive,omitempty"`
}
// SlotSpec 槽位抽取规则。group 未填默认取 1(第一个捕获组)。
type SlotSpec struct {
Key string `json:"key"`
Required bool `json:"required,omitempty"`
Pattern string `json:"pattern"`
Group int `json:"group,omitempty"`
}
// ConfidenceSpec 置信度参数。Base 初始分;PatternBonus 模式命中加成;
// Threshold 为该 spec 自身的过阈线,score ≥ threshold 才进入候选。
type ConfidenceSpec struct {
Base float64 `json:"base,omitempty"`
PatternBonus float64 `json:"pattern_bonus,omitempty"`
Threshold float64 `json:"threshold,omitempty"`
}
// IntentSpec 意图定义,Go 注册与 JSON overlay 共用同一结构。
// Handler 与 Metadata 由宿主解释(路由通道、动作键等),本模块只透传。
type IntentSpec struct {
ID string `json:"id"`
Domain string `json:"domain"`
Version int `json:"version"`
Description string `json:"description,omitempty"`
Priority int `json:"priority,omitempty"`
Disabled bool `json:"disabled,omitempty"`
Matcher MatchSpec `json:"matcher"`
Slots []SlotSpec `json:"slots,omitempty"`
Confidence ConfidenceSpec `json:"confidence,omitempty"`
Risk string `json:"risk,omitempty"`
RequiresConfirm bool `json:"requires_confirm,omitempty"`
Handler string `json:"handler,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
var (
domainRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,31}$`)
segmentRe = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,63}$`)
slotKeyRe = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
)
// limits 在 Options 中声明,防止配置膨胀拖垮识别与构建。
type limits struct {
maxSpecs int
maxPatternsPerSpec int
maxSlotsPerSpec int
}
// validateSpec 校验单条 spec 的结构合法性,错误可定位到字段。limits 为零值时跳过数量上限。
func validateSpec(spec IntentSpec, lim limits) error {
if spec.Domain == "" {
return newError(InvalidSpec, "domain 为空", "field", "domain")
}
if !domainRe.MatchString(spec.Domain) {
return newError(InvalidSpec, "domain 格式不合法(^[a-z][a-z0-9-]{1,31}$)", "field", "domain", "domain", spec.Domain)
}
if spec.ID == "" {
return newError(InvalidSpec, "id 为空", "field", "id")
}
// id 形如 <domain>.<mnemonic>,mnemonic 可继续分级(demo.run.status_query)。
segments := strings.Split(spec.ID, ".")
if len(segments) < 2 {
return newError(InvalidSpec, "id 至少包含 domain 与记忆名两段", "field", "id", "id", spec.ID)
}
if segments[0] != spec.Domain {
return newError(InvalidSpec, "id 首段必须等于 domain", "field", "id", "id", spec.ID, "domain", spec.Domain)
}
for _, seg := range segments[1:] {
if !segmentRe.MatchString(seg) {
return newError(InvalidSpec, "id 段格式不合法(^[a-z][a-z0-9_-]{0,63}$)", "field", "id", "id", spec.ID)
}
}
if spec.Version < 1 {
return newError(InvalidSpec, "version 必须 ≥ 1", "field", "version", "version", spec.Version)
}
switch spec.Risk {
case "", RiskLow, RiskMedium, RiskHigh:
default:
return newError(InvalidSpec, "risk 只能是 low/medium/high 或留空", "field", "risk", "risk", spec.Risk)
}
m := spec.Matcher
for _, terms := range []struct {
name string
value []string
}{{"includes_any", m.IncludesAny}, {"includes_all", m.IncludesAll}, {"excludes_any", m.ExcludesAny}} {
for _, t := range terms.value {
if strings.TrimSpace(t) == "" {
return newError(InvalidSpec, "matcher 词条为空", "field", "matcher."+terms.name)
}
}
}
if len(m.IncludesAny) == 0 && len(m.IncludesAll) == 0 && len(m.Patterns) == 0 {
return newError(InvalidSpec, "matcher 缺少正向条件(includes_any / includes_all / patterns 至少一项)", "field", "matcher", "id", spec.ID)
}
if lim.maxPatternsPerSpec > 0 && len(m.Patterns) > lim.maxPatternsPerSpec {
return newError(InvalidSpec, "patterns 数量超过上限", "field", "matcher.patterns", "count", len(m.Patterns), "max", lim.maxPatternsPerSpec, "id", spec.ID)
}
for i, p := range m.Patterns {
if _, err := regexp.Compile(p); err != nil {
return newError(InvalidSpec, "pattern 编译失败", "field", fmt.Sprintf("matcher.patterns[%d]", i), "pattern", p, "id", spec.ID, "error", err.Error())
}
}
if lim.maxSlotsPerSpec > 0 && len(spec.Slots) > lim.maxSlotsPerSpec {
return newError(InvalidSpec, "slots 数量超过上限", "field", "slots", "count", len(spec.Slots), "max", lim.maxSlotsPerSpec, "id", spec.ID)
}
for i, s := range spec.Slots {
if !slotKeyRe.MatchString(s.Key) {
return newError(InvalidSpec, "slot key 格式不合法(^[a-z][a-z0-9_]{0,63}$)", "field", fmt.Sprintf("slots[%d].key", i), "key", s.Key)
}
re, err := regexp.Compile(s.Pattern)
if err != nil {
return newError(InvalidSpec, "slot pattern 编译失败", "field", fmt.Sprintf("slots[%d].pattern", i), "key", s.Key, "error", err.Error())
}
// group 约定:未填默认取 1(第一个捕获组);需整段匹配时在 pattern 里写捕获组即可。
g := effectiveGroup(s.Group)
if g < 1 || g > re.NumSubexp() {
return newError(InvalidSpec, "slot group 超出捕获组范围", "field", fmt.Sprintf("slots[%d].group", i), "key", s.Key, "group", s.Group, "groups", re.NumSubexp())
}
}
c := spec.Confidence
if c.Base < 0 || c.Base > 1 {
return newError(InvalidSpec, "confidence.base 须在 (0,1],0/留空取默认", "field", "confidence.base", "id", spec.ID)
}
if c.PatternBonus < 0 || c.PatternBonus >= 1 {
return newError(InvalidSpec, "confidence.pattern_bonus 须在 [0,1),留空取默认", "field", "confidence.pattern_bonus", "id", spec.ID)
}
if c.Threshold < 0 || c.Threshold > 1 {
return newError(InvalidSpec, "confidence.threshold 须在 (0,1],0/留空取默认", "field", "confidence.threshold", "id", spec.ID)
}
for k := range spec.Metadata {
if strings.TrimSpace(k) == "" {
return newError(InvalidSpec, "metadata 键为空", "field", "metadata", "id", spec.ID)
}
}
return nil
}
// effectiveGroup 槽位 group 的缺省规则:未填(0)默认取 1。
func effectiveGroup(g int) int {
if g == 0 {
return 1
}
return g
}
+116
View File
@@ -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")
}
}
+14
View File
@@ -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"}
]
}
+65
View File
@@ -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": []
}