Reject invalid classifier refinements and scope slot validators #3
+38
-7
@@ -6,17 +6,48 @@ on:
|
||||
branches: [main]
|
||||
jobs:
|
||||
vet-and-test:
|
||||
runs-on: nightly
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
- run: go vet ./... && go test -count=1 ./...
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init -q
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout -q --detach FETCH_HEAD
|
||||
if [ -z "${SUBMODULE_TOKEN}" ]; then
|
||||
echo "SUBMODULE_TOKEN is not configured" >&2
|
||||
exit 1
|
||||
fi
|
||||
git config --global url."https://weichen:${SUBMODULE_TOKEN}@git-home.u1680832.nyat.app:30319/".insteadOf "ssh://git@git-ssh.u1680832.nyat.app:31411/"
|
||||
git -c fetch.recurseSubmodules=false submodule update --init --recursive
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
|
||||
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
|
||||
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
- name: Vet, test, and race
|
||||
run: GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./... && GOWORK=off go test -count=1 -race ./...
|
||||
docker-checkout-hostgateway:
|
||||
runs-on: ubuntu-latest
|
||||
options: --add-host gitea:host-gateway
|
||||
env:
|
||||
GITHUB_SERVER_URL: http://gitea:3000
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: test -f go.mod && echo "docker-mode checkout via host-gateway OK"
|
||||
- name: Checkout without GitHub actions
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init -q
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout -q --detach FETCH_HEAD
|
||||
test -f go.mod && echo "docker-mode checkout via host-gateway OK"
|
||||
|
||||
Vendored
+1
-1
Submodule deps/error-code-system updated: dbeed2f1b6...a3ee67bba2
Vendored
+1
-1
Submodule deps/logging-system updated: 2c5716fdcf...49b5d8be60
@@ -7,7 +7,9 @@ import "context"
|
||||
// 返回错误时识别保持 Ambiguous,不会使 Recognize 失败(识别确定性不受实现影响)。
|
||||
// 宿主应保证 Refine 幂等且无副作用:同样的输入与候选应得到同样的重排结果。
|
||||
type Classifier interface {
|
||||
// Refine 对候选重打分/重排。返回的切片应包含与输入相同的一组候选
|
||||
// (可更新 Confidence 与顺序);快照信息已随候选 ID 携带。
|
||||
// Refine 对候选重打分/重排。返回的 ID 集合必须与输入完全相同且不能重复;
|
||||
// Confidence 必须是有限值并落在 [0,1],越界会被拒绝而不是截断。
|
||||
// 除 Confidence 以外的字段会被按 ID 从原候选重建,改写不生效。
|
||||
// 校验失败时识别保持 Ambiguous。
|
||||
Refine(ctx context.Context, input string, candidates []Candidate) ([]Candidate, error)
|
||||
}
|
||||
|
||||
+51
-6
@@ -2,6 +2,8 @@ package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -92,7 +94,7 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
|
||||
return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
|
||||
}
|
||||
|
||||
// Ambiguous:可选 L2 分类器重排;失败保持 Ambiguous,不影响识别确定性承诺的失败语义。
|
||||
// Ambiguous:可选 L2 分类器重排;失败或结果不合法都保持 Ambiguous。
|
||||
cands := candidatesOf(trimHits(hits, snap.topN), input, false)
|
||||
if r.cls != nil {
|
||||
refined, err := r.cls.Refine(ctx, input, cands)
|
||||
@@ -100,16 +102,59 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
|
||||
r.log.Module("intent").WithContext(ctx).Warn("intent classifier failed; keep ambiguous", "error", err.Error())
|
||||
return Result{Input: input, Branch: BranchAmbiguous, Candidates: cands, SnapshotRevision: snap.rev}
|
||||
}
|
||||
if top, ok := adjudicateRefined(refined, snap); ok {
|
||||
cands = refined
|
||||
top.Reason = top.Reason + "; refined_by=classifier"
|
||||
return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
|
||||
rebuilt, err := validateRefined(cands, refined)
|
||||
if err != nil {
|
||||
r.log.Module("intent").WithContext(ctx).Warn("intent classifier result rejected; keep ambiguous", "error", err.Error())
|
||||
return Result{Input: input, Branch: BranchAmbiguous, Candidates: cands, SnapshotRevision: snap.rev}
|
||||
}
|
||||
cands = refined
|
||||
if top, ok := adjudicateRefined(rebuilt, snap); ok {
|
||||
snap.fillTopSlots(&top, input)
|
||||
top.Reason = top.Reason + "; refined_by=classifier"
|
||||
return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: rebuilt, SnapshotRevision: snap.rev}
|
||||
}
|
||||
cands = rebuilt
|
||||
}
|
||||
return Result{Input: input, Branch: BranchAmbiguous, Candidates: cands, SnapshotRevision: snap.rev}
|
||||
}
|
||||
|
||||
// validateRefined 要求分类器返回与原候选完全相同的 ID 集合且无重复。
|
||||
// 置信度必须是有限值并落在 [0,1];越界判为不合法,不截断。
|
||||
// 除 Confidence 外的字段一律按 ID 从原候选重建,分类器改写的 Handler 等不生效。
|
||||
func validateRefined(original, refined []Candidate) ([]Candidate, error) {
|
||||
byID := make(map[string]Candidate, len(original))
|
||||
for _, c := range original {
|
||||
if _, dup := byID[c.ID]; dup {
|
||||
return nil, fmt.Errorf("duplicate original id %s", c.ID)
|
||||
}
|
||||
byID[c.ID] = c
|
||||
}
|
||||
if len(refined) != len(original) {
|
||||
return nil, fmt.Errorf("refined candidate count %d != %d", len(refined), len(original))
|
||||
}
|
||||
seen := make(map[string]struct{}, len(refined))
|
||||
out := make([]Candidate, len(refined))
|
||||
for i, got := range refined {
|
||||
if _, dup := seen[got.ID]; dup {
|
||||
return nil, fmt.Errorf("duplicate refined id %s", got.ID)
|
||||
}
|
||||
seen[got.ID] = struct{}{}
|
||||
src, ok := byID[got.ID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown refined id %s", got.ID)
|
||||
}
|
||||
if !validConfidence(got.Confidence) {
|
||||
return nil, fmt.Errorf("confidence out of range for %s", got.ID)
|
||||
}
|
||||
src.Confidence = got.Confidence
|
||||
out[i] = src
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validConfidence(v float64) bool {
|
||||
return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v <= 1
|
||||
}
|
||||
|
||||
// adjudicateRefined 对分类器结果重排后应用同一裁决规则;分差仍贴近则返回 false。
|
||||
func adjudicateRefined(refined []Candidate, snap *Snapshot) (Candidate, bool) {
|
||||
if len(refined) == 0 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package intent
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -176,6 +177,136 @@ func TestRecognizeClassifierFailureKeepsAmbiguous(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type rewriteClassifier struct {
|
||||
fn func([]Candidate) []Candidate
|
||||
}
|
||||
|
||||
func (s rewriteClassifier) Refine(ctx context.Context, input string, cands []Candidate) ([]Candidate, error) {
|
||||
return s.fn(cands), nil
|
||||
}
|
||||
|
||||
func TestRecognizeClassifierRejectsBadRefinement(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func([]Candidate) []Candidate
|
||||
}{
|
||||
{"unknown_id", func(cands []Candidate) []Candidate {
|
||||
out := append([]Candidate(nil), cands...)
|
||||
out[0].ID = "not.a.real.id"
|
||||
return out
|
||||
}},
|
||||
{"missing_id", func(cands []Candidate) []Candidate {
|
||||
return cands[:1]
|
||||
}},
|
||||
{"duplicate_id", func(cands []Candidate) []Candidate {
|
||||
return []Candidate{cands[0], cands[0]}
|
||||
}},
|
||||
{"confidence_above_one", func(cands []Candidate) []Candidate {
|
||||
out := append([]Candidate(nil), cands...)
|
||||
out[0].Confidence = 1.2
|
||||
return out
|
||||
}},
|
||||
{"confidence_negative", func(cands []Candidate) []Candidate {
|
||||
out := append([]Candidate(nil), cands...)
|
||||
out[0].Confidence = -0.1
|
||||
return out
|
||||
}},
|
||||
{"confidence_nan", func(cands []Candidate) []Candidate {
|
||||
out := append([]Candidate(nil), cands...)
|
||||
out[0].Confidence = math.NaN()
|
||||
return out
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: tc.fn})
|
||||
res, err := rec.Recognize(context.Background(), "你好呀", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchAmbiguous || res.Top != nil {
|
||||
t.Fatalf("expected ambiguous kept: %+v", res)
|
||||
}
|
||||
if len(res.Candidates) < 2 || res.Candidates[0].ID != "demo.greet.hello" {
|
||||
t.Fatalf("original candidates kept: %+v", res.Candidates)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeClassifierRebuildsTamperedHandler(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
plain, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "你好呀", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantHandler := plain.Candidates[0].Handler
|
||||
rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: func(cands []Candidate) []Candidate {
|
||||
out := append([]Candidate(nil), cands...)
|
||||
for i := range out {
|
||||
out[i].Handler = "tampered.handler"
|
||||
if out[i].ID == "demo.greet.hello" {
|
||||
out[i].Confidence = 0.95
|
||||
} else {
|
||||
out[i].Confidence = 0.2
|
||||
}
|
||||
}
|
||||
return out
|
||||
}})
|
||||
res, err := rec.Recognize(context.Background(), "你好呀", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.greet.hello" {
|
||||
t.Fatalf("expected resolved match: %+v", res)
|
||||
}
|
||||
if res.Top.Handler != wantHandler {
|
||||
t.Fatalf("handler rebuilt from original %q, got %q", wantHandler, res.Top.Handler)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeClassifierRefinedTopGetsSlots(t *testing.T) {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(
|
||||
IntentSpec{
|
||||
ID: "demo.slot.a", Domain: "demo", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"查槽"}},
|
||||
Handler: "handler.a",
|
||||
Slots: []SlotSpec{{Key: "n", Pattern: `查槽[: ]*(\d+)`, Group: 1}},
|
||||
Confidence: ConfidenceSpec{Base: 0.8, Threshold: 0.5},
|
||||
},
|
||||
IntentSpec{
|
||||
ID: "demo.slot.b", Domain: "demo", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"查槽"}},
|
||||
Handler: "handler.b",
|
||||
Confidence: ConfidenceSpec{Base: 0.8, Threshold: 0.5},
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := reg.Snapshot()
|
||||
plain, _ := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "查槽 7", snap)
|
||||
if plain.Branch != BranchAmbiguous || plain.Top != nil {
|
||||
t.Fatalf("expected ambiguous before refine: %+v", plain)
|
||||
}
|
||||
rec := NewRecognizer(Options{}, nil, stubClassifier{scores: map[string]float64{"demo.slot.a": 0.95, "demo.slot.b": 0.2}})
|
||||
res, err := rec.Recognize(context.Background(), "查槽 7", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.slot.a" {
|
||||
t.Fatalf("refined top: %+v", res)
|
||||
}
|
||||
if res.Top.Slots["n"] != "7" {
|
||||
t.Fatalf("top slots: %+v", res.Top.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
type stubExpectation struct {
|
||||
pending *PendingExpectation
|
||||
}
|
||||
|
||||
+28
-5
@@ -35,6 +35,9 @@ type Options struct {
|
||||
MaxSlotsPerSpec int
|
||||
// Log 可选日志器;nil 时使用 Nop。识别日志不记录原始输入。
|
||||
Log *logsys.Logger
|
||||
// SlotValidators 作用域槽位校验器。构建与校验时先查此表,查不到再回落
|
||||
// 已弃用的全局表(RegisterSlotValidator)。不同 Registry 的同名校验器互不影响。
|
||||
SlotValidators map[string]SlotValidator
|
||||
}
|
||||
|
||||
// Options 字段默认值。
|
||||
@@ -70,6 +73,13 @@ func (o Options) normalized() Options {
|
||||
if n.Log == nil {
|
||||
n.Log = logsys.Nop()
|
||||
}
|
||||
if len(n.SlotValidators) > 0 {
|
||||
cp := make(map[string]SlotValidator, len(n.SlotValidators))
|
||||
for k, v := range n.SlotValidators {
|
||||
cp[k] = v
|
||||
}
|
||||
n.SlotValidators = cp
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -163,7 +173,7 @@ func (cs *compiledSpec) evaluate(input, folded string) matchOutcome {
|
||||
return matchOutcome{score: math.Round(score*10000) / 10000, passed: score >= cs.threshold, hitTerms: hitTerms, hitPat: hitPat}
|
||||
}
|
||||
|
||||
func compileSpec(spec IntentSpec, guard bool, index int) (*compiledSpec, error) {
|
||||
func compileSpec(spec IntentSpec, guard bool, index int, scoped map[string]SlotValidator) (*compiledSpec, error) {
|
||||
m := spec.Matcher
|
||||
fold := func(terms []string) []string {
|
||||
out := make([]string, len(terms))
|
||||
@@ -205,7 +215,7 @@ func compileSpec(spec IntentSpec, guard bool, index int) (*compiledSpec, error)
|
||||
cs.patterns[i] = re
|
||||
}
|
||||
for _, s := range spec.Slots {
|
||||
cs2, err := compileSlot(s)
|
||||
cs2, err := compileSlot(s, scoped)
|
||||
if err != nil {
|
||||
return nil, err.(*Error).with("id", spec.ID)
|
||||
}
|
||||
@@ -411,6 +421,19 @@ func (h specHit) candidate(input string, withSlots bool) Candidate {
|
||||
return c
|
||||
}
|
||||
|
||||
// fillTopSlots 给裁决后的 Top 补抽槽位。Ambiguous 路径建候选时不抽槽位,
|
||||
// 分类器选定 Top 之后才知道该抽哪一条。
|
||||
func (s *Snapshot) fillTopSlots(c *Candidate, input string) {
|
||||
idx, ok := s.byID[c.ID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slots, missing, invalid := s.specs[idx].extractSlots(input)
|
||||
c.Slots = slots
|
||||
c.MissingRequiredSlots = missing
|
||||
c.InvalidSlots = invalid
|
||||
}
|
||||
|
||||
func candidatesOf(hits []specHit, input string, topSlots bool) []Candidate {
|
||||
out := make([]Candidate, 0, len(hits))
|
||||
for i, h := range hits {
|
||||
@@ -440,7 +463,7 @@ func (b *RegistryBuilder) Register(specs ...IntentSpec) error {
|
||||
if b.seen[spec.ID] {
|
||||
return newError(InvalidSpec, "内置 spec id 重复", "id", spec.ID)
|
||||
}
|
||||
if err := validateSpec(spec, lim); err != nil {
|
||||
if err := validateSpec(spec, lim, b.opts.SlotValidators); err != nil {
|
||||
return err
|
||||
}
|
||||
b.seen[spec.ID] = true
|
||||
@@ -574,7 +597,7 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry,
|
||||
continue
|
||||
}
|
||||
spec := e.spec
|
||||
if err := validateSpec(spec, lim); err != nil {
|
||||
if err := validateSpec(spec, lim, opts.SlotValidators); err != nil {
|
||||
return nil, err.(*Error).with("source", e.source)
|
||||
}
|
||||
if idx, ok := pos[spec.ID]; ok {
|
||||
@@ -616,7 +639,7 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry,
|
||||
snap.disabled = append(snap.disabled, spec.ID)
|
||||
continue
|
||||
}
|
||||
cs, err := compileSpec(spec, guards[spec.Domain], nextIndex)
|
||||
cs, err := compileSpec(spec, guards[spec.Domain], nextIndex, opts.SlotValidators)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+18
-5
@@ -11,13 +11,18 @@ type SlotValidator func(value string) error
|
||||
|
||||
var slotValidators sync.Map // name → SlotValidator
|
||||
|
||||
// RegisterSlotValidator 注册具名校验器;spec 通过 SlotSpec.Validator 引用。
|
||||
// RegisterSlotValidator 注册进程级具名校验器;spec 通过 SlotSpec.Validator 引用。
|
||||
// 必须在快照构建前注册(构建期会校验名字存在)。
|
||||
//
|
||||
// Deprecated: 校验器应随 Registry 隔离。请改用 Options.SlotValidators。
|
||||
// 全局表只作回落:构建与校验先查作用域表,查不到再查这里。
|
||||
func RegisterSlotValidator(name string, fn SlotValidator) {
|
||||
slotValidators.Store(name, fn)
|
||||
}
|
||||
|
||||
// LookupSlotValidator 按名查找已注册校验器。
|
||||
// LookupSlotValidator 按名查找已注册的全局校验器。
|
||||
//
|
||||
// Deprecated: 请改用 Options.SlotValidators。本函数只服务全局回落。
|
||||
func LookupSlotValidator(name string) (SlotValidator, bool) {
|
||||
fn, ok := slotValidators.Load(name)
|
||||
if !ok {
|
||||
@@ -27,6 +32,14 @@ func LookupSlotValidator(name string) (SlotValidator, bool) {
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// resolveSlotValidator 先查作用域表,查不到再回落已弃用的全局表。
|
||||
func resolveSlotValidator(scoped map[string]SlotValidator, name string) (SlotValidator, bool) {
|
||||
if fn, ok := scoped[name]; ok && fn != nil {
|
||||
return fn, true
|
||||
}
|
||||
return LookupSlotValidator(name)
|
||||
}
|
||||
|
||||
// 内置归一化步骤:trim / lower / upper。
|
||||
var builtinNormalizers = map[string]func(string) string{
|
||||
"trim": strings.TrimSpace,
|
||||
@@ -105,7 +118,7 @@ func (cs *compiledSpec) extractSlots(input string) (slots map[string]string, mis
|
||||
return slots, missing, invalid
|
||||
}
|
||||
|
||||
func compileSlot(spec SlotSpec) (compiledSlot, error) {
|
||||
func compileSlot(spec SlotSpec, scoped map[string]SlotValidator) (compiledSlot, error) {
|
||||
re, err := regexp.Compile(spec.Pattern)
|
||||
if err != nil {
|
||||
return compiledSlot{}, newError(InvalidSpec, "slot pattern 编译失败",
|
||||
@@ -136,11 +149,11 @@ func compileSlot(spec SlotSpec) (compiledSlot, error) {
|
||||
cs.formatRe = fre
|
||||
}
|
||||
if spec.Validator != "" {
|
||||
fn, ok := LookupSlotValidator(spec.Validator)
|
||||
fn, ok := resolveSlotValidator(scoped, spec.Validator)
|
||||
if !ok {
|
||||
return compiledSlot{}, newError(InvalidSpec, "slot validator 未注册",
|
||||
"field", "slots", "key", spec.Key, "validator", spec.Validator,
|
||||
"hint", "先调用 intent.RegisterSlotValidator 再构建快照")
|
||||
"hint", "在 Options.SlotValidators 登记,或调用已弃用的 intent.RegisterSlotValidator")
|
||||
}
|
||||
cs.validator = fn
|
||||
}
|
||||
|
||||
@@ -67,6 +67,49 @@ func TestSlotNormalizeAndValidateChain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedSlotValidatorsDoNotInterfere(t *testing.T) {
|
||||
spec := slotSpecWith(func(s *IntentSpec) {
|
||||
s.Slots = []SlotSpec{{
|
||||
Key: "run_id", Required: true,
|
||||
Pattern: `(?:run|任务)[:# ]*([A-Za-z0-9-]+)`, Group: 1,
|
||||
Validator: "shared",
|
||||
}}
|
||||
})
|
||||
reject := SlotValidator(func(v string) error {
|
||||
if v == "42" {
|
||||
return errors.New("rejected by a")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
accept := SlotValidator(func(string) error { return nil })
|
||||
build := func(fn SlotValidator) *Snapshot {
|
||||
t.Helper()
|
||||
b := NewRegistryBuilder(Options{SlotValidators: map[string]SlotValidator{"shared": fn}})
|
||||
if err := b.Register(spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reg.Snapshot()
|
||||
}
|
||||
snapA := build(reject)
|
||||
snapB := build(accept)
|
||||
rec := NewRecognizer(Options{}, nil, nil)
|
||||
resA, _ := rec.Recognize(context.Background(), "查询任务 42 的进度", snapA)
|
||||
resB, _ := rec.Recognize(context.Background(), "查询任务 42 的进度", snapB)
|
||||
if resA.Top == nil || resA.Top.InvalidSlots["run_id"] == "" {
|
||||
t.Fatalf("registry A should reject 42: %+v", resA.Top)
|
||||
}
|
||||
if resB.Top == nil || resB.Top.Slots["run_id"] != "42" {
|
||||
t.Fatalf("registry B should accept 42: %+v", resB.Top)
|
||||
}
|
||||
if len(resB.Top.InvalidSlots) != 0 {
|
||||
t.Fatalf("registry B invalid: %+v", resB.Top.InvalidSlots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotEnumAndFormat(t *testing.T) {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(slotSpecWith(func(s *IntentSpec) {
|
||||
@@ -112,7 +155,7 @@ func TestSlotSpecValidationRejections(t *testing.T) {
|
||||
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)
|
||||
err := validateSpec(spec, lim, nil)
|
||||
var e *Error
|
||||
if !errors.As(err, &e) || e.Code != InvalidSpec {
|
||||
t.Fatalf("expected invalid_spec, got %v", err)
|
||||
|
||||
+6
-4
@@ -51,7 +51,8 @@ type SlotSpec struct {
|
||||
Format string `json:"format,omitempty"`
|
||||
// Normalize 归一化步骤,按序应用:trim | lower | upper。
|
||||
Normalize []string `json:"normalize,omitempty"`
|
||||
// Validator 宿主经 RegisterSlotValidator 注册的校验器名。
|
||||
// Validator 槽位校验器名。优先解析 Options.SlotValidators,查不到再回落
|
||||
// 已弃用的全局表(RegisterSlotValidator)。
|
||||
Validator string `json:"validator,omitempty"`
|
||||
}
|
||||
|
||||
@@ -95,7 +96,8 @@ type limits struct {
|
||||
}
|
||||
|
||||
// validateSpec 校验单条 spec 的结构合法性,错误可定位到字段。limits 为零值时跳过数量上限。
|
||||
func validateSpec(spec IntentSpec, lim limits) error {
|
||||
// scoped 为 Options.SlotValidators;查不到名字时回落已弃用的全局表。
|
||||
func validateSpec(spec IntentSpec, lim limits, scoped map[string]SlotValidator) error {
|
||||
if spec.Domain == "" {
|
||||
return newError(InvalidSpec, "domain 为空", "field", "domain")
|
||||
}
|
||||
@@ -181,8 +183,8 @@ func validateSpec(spec IntentSpec, lim limits) error {
|
||||
}
|
||||
}
|
||||
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")
|
||||
if _, ok := resolveSlotValidator(scoped, s.Validator); !ok {
|
||||
return newError(InvalidSpec, "slot validator 未注册", "field", fmt.Sprintf("slots[%d].validator", i), "key", s.Key, "validator", s.Validator, "hint", "在 Options.SlotValidators 登记,或调用已弃用的 intent.RegisterSlotValidator")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -16,14 +16,14 @@ func validSpec() IntentSpec {
|
||||
|
||||
func TestValidateSpecAcceptsValid(t *testing.T) {
|
||||
lim := Options{}.normalized().limits()
|
||||
if err := validateSpec(validSpec(), lim); err != nil {
|
||||
if err := validateSpec(validSpec(), lim, nil); err != nil {
|
||||
t.Fatalf("valid spec rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSpecDefaultsNoLimits(t *testing.T) {
|
||||
// 零值 limits 跳过数量上限(供意图较少的宿主直接校验)。
|
||||
if err := validateSpec(validSpec(), limits{}); err != nil {
|
||||
if err := validateSpec(validSpec(), limits{}, nil); err != nil {
|
||||
t.Fatalf("valid spec rejected without limits: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func TestValidateSpecRejections(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
spec := validSpec()
|
||||
tc.mut(&spec)
|
||||
err := validateSpec(spec, lim)
|
||||
err := validateSpec(spec, lim, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection")
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestValidateSpecIncludesAllOnly(t *testing.T) {
|
||||
// 仅 includes_all 也是合法正向条件。
|
||||
spec := validSpec()
|
||||
spec.Matcher = MatchSpec{IncludesAll: []string{"任务", "进度"}}
|
||||
if err := validateSpec(spec, Options{}.normalized().limits()); err != nil {
|
||||
if err := validateSpec(spec, Options{}.normalized().limits(), nil); err != nil {
|
||||
t.Fatalf("includes_all-only spec rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user