diff --git a/README.md b/README.md index 5d9b4f4..72e51ba 100644 --- a/README.md +++ b/README.md @@ -102,19 +102,24 @@ Go 与 JSON 共用同一结构;JSON overlay 文档形如: | `id` | 全局唯一;首段必须等于 `domain`,各段 `^[a-z][a-z0-9_-]{0,63}$`,可多级(`demo.run.status_query`) | | `version` | ≥1;overlay 覆盖同 id 时必须递增 | | `matcher` | 见上文计分规则;至少一个正向条件;`case_sensitive` 只作用于词表,pattern 用 `(?i)` | -| `slots` | `group` 默认 1;`required` 缺失记录到 `MissingRequiredSlots` | +| `slots` | `group` 默认 1;`required` 未捕获记录到 `MissingRequiredSlots`;`label`/`question` 供 HITL 提问与目录展示;`enum`/`format`/`normalize`(trim\|lower\|upper)/`validator`(`RegisterSlotValidator` 注册)构成确定性校验链,非法值进 `Top.InvalidSlots`(与"缺失"区分) | | `confidence` | 缺省 `base=0.7 / pattern_bonus=0.2 / threshold=0.6` | | `risk` / `requires_confirm` / `handler` / `metadata` | 模块不解释,透传给宿主做授权、确认与路由 | | `disabled` | 定义但停用(灰度暂存) | 合并语义:Go 内置为基线;目录 root 内 `*.json` 按文件名字典序、roots 按给定顺序应用;同 id 覆盖需版本递增;`disable` 按 id 禁用(保护域整批失败);未知字段拒绝并定位到文件。数量上限见 `Options`(spec 总数、单 spec 正则/槽位数、输入长度),防止配置膨胀。 +## 能力目录(Catalog) + +`Snapshot.Catalog()/CatalogMarkdown()` 把启用的非守卫 spec 渲染为规划者/领导 Agent 可消费的声明式目录:有哪些可直接调用的能力、必填槽位与提问建议、风险与确认要求。用于"目录驱动的编排"——规划者据此把子任务短路给系统功能、按槽位 question 生成定向 HITL(Agent-Squad 已按此接入,见其 `squadintent` 与 `docs/records`)。 + ## 诊断(intentctl) ```bash go run ./cmd/intentctl lint -root configs/intents # 校验 + 冲突提示 go run ./cmd/intentctl explain -root configs/intents "查询任务进度" # 分支/候选/槽位/裁决轨迹 go run ./cmd/intentctl eval -root configs/intents evals/corpus.json +go run ./cmd/intentctl catalog -root configs/intents # 能力目录(注入规划者提示词) ``` 评测集格式见 `intent/testdata/corpus.json`(`name / input / expect_branch / expect_top / expect_slots / expect_missing_slots`)。评测集放在配置目录之外。注意:宿主以 Go 内置注册守卫时,守卫用例只能在宿主自己的测试(或 conformance.Run)中验证;intentctl 只加载 JSON 配置。 diff --git a/cmd/intentctl/main.go b/cmd/intentctl/main.go index 67a511a..b6480e0 100644 --- a/cmd/intentctl/main.go +++ b/cmd/intentctl/main.go @@ -33,6 +33,8 @@ func run(args []string) error { err = cmdExplain(args[1:]) case "eval": err = cmdEval(args[1:]) + case "catalog": + err = cmdCatalog(args[1:]) default: err = usage() } @@ -40,7 +42,7 @@ func run(args []string) error { } func usage() error { - return fmt.Errorf("用法:intentctl ...\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] ") + 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] \n catalog -root [-protected a,b] [-guard a,b] 输出能力目录(注入规划者/领导提示词用)") } type commonOpts struct { @@ -149,6 +151,13 @@ func cmdExplain(args []string) error { if len(res.Top.MissingRequiredSlots) > 0 { fmt.Printf(" 缺失必填槽位:%s\n", strings.Join(res.Top.MissingRequiredSlots, ",")) } + if len(res.Top.InvalidSlots) > 0 { + fmt.Printf(" 非法槽位:") + for _, k := range sortedKeys(res.Top.InvalidSlots) { + fmt.Printf("%s(%s) ", k, res.Top.InvalidSlots[k]) + } + fmt.Println() + } } if len(res.Candidates) > 0 { fmt.Printf("候选(%d):\n", len(res.Candidates)) @@ -180,6 +189,29 @@ func printStage(name string, scored []intent.ScoredSpec, rejected []intent.Rejec } } +func cmdCatalog(args []string) error { + fs := flag.NewFlagSet("catalog", flag.ContinueOnError) + c := parseCommon(fs) + if err := fs.Parse(args); err != nil { + return err + } + reg, err := buildRegistry(c) + if err != nil { + return fmt.Errorf("配置校验失败:%w", err) + } + fmt.Print(reg.Snapshot().CatalogMarkdown()) + return nil +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + // evalCase 与 intent 包评测集(testdata/corpus.json)同格式。 type evalCase struct { Name string `json:"name"` diff --git a/intent/catalog.go b/intent/catalog.go new file mode 100644 index 0000000..cbd0219 --- /dev/null +++ b/intent/catalog.go @@ -0,0 +1,121 @@ +package intent + +import ( + "fmt" + "strings" +) + +// CatalogSlot 目录中的槽位视图:给规划者/领导 Agent 看的声明式信息 +// (有哪些能力、缺什么信息、建议怎么问),不暴露匹配实现细节。 +type CatalogSlot struct { + Key string `json:"key"` + Required bool `json:"required,omitempty"` + Label string `json:"label,omitempty"` + Question string `json:"question,omitempty"` + Enum []string `json:"enum,omitempty"` + Format string `json:"format,omitempty"` +} + +// CatalogSpec 目录中的单条能力。 +type CatalogSpec struct { + ID string `json:"id"` + Domain string `json:"domain,omitempty"` + Version int `json:"version,omitempty"` + Description string `json:"description,omitempty"` + Risk string `json:"risk,omitempty"` + RequiresConfirm bool `json:"requires_confirm,omitempty"` + Handler string `json:"handler,omitempty"` + Slots []CatalogSlot `json:"slots,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// Catalog 返回能力目录:全部启用的非守卫 spec,按合并序(确定性)。 +// 守卫域 spec 是拦截器而非可调用能力,不进目录;需要全量时用 SpecIDs/SpecByID。 +func (s *Snapshot) Catalog() []CatalogSpec { + out := make([]CatalogSpec, 0, len(s.specs)) + for _, cs := range s.specs { + if cs.guard { + continue + } + view := CatalogSpec{ + ID: cs.spec.ID, + Domain: cs.spec.Domain, + Version: cs.spec.Version, + Description: cs.spec.Description, + Risk: cs.spec.Risk, + RequiresConfirm: cs.spec.RequiresConfirm, + Handler: cs.spec.Handler, + Metadata: cs.spec.Metadata, + } + if view.Risk == "" { + view.Risk = RiskLow + } + for _, sl := range cs.slots { + view.Slots = append(view.Slots, CatalogSlot{ + Key: sl.key, Required: sl.required, Label: sl.label, Question: sl.question, + Enum: sl.enum, Format: specSlotFormat(cs.spec, sl.key), + }) + } + out = append(out, view) + } + return out +} + +// CatalogMarkdown 渲染注入规划者/领导提示词用的目录文本(确定性)。 +// 文案面向 LLM:说明可直接调用、必填槽位与提问建议。 +func (s *Snapshot) CatalogMarkdown() string { + var b strings.Builder + fmt.Fprintf(&b, "# 系统能力目录(意图快照 revision=%d)\n\n", s.rev) + b.WriteString("以下能力可直接调用,无需派智能体执行。子任务命中其一时,输出携带 intent_id 的步骤并填齐必填槽位;\n") + b.WriteString("必填槽位信息不足时不要编造,改为向用户澄清(优先使用各槽位的提问建议)。\n\n") + specs := s.Catalog() + if len(specs) == 0 { + b.WriteString("(目录为空:当前没有可直接调用的系统能力)\n") + return b.String() + } + for _, sp := range specs { + desc := sp.Description + if desc == "" { + desc = sp.ID + } + fmt.Fprintf(&b, "## %s — %s\n", sp.ID, desc) + confirm := "否" + if sp.RequiresConfirm { + confirm = "是" + } + fmt.Fprintf(&b, "- 风险:%s;执行前需人工确认:%s;handler:%s\n", sp.Risk, confirm, sp.Handler) + for _, sl := range sp.Slots { + req := "选填" + if sl.Required { + req = "必填" + } + label := sl.Label + if label == "" { + label = sl.Key + } + fmt.Fprintf(&b, "- 槽位 %s(%s,%s)", sl.Key, req, label) + if len(sl.Enum) > 0 { + fmt.Fprintf(&b, ";取值:%s", strings.Join(sl.Enum, " | ")) + } + if sl.Format != "" { + fmt.Fprintf(&b, ";格式:%s", sl.Format) + } + b.WriteString("\n") + if sl.Question != "" { + fmt.Fprintf(&b, " - 提问建议:%s\n", sl.Question) + } + } + b.WriteString("\n") + } + return b.String() +} + +// specSlotFormat 从原始 spec 取槽位的 Format 声明(编译产物不保留字符串正则源)。 +func specSlotFormat(spec IntentSpec, key string) string { + for _, sl := range spec.Slots { + if sl.Key == key { + return sl.Format + } + } + return "" +} diff --git a/intent/registry.go b/intent/registry.go index acfd10c..eb9b30c 100644 --- a/intent/registry.go +++ b/intent/registry.go @@ -94,13 +94,7 @@ func (o Options) limits() limits { } // ---- 编译产物 ---- - -type compiledSlot struct { - key string - required bool - re *regexp.Regexp - group int -} +// 槽位的编译产物与抽取/校验逻辑见 slots.go。 type compiledSpec struct { spec IntentSpec @@ -169,26 +163,6 @@ func (cs *compiledSpec) evaluate(input, folded string) matchOutcome { return matchOutcome{score: math.Round(score*10000) / 10000, passed: score >= cs.threshold, hitTerms: hitTerms, hitPat: hitPat} } -// extractSlots 从原文抽取槽位;返回缺失的必填槽位键。 -func (cs *compiledSpec) extractSlots(input string) (map[string]string, []string) { - if len(cs.slots) == 0 { - return nil, nil - } - slots := make(map[string]string, len(cs.slots)) - var missing []string - for _, s := range cs.slots { - m := s.re.FindStringSubmatch(input) - if m != nil && s.group < len(m) && m[s.group] != "" { - slots[s.key] = m[s.group] - continue - } - if s.required { - missing = append(missing, s.key) - } - } - return slots, missing -} - func compileSpec(spec IntentSpec, guard bool, index int) (*compiledSpec, error) { m := spec.Matcher fold := func(terms []string) []string { @@ -231,11 +205,11 @@ func compileSpec(spec IntentSpec, guard bool, index int) (*compiledSpec, error) cs.patterns[i] = re } for _, s := range spec.Slots { - re, err := regexp.Compile(s.Pattern) + cs2, err := compileSlot(s) if err != nil { - return nil, newError(InvalidSpec, "slot pattern 编译失败", "field", "slots", "key", s.Key, "id", spec.ID, "error", err.Error()) + return nil, err.(*Error).with("id", spec.ID) } - cs.slots = append(cs.slots, compiledSlot{key: s.Key, required: s.Required, re: re, group: effectiveGroup(s.Group)}) + cs.slots = append(cs.slots, cs2) } return cs, nil } @@ -432,7 +406,7 @@ func (h specHit) candidate(input string, withSlots bool) Candidate { } } if withSlots { - c.Slots, c.MissingRequiredSlots = cs.extractSlots(input) + c.Slots, c.MissingRequiredSlots, c.InvalidSlots = cs.extractSlots(input) } return c } diff --git a/intent/result.go b/intent/result.go index 48aef0a..ee55a77 100644 --- a/intent/result.go +++ b/intent/result.go @@ -32,6 +32,9 @@ type Candidate struct { Metadata map[string]string Slots map[string]string MissingRequiredSlots []string + // InvalidSlots 捕获到但未通过确定性校验的槽位(key→原因,如枚举不合法)。 + // 必填且非法只进此处(不重复进 Missing),宿主可区分"没给"与"给错了"。 + InvalidSlots map[string]string // Reason 命中解释:命中词与模式,供宿主审计与 intentctl explain 复用。 Reason string } diff --git a/intent/slots.go b/intent/slots.go new file mode 100644 index 0000000..894d529 --- /dev/null +++ b/intent/slots.go @@ -0,0 +1,148 @@ +package intent + +import ( + "regexp" + "strings" + "sync" +) + +// SlotValidator 宿主注册的槽位值校验器。必须纯函数、确定性、无 I/O。 +type SlotValidator func(value string) error + +var slotValidators sync.Map // name → SlotValidator + +// RegisterSlotValidator 注册具名校验器;spec 通过 SlotSpec.Validator 引用。 +// 必须在快照构建前注册(构建期会校验名字存在)。 +func RegisterSlotValidator(name string, fn SlotValidator) { + slotValidators.Store(name, fn) +} + +// LookupSlotValidator 按名查找已注册校验器。 +func LookupSlotValidator(name string) (SlotValidator, bool) { + fn, ok := slotValidators.Load(name) + if !ok { + return nil, false + } + v, ok := fn.(SlotValidator) + return v, ok +} + +// 内置归一化步骤:trim / lower / upper。 +var builtinNormalizers = map[string]func(string) string{ + "trim": strings.TrimSpace, + "lower": strings.ToLower, + "upper": strings.ToUpper, +} + +func normalizeSlotValue(v string, ops []string) string { + for _, op := range ops { + if fn, ok := builtinNormalizers[op]; ok { + v = fn(v) + } + } + return v +} + +// compiledSlot 编译后的槽位抽取与校验产物。 +type compiledSlot struct { + key string + required bool + label string + question string + re *regexp.Regexp + group int + enumSet map[string]bool + enum []string + formatRe *regexp.Regexp + normalize []string + validator SlotValidator +} + +// validate 对归一化后的值做确定性校验;返回(原因, 是否合法)。 +func (s *compiledSlot) validate(v string) (string, bool) { + if s.formatRe != nil && !s.formatRe.MatchString(v) { + return "format mismatch: " + s.formatRe.String(), false + } + if s.enumSet != nil && !s.enumSet[v] { + return "value not in enum", false + } + if s.validator != nil { + if err := s.validator(v); err != nil { + return "validator: " + err.Error(), false + } + } + return "", true +} + +// extractSlots 抽取并归一化槽位。 +// missing:必填槽位未捕获(模式没匹配上);invalid:捕获到但校验不合法(key→原因)。 +// 必填且非法只进 invalid(宿主可区分"没给"与"给错了"来决定澄清话术)。 +func (cs *compiledSpec) extractSlots(input string) (slots map[string]string, missing []string, invalid map[string]string) { + if len(cs.slots) == 0 { + return nil, nil, nil + } + for _, s := range cs.slots { + m := s.re.FindStringSubmatch(input) + if m == nil || s.group >= len(m) || m[s.group] == "" { + if s.required { + missing = append(missing, s.key) + } + continue + } + v := normalizeSlotValue(m[s.group], s.normalize) + if reason, ok := s.validate(v); !ok { + if invalid == nil { + invalid = map[string]string{} + } + invalid[s.key] = reason + continue + } + if slots == nil { + slots = map[string]string{} + } + slots[s.key] = v + } + return slots, missing, invalid +} + +func compileSlot(spec SlotSpec) (compiledSlot, error) { + re, err := regexp.Compile(spec.Pattern) + if err != nil { + return compiledSlot{}, newError(InvalidSpec, "slot pattern 编译失败", + "field", "slots", "key", spec.Key, "error", err.Error()) + } + cs := compiledSlot{ + key: spec.Key, + required: spec.Required, + label: spec.Label, + question: spec.Question, + re: re, + group: effectiveGroup(spec.Group), + normalize: append([]string(nil), spec.Normalize...), + } + if len(spec.Enum) > 0 { + cs.enum = append([]string(nil), spec.Enum...) + cs.enumSet = make(map[string]bool, len(spec.Enum)) + for _, e := range spec.Enum { + cs.enumSet[e] = true + } + } + if spec.Format != "" { + fre, err := regexp.Compile(spec.Format) + if err != nil { + return compiledSlot{}, newError(InvalidSpec, "slot format 编译失败", + "field", "slots", "key", spec.Key, "format", spec.Format, "error", err.Error()) + } + cs.formatRe = fre + } + if spec.Validator != "" { + fn, ok := LookupSlotValidator(spec.Validator) + if !ok { + return compiledSlot{}, newError(InvalidSpec, "slot validator 未注册", + "field", "slots", "key", spec.Key, "validator", spec.Validator, + "hint", "先调用 intent.RegisterSlotValidator 再构建快照") + } + cs.validator = fn + } + return cs, nil +} diff --git a/intent/slots_catalog_test.go b/intent/slots_catalog_test.go new file mode 100644 index 0000000..95fde6f --- /dev/null +++ b/intent/slots_catalog_test.go @@ -0,0 +1,176 @@ +package intent + +import ( + "context" + "errors" + "strings" + "testing" +) + +func slotSpecWith(overrides ...func(*IntentSpec)) IntentSpec { + spec := IntentSpec{ + ID: "demo.run.status_query", Domain: "demo", Version: 1, + Matcher: MatchSpec{IncludesAny: []string{"进度"}}, + } + for _, f := range overrides { + f(&spec) + } + return spec +} + +func TestSlotNormalizeAndValidateChain(t *testing.T) { + RegisterSlotValidator("runid", func(v string) error { + if !strings.HasPrefix(v, "run-") { + return errors.New("须以 run- 开头") + } + return nil + }) + b := NewRegistryBuilder(Options{}) + if err := b.Register(slotSpecWith(func(s *IntentSpec) { + s.Slots = []SlotSpec{{ + Key: "run_id", Required: true, + Pattern: "(?:run|任务)[:# ]*([A-Za-z0-9-]+)", Group: 1, + Normalize: []string{"trim", "lower"}, + Validator: "runid", + }} + })); err != nil { + t.Fatal(err) + } + reg, err := b.Build() + if err != nil { + t.Fatal(err) + } + rec := NewRecognizer(Options{}, nil, nil) + + // 归一化 + 校验通过:大写、带空白 → trim+lower 后通过自定义校验。 + res, _ := rec.Recognize(context.Background(), "查询任务 RUN-42 的进度", reg.Snapshot()) + if res.Top == nil || res.Top.Slots["run_id"] != "run-42" { + t.Fatalf("normalized slot: %+v", res.Top) + } + if len(res.Top.InvalidSlots) != 0 || len(res.Top.MissingRequiredSlots) != 0 { + t.Fatalf("unexpected missing/invalid: %+v", res.Top) + } + + // 捕获到但校验不合法:只进 InvalidSlots,不进 Missing。 + res, _ = rec.Recognize(context.Background(), "查询任务 42 的进度", reg.Snapshot()) + if res.Top == nil { + t.Fatal("expected match") + } + if res.Top.InvalidSlots["run_id"] == "" { + t.Fatalf("expected invalid slot with reason, got %+v", res.Top.InvalidSlots) + } + if len(res.Top.MissingRequiredSlots) != 0 { + t.Fatalf("invalid must not duplicate into missing: %+v", res.Top.MissingRequiredSlots) + } + if res.Top.Slots["run_id"] != "" { + t.Fatalf("invalid slot must not be filled: %+v", res.Top.Slots) + } +} + +func TestSlotEnumAndFormat(t *testing.T) { + b := NewRegistryBuilder(Options{}) + if err := b.Register(slotSpecWith(func(s *IntentSpec) { + s.Slots = []SlotSpec{ + {Key: "env", Pattern: "环境[: ]*(\\w+)", Group: 1, Enum: []string{"prod", "staging"}}, + {Key: "date", Pattern: "日期[: ]*(\\S+)", Group: 1, Format: `^\d{4}-\d{2}-\d{2}$`}, + } + })); err != nil { + t.Fatal(err) + } + reg, _ := b.Build() + rec := NewRecognizer(Options{}, nil, nil) + + res, _ := rec.Recognize(context.Background(), "查进度 环境: prod 日期: 2026-09-29", reg.Snapshot()) + if res.Top.InvalidSlots != nil && len(res.Top.InvalidSlots) != 0 { + t.Fatalf("valid values flagged: %+v", res.Top.InvalidSlots) + } + if res.Top.Slots["env"] != "prod" || res.Top.Slots["date"] != "2026-09-29" { + t.Fatalf("slots: %+v", res.Top.Slots) + } + + res, _ = rec.Recognize(context.Background(), "查进度 环境: dev 日期: 明天", reg.Snapshot()) + if res.Top.InvalidSlots["env"] != "value not in enum" { + t.Fatalf("enum check: %+v", res.Top.InvalidSlots) + } + if !strings.Contains(res.Top.InvalidSlots["date"], "format mismatch") { + t.Fatalf("format check: %+v", res.Top.InvalidSlots) + } +} + +func TestSlotSpecValidationRejections(t *testing.T) { + lim := Options{}.normalized().limits() + cases := []struct { + name string + slot SlotSpec + field string + }{ + {"bad_format", SlotSpec{Key: "k", Pattern: "(x)", Format: "["}, "slots[0].format"}, + {"bad_normalize", SlotSpec{Key: "k", Pattern: "(x)", Normalize: []string{"camel"}}, "slots[0].normalize"}, + {"unregistered_validator", SlotSpec{Key: "k", Pattern: "(x)", Validator: "nope"}, "slots[0].validator"}, + {"empty_enum", SlotSpec{Key: "k", Pattern: "(x)", Enum: []string{" "}}, "slots[0].enum"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + spec := slotSpecWith(func(s *IntentSpec) { s.Slots = []SlotSpec{tc.slot} }) + err := validateSpec(spec, lim) + var e *Error + if !errors.As(err, &e) || e.Code != InvalidSpec { + t.Fatalf("expected invalid_spec, got %v", err) + } + if got, _ := e.Fields["field"].(string); got != tc.field { + t.Fatalf("field %q, got %q (%v)", tc.field, got, e.Fields) + } + }) + } +} + +func TestCatalogExcludesGuardAndIncludesSlots(t *testing.T) { + b := NewRegistryBuilder(Options{GuardDomains: []string{"guard"}}) + if err := b.Register( + IntentSpec{ID: "guard.config_change", Domain: "guard", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"网关"}}}, + slotSpecWith(func(s *IntentSpec) { + s.Description = "查询任务运行进度" + s.Handler = "taskrun.query_status" + s.Slots = []SlotSpec{{ + Key: "run_id", Required: true, Pattern: "(run-\\d+)", + Label: "运行编号", Question: "要查询哪个运行?请提供 run 编号", + }} + }), + ); err != nil { + t.Fatal(err) + } + reg, _ := b.Build() + snap := reg.Snapshot() + + catalog := snap.Catalog() + if len(catalog) != 1 || catalog[0].ID != "demo.run.status_query" { + t.Fatalf("catalog must exclude guard specs: %+v", catalog) + } + cs := catalog[0] + if cs.Handler != "taskrun.query_status" || cs.Risk != RiskLow || len(cs.Slots) != 1 { + t.Fatalf("catalog spec: %+v", cs) + } + if cs.Slots[0].Label != "运行编号" || cs.Slots[0].Question == "" || !cs.Slots[0].Required { + t.Fatalf("catalog slot: %+v", cs.Slots[0]) + } + + md := snap.CatalogMarkdown() + for _, want := range []string{"demo.run.status_query", "查询任务运行进度", "必填", "运行编号", "提问建议:要查询哪个运行"} { + if !strings.Contains(md, want) { + t.Fatalf("markdown missing %q:\n%s", want, md) + } + } + if strings.Contains(md, "guard.config_change") { + t.Fatalf("markdown must exclude guard specs:\n%s", md) + } +} + +func TestCatalogEmpty(t *testing.T) { + reg, _ := NewRegistryBuilder(Options{}).Build() + if got := reg.Snapshot().Catalog(); len(got) != 0 { + t.Fatalf("expected empty catalog, got %+v", got) + } + if md := reg.Snapshot().CatalogMarkdown(); !strings.Contains(md, "目录为空") { + t.Fatalf("empty markdown: %q", md) + } +} diff --git a/intent/spec.go b/intent/spec.go index c56f440..9dcf338 100644 --- a/intent/spec.go +++ b/intent/spec.go @@ -34,11 +34,25 @@ type MatchSpec struct { } // SlotSpec 槽位抽取规则。group 未填默认取 1(第一个捕获组)。 +// label/question 供宿主做 HITL 提问与目录展示;enum/format/normalize/validator +// 构成确定性校验链:抽取 → 归一化 → 校验,校验失败进 Candidate.InvalidSlots。 type SlotSpec struct { Key string `json:"key"` Required bool `json:"required,omitempty"` Pattern string `json:"pattern"` Group int `json:"group,omitempty"` + // Label 槽位的人类可读名(目录/HITL 展示用)。 + Label string `json:"label,omitempty"` + // Question 建议的澄清提问(HITL 生成用),如"要查询哪个运行?请提供 run 编号"。 + Question string `json:"question,omitempty"` + // Enum 合法值枚举(归一化后校验)。 + Enum []string `json:"enum,omitempty"` + // Format 整段匹配正则(校验,区别于 Pattern 的抽取用途)。 + Format string `json:"format,omitempty"` + // Normalize 归一化步骤,按序应用:trim | lower | upper。 + Normalize []string `json:"normalize,omitempty"` + // Validator 宿主经 RegisterSlotValidator 注册的校验器名。 + Validator string `json:"validator,omitempty"` } // ConfidenceSpec 置信度参数。Base 初始分;PatternBonus 模式命中加成; @@ -151,6 +165,26 @@ func validateSpec(spec IntentSpec, lim limits) error { if g < 1 || g > re.NumSubexp() { return newError(InvalidSpec, "slot group 超出捕获组范围", "field", fmt.Sprintf("slots[%d].group", i), "key", s.Key, "group", s.Group, "groups", re.NumSubexp()) } + for _, e := range s.Enum { + if strings.TrimSpace(e) == "" { + return newError(InvalidSpec, "slot enum 含空值", "field", fmt.Sprintf("slots[%d].enum", i), "key", s.Key) + } + } + if s.Format != "" { + if _, err := regexp.Compile(s.Format); err != nil { + return newError(InvalidSpec, "slot format 编译失败", "field", fmt.Sprintf("slots[%d].format", i), "key", s.Key, "error", err.Error()) + } + } + for _, op := range s.Normalize { + if _, ok := builtinNormalizers[op]; !ok { + return newError(InvalidSpec, "slot normalize 步骤不合法(trim|lower|upper)", "field", fmt.Sprintf("slots[%d].normalize", i), "key", s.Key, "op", op) + } + } + if s.Validator != "" { + if _, ok := LookupSlotValidator(s.Validator); !ok { + return newError(InvalidSpec, "slot validator 未注册", "field", fmt.Sprintf("slots[%d].validator", i), "key", s.Key, "validator", s.Validator, "hint", "先调用 intent.RegisterSlotValidator 再注册/加载 spec") + } + } } c := spec.Confidence