Files
Agent-Intent/intent/slots_catalog_test.go
T
weichen b2fa5be60f 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.
2026-09-29 16:32:50 +00:00

177 lines
5.8 KiB
Go

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)
}
}