Add deterministic slot constraints with labels and questions

Details (original message):
Add deterministic slot constraints with labels and questions, and the planner-facing capability catalog.
This commit is contained in:
weichen
2026-09-29 16:32:50 +00:00
parent 8f9496d5d8
commit b2fa5be60f
8 changed files with 526 additions and 33 deletions
+6 -1
View File
@@ -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 配置。
+33 -1
View File
@@ -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 <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>")
return fmt.Errorf("用法:intentctl <lint|explain|eval|catalog> ...\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>\n catalog -root <dir> [-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"`
+121
View File
@@ -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 ""
}
+5 -31
View File
@@ -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
}
+3
View File
@@ -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
}
+148
View File
@@ -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
}
+176
View File
@@ -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)
}
}
+34
View File
@@ -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