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:
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user