Details (original message): Add deterministic slot constraints with labels and questions, and the planner-facing capability catalog.
177 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|