Fold NFKC input and stop catalog metadata leaks
Why: fullwidth text missed case-folded terms, SpecByID shared maps, the catalog prompt printed handlers and private metadata, overlapping slots kept both values, and negation was easy to mistake for semantics. Evidence: GOWORK=off go vet ./... && go test -count=1 -race ./... passed, including TestR2NFKCFoldsFullwidthButNotTraditional, TestR2SpecByIDDeepCopiesMetadata, TestR2CatalogHidesPrivateMetadataAndHandlerInMarkdown, TestR2EvalRulesDocumentsMaxSpecs, TestR2OverlappingSlotsAreAmbiguous, and TestR2NegationIsOnlyExcludesAny. Impact: NFKC is opt-in and does not convert traditional Chinese; catalog markdown omits handlers and non-allowlisted metadata; overlapping slot spans are invalid. Refs: I4, I7, I8, I10, I11, I12 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,8 @@ require (
|
|||||||
logging-system v0.0.0
|
logging-system v0.0.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require golang.org/x/text v0.29.0 // indirect
|
||||||
|
|
||||||
replace error-code-system => ./deps/error-code-system
|
replace error-code-system => ./deps/error-code-system
|
||||||
|
|
||||||
replace logging-system => ./deps/logging-system
|
replace logging-system => ./deps/logging-system
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
+2
-2
@@ -45,7 +45,7 @@ func (s *Snapshot) Catalog() []CatalogSpec {
|
|||||||
Risk: cs.spec.Risk,
|
Risk: cs.spec.Risk,
|
||||||
RequiresConfirm: cs.spec.RequiresConfirm,
|
RequiresConfirm: cs.spec.RequiresConfirm,
|
||||||
Handler: cs.spec.Handler,
|
Handler: cs.spec.Handler,
|
||||||
Metadata: cs.spec.Metadata,
|
Metadata: catalogMetadata(cs.spec.Metadata),
|
||||||
}
|
}
|
||||||
if view.Risk == "" {
|
if view.Risk == "" {
|
||||||
view.Risk = RiskLow
|
view.Risk = RiskLow
|
||||||
@@ -83,7 +83,7 @@ func (s *Snapshot) CatalogMarkdown() string {
|
|||||||
if sp.RequiresConfirm {
|
if sp.RequiresConfirm {
|
||||||
confirm = "是"
|
confirm = "是"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "- 风险:%s;执行前需人工确认:%s;handler:%s\n", sp.Risk, confirm, sp.Handler)
|
fmt.Fprintf(&b, "- 风险:%s;执行前需人工确认:%s\n", sp.Risk, confirm)
|
||||||
for _, sl := range sp.Slots {
|
for _, sl := range sp.Slots {
|
||||||
req := "选填"
|
req := "选填"
|
||||||
if sl.Required {
|
if sl.Required {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package intent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/text/unicode/norm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// foldMatchText 做识别用折叠。nfkc 只做兼容等价(全角 ASCII、组合字符),
|
||||||
|
// 不做繁简转换。caseSensitive 为真时保留大小写。
|
||||||
|
func foldMatchText(s string, nfkc, caseSensitive bool) string {
|
||||||
|
if nfkc {
|
||||||
|
s = norm.NFKC.String(s)
|
||||||
|
}
|
||||||
|
if !caseSensitive {
|
||||||
|
s = strings.ToLower(s)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// foldInput 返回用于匹配的文本:第一段保留大小写(可选 NFKC),第二段是其小写。
|
||||||
|
func (s *Snapshot) foldInput(input string) (matchInput, folded string) {
|
||||||
|
if s == nil {
|
||||||
|
return input, strings.ToLower(input)
|
||||||
|
}
|
||||||
|
matchInput = foldMatchText(input, s.nfkc, true)
|
||||||
|
return matchInput, strings.ToLower(matchInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStrings(in []string) []string {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]string(nil), in...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneIntentSpec(in IntentSpec) IntentSpec {
|
||||||
|
out := in
|
||||||
|
out.Matcher.IncludesAny = cloneStrings(in.Matcher.IncludesAny)
|
||||||
|
out.Matcher.IncludesAll = cloneStrings(in.Matcher.IncludesAll)
|
||||||
|
out.Matcher.ExcludesAny = cloneStrings(in.Matcher.ExcludesAny)
|
||||||
|
out.Matcher.Patterns = cloneStrings(in.Matcher.Patterns)
|
||||||
|
if in.Slots != nil {
|
||||||
|
out.Slots = append([]SlotSpec(nil), in.Slots...)
|
||||||
|
for i := range out.Slots {
|
||||||
|
out.Slots[i].Enum = cloneStrings(in.Slots[i].Enum)
|
||||||
|
out.Slots[i].Normalize = cloneStrings(in.Slots[i].Normalize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Metadata != nil {
|
||||||
|
out.Metadata = make(map[string]string, len(in.Metadata))
|
||||||
|
for k, v := range in.Metadata {
|
||||||
|
out.Metadata[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogMetadataAllow 是目录与 Markdown 允许带出的 metadata 键。
|
||||||
|
// handler 仍留在 CatalogSpec 供执行器按名分派,但不写入 Markdown。
|
||||||
|
var catalogMetadataAllow = map[string]bool{
|
||||||
|
"blueprint": true,
|
||||||
|
"guidance": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogMetadata(in map[string]string) map[string]string {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
for k, v := range in {
|
||||||
|
if catalogMetadataAllow[k] {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package intent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestR2NFKCFoldsFullwidthButNotTraditional(t *testing.T) {
|
||||||
|
spec := IntentSpec{
|
||||||
|
ID: "demo.greet.hello", Domain: "demo", Version: 1,
|
||||||
|
Matcher: MatchSpec{IncludesAny: []string{"hello"}},
|
||||||
|
}
|
||||||
|
b := NewRegistryBuilder(Options{NFKC: true})
|
||||||
|
if err := b.Register(spec); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg, err := b.Build()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := NewRecognizer(Options{NFKC: true}, nil, nil)
|
||||||
|
res, err := rec.Recognize(context.Background(), "Hello", reg.Snapshot())
|
||||||
|
if err != nil || res.Top == nil || res.Top.ID != spec.ID {
|
||||||
|
t.Fatalf("fullwidth hello: %+v %v", res, err)
|
||||||
|
}
|
||||||
|
trad, err := rec.Recognize(context.Background(), "寫入", reg.Snapshot())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if trad.Branch != BranchFallback {
|
||||||
|
t.Fatalf("NFKC must not convert traditional Chinese, got %+v", trad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2SpecByIDDeepCopiesMetadata(t *testing.T) {
|
||||||
|
b := NewRegistryBuilder(Options{})
|
||||||
|
if err := b.Register(IntentSpec{
|
||||||
|
ID: "demo.meta.copy", Domain: "demo", Version: 1,
|
||||||
|
Matcher: MatchSpec{IncludesAny: []string{"复制"}},
|
||||||
|
Metadata: map[string]string{"guidance": "keep"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg, err := b.Build()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, ok := reg.Snapshot().SpecByID("demo.meta.copy")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing spec")
|
||||||
|
}
|
||||||
|
got.Metadata["guidance"] = "mutated"
|
||||||
|
again, _ := reg.Snapshot().SpecByID("demo.meta.copy")
|
||||||
|
if again.Metadata["guidance"] != "keep" {
|
||||||
|
t.Fatalf("snapshot metadata changed: %q", again.Metadata["guidance"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2CatalogHidesPrivateMetadataAndHandlerInMarkdown(t *testing.T) {
|
||||||
|
b := NewRegistryBuilder(Options{})
|
||||||
|
if err := b.Register(IntentSpec{
|
||||||
|
ID: "demo.catalog.public", Domain: "demo", Version: 1,
|
||||||
|
Handler: "secret.handler",
|
||||||
|
Matcher: MatchSpec{IncludesAny: []string{"目录"}},
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"blueprint": "bp",
|
||||||
|
"token": "do-not-leak",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg, err := b.Build()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snap := reg.Snapshot()
|
||||||
|
cat := snap.Catalog()
|
||||||
|
if len(cat) != 1 {
|
||||||
|
t.Fatalf("catalog: %+v", cat)
|
||||||
|
}
|
||||||
|
if cat[0].Metadata["blueprint"] != "bp" || cat[0].Metadata["token"] != "" {
|
||||||
|
t.Fatalf("metadata allowlist: %+v", cat[0].Metadata)
|
||||||
|
}
|
||||||
|
md := snap.CatalogMarkdown()
|
||||||
|
if strings.Contains(md, "secret.handler") || strings.Contains(md, "do-not-leak") || strings.Contains(md, "handler:") {
|
||||||
|
t.Fatalf("markdown leaked private fields:\n%s", md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2EvalRulesDocumentsMaxSpecs(t *testing.T) {
|
||||||
|
if DefaultMaxSpecs != 2048 {
|
||||||
|
t.Fatalf("linear scan cap changed: %d", DefaultMaxSpecs)
|
||||||
|
}
|
||||||
|
b := NewRegistryBuilder(Options{MaxSpecs: 1})
|
||||||
|
if err := b.Register(
|
||||||
|
IntentSpec{ID: "demo.cap.a", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"a"}}},
|
||||||
|
IntentSpec{ID: "demo.cap.b", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"b"}}},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := b.Build(); err == nil {
|
||||||
|
t.Fatal("build must reject specs above MaxSpecs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2OverlappingSlotsAreAmbiguous(t *testing.T) {
|
||||||
|
b := NewRegistryBuilder(Options{})
|
||||||
|
if err := b.Register(IntentSpec{
|
||||||
|
ID: "demo.slot.overlap", Domain: "demo", Version: 1,
|
||||||
|
Matcher: MatchSpec{IncludesAny: []string{"abcd"}},
|
||||||
|
Slots: []SlotSpec{
|
||||||
|
{Key: "left", Pattern: "(abc)"},
|
||||||
|
{Key: "right", Pattern: "(bcd)"},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg, err := b.Build()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "abcd", reg.Snapshot())
|
||||||
|
if err != nil || res.Top == nil {
|
||||||
|
t.Fatalf("recognize: %+v %v", res, err)
|
||||||
|
}
|
||||||
|
if len(res.Top.Slots) != 0 {
|
||||||
|
t.Fatalf("overlapping slots must not be kept: %+v", res.Top.Slots)
|
||||||
|
}
|
||||||
|
if res.Top.InvalidSlots["left"] != "span overlap" || res.Top.InvalidSlots["right"] != "span overlap" {
|
||||||
|
t.Fatalf("invalid slots: %+v", res.Top.InvalidSlots)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2NegationIsOnlyExcludesAny(t *testing.T) {
|
||||||
|
plain := IntentSpec{
|
||||||
|
ID: "demo.write.file", Domain: "demo", Version: 1,
|
||||||
|
Matcher: MatchSpec{IncludesAny: []string{"写入"}},
|
||||||
|
}
|
||||||
|
excluded := plain
|
||||||
|
excluded.ID = "demo.write.excluded"
|
||||||
|
excluded.Matcher.ExcludesAny = []string{"不要"}
|
||||||
|
rec := NewRecognizer(Options{}, nil, nil)
|
||||||
|
|
||||||
|
plainBuilder := NewRegistryBuilder(Options{})
|
||||||
|
if err := plainBuilder.Register(plain); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
built, err := plainBuilder.Build()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hit, err := rec.Recognize(context.Background(), "不要写入文件", built.Snapshot())
|
||||||
|
if err != nil || hit.Top == nil || hit.Top.ID != plain.ID {
|
||||||
|
t.Fatalf("without excludes_any the negated phrase still matches: %+v %v", hit, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exBuilder := NewRegistryBuilder(Options{})
|
||||||
|
if err := exBuilder.Register(excluded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
builtEx, err := exBuilder.Build()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
miss, err := rec.Recognize(context.Background(), "不要写入文件", builtEx.Snapshot())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if miss.Branch != BranchFallback {
|
||||||
|
t.Fatalf("excludes_any must reject, got %+v", miss)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,10 +63,11 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
|
|||||||
if strings.TrimSpace(input) == "" {
|
if strings.TrimSpace(input) == "" {
|
||||||
return Result{Input: input, Branch: BranchFallback, SnapshotRevision: snap.rev}
|
return Result{Input: input, Branch: BranchFallback, SnapshotRevision: snap.rev}
|
||||||
}
|
}
|
||||||
folded := strings.ToLower(input)
|
// 否定只认 excludes_any 子串,不做语义否定;「不要写入」这类说法交给命令层 Cancel。
|
||||||
|
matchInput, folded := snap.foldInput(input)
|
||||||
|
|
||||||
// Stage 0:守卫命中即短路,可表达"识别即拒绝"。
|
// Stage 0:守卫命中即短路,可表达"识别即拒绝"。
|
||||||
if guardHits := snap.evalGuard(input, folded); len(guardHits) > 0 {
|
if guardHits := snap.evalGuard(matchInput, folded); len(guardHits) > 0 {
|
||||||
cands := candidatesOf(trimHits(guardHits, snap.topN), input, true)
|
cands := candidatesOf(trimHits(guardHits, snap.topN), input, true)
|
||||||
top := cands[0]
|
top := cands[0]
|
||||||
return Result{Input: input, Branch: BranchGuardHit, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
|
return Result{Input: input, Branch: BranchGuardHit, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
|
||||||
@@ -75,7 +76,7 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
|
|||||||
// Stage 1:会话挂起预期优先于一般规则(宿主可选注入)。
|
// Stage 1:会话挂起预期优先于一般规则(宿主可选注入)。
|
||||||
if r.exp != nil {
|
if r.exp != nil {
|
||||||
if pending := r.exp.Pending(ctx); pending != nil && len(pending.IntentIDs) > 0 {
|
if pending := r.exp.Pending(ctx); pending != nil && len(pending.IntentIDs) > 0 {
|
||||||
if hits := snap.evalIDs(pending.IntentIDs, input, folded); len(hits) > 0 {
|
if hits := snap.evalIDs(pending.IntentIDs, matchInput, folded); len(hits) > 0 {
|
||||||
cands := candidatesOf(trimHits(hits, snap.topN), input, true)
|
cands := candidatesOf(trimHits(hits, snap.topN), input, true)
|
||||||
top := cands[0]
|
top := cands[0]
|
||||||
return Result{Input: input, Branch: BranchExpectationHit, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
|
return Result{Input: input, Branch: BranchExpectationHit, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
|
||||||
@@ -84,7 +85,7 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stage 2 + 3:规则计分与置信度裁决。
|
// Stage 2 + 3:规则计分与置信度裁决。
|
||||||
hits := snap.evalRules(input, folded)
|
hits := snap.evalRules(matchInput, folded)
|
||||||
switch {
|
switch {
|
||||||
case len(hits) == 0:
|
case len(hits) == 0:
|
||||||
return Result{Input: input, Branch: BranchFallback, SnapshotRevision: snap.rev}
|
return Result{Input: input, Branch: BranchFallback, SnapshotRevision: snap.rev}
|
||||||
@@ -196,11 +197,11 @@ func (s *Snapshot) Explain(input string) Trace {
|
|||||||
trace.Branch = BranchFallback
|
trace.Branch = BranchFallback
|
||||||
return trace
|
return trace
|
||||||
}
|
}
|
||||||
folded := strings.ToLower(input)
|
matchInput, folded := s.foldInput(input)
|
||||||
|
|
||||||
for _, idx := range s.guardIdx {
|
for _, idx := range s.guardIdx {
|
||||||
cs := s.specs[idx]
|
cs := s.specs[idx]
|
||||||
o := cs.evaluate(input, folded)
|
o := cs.evaluate(matchInput, folded)
|
||||||
if o.passed {
|
if o.passed {
|
||||||
trace.Guard = append(trace.Guard, ScoredSpec{ID: cs.spec.ID, Score: o.score, HitTerms: o.hitTerms, HitPatterns: o.hitPat})
|
trace.Guard = append(trace.Guard, ScoredSpec{ID: cs.spec.ID, Score: o.score, HitTerms: o.hitTerms, HitPatterns: o.hitPat})
|
||||||
} else {
|
} else {
|
||||||
@@ -220,7 +221,7 @@ func (s *Snapshot) Explain(input string) Trace {
|
|||||||
if cs.guard {
|
if cs.guard {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
o := cs.evaluate(input, folded)
|
o := cs.evaluate(matchInput, folded)
|
||||||
if o.passed {
|
if o.passed {
|
||||||
hits = append(hits, specHit{cs: cs, score: o.score, terms: o.hitTerms, pats: o.hitPat})
|
hits = append(hits, specHit{cs: cs, score: o.score, terms: o.hitTerms, pats: o.hitPat})
|
||||||
trace.Rules = append(trace.Rules, ScoredSpec{ID: cs.spec.ID, Score: o.score, HitTerms: o.hitTerms, HitPatterns: o.hitPat})
|
trace.Rules = append(trace.Rules, ScoredSpec{ID: cs.spec.ID, Score: o.score, HitTerms: o.hitTerms, HitPatterns: o.hitPat})
|
||||||
|
|||||||
+14
-10
@@ -38,6 +38,9 @@ type Options struct {
|
|||||||
// SlotValidators 作用域槽位校验器。构建与校验时先查此表,查不到再回落
|
// SlotValidators 作用域槽位校验器。构建与校验时先查此表,查不到再回落
|
||||||
// 已弃用的全局表(RegisterSlotValidator)。不同 Registry 的同名校验器互不影响。
|
// 已弃用的全局表(RegisterSlotValidator)。不同 Registry 的同名校验器互不影响。
|
||||||
SlotValidators map[string]SlotValidator
|
SlotValidators map[string]SlotValidator
|
||||||
|
// NFKC 为真时,词条与输入先做兼容等价归一(全角 ASCII、组合字符),再按
|
||||||
|
// case_sensitive 决定是否小写。NFKC 不做繁简转换。
|
||||||
|
NFKC bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options 字段默认值。
|
// Options 字段默认值。
|
||||||
@@ -173,16 +176,12 @@ func (cs *compiledSpec) evaluate(input, folded string) matchOutcome {
|
|||||||
return matchOutcome{score: math.Round(score*10000) / 10000, passed: score >= cs.threshold, hitTerms: hitTerms, hitPat: hitPat}
|
return matchOutcome{score: math.Round(score*10000) / 10000, passed: score >= cs.threshold, hitTerms: hitTerms, hitPat: hitPat}
|
||||||
}
|
}
|
||||||
|
|
||||||
func compileSpec(spec IntentSpec, guard bool, index int, scoped map[string]SlotValidator) (*compiledSpec, error) {
|
func compileSpec(spec IntentSpec, guard bool, index int, scoped map[string]SlotValidator, nfkc bool) (*compiledSpec, error) {
|
||||||
m := spec.Matcher
|
m := spec.Matcher
|
||||||
fold := func(terms []string) []string {
|
fold := func(terms []string) []string {
|
||||||
out := make([]string, len(terms))
|
out := make([]string, len(terms))
|
||||||
for i, t := range terms {
|
for i, t := range terms {
|
||||||
if m.CaseSensitive {
|
out[i] = foldMatchText(t, nfkc, m.CaseSensitive)
|
||||||
out[i] = t
|
|
||||||
} else {
|
|
||||||
out[i] = strings.ToLower(t)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -239,6 +238,7 @@ type Snapshot struct {
|
|||||||
disabled []string // 已禁用 id(overlay disable 与 spec.disabled 两个入口)
|
disabled []string // 已禁用 id(overlay disable 与 spec.disabled 两个入口)
|
||||||
sources map[string]string
|
sources map[string]string
|
||||||
total int // 含禁用
|
total int // 含禁用
|
||||||
|
nfkc bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revision 返回快照版本号,随每次成功 Reload 递增。
|
// Revision 返回快照版本号,随每次成功 Reload 递增。
|
||||||
@@ -267,10 +267,11 @@ func (s *Snapshot) Source(id string) (string, bool) {
|
|||||||
return src, ok
|
return src, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// SpecByID 返回指定 id 的启用 spec 定义副本。不存在或已禁用时第二个返回值为 false。
|
// SpecByID 返回指定 id 的启用 spec 定义副本。切片与 Metadata 深拷贝,
|
||||||
|
// 调用方改返回值不会改到快照。不存在或已禁用时第二个返回值为 false。
|
||||||
func (s *Snapshot) SpecByID(id string) (IntentSpec, bool) {
|
func (s *Snapshot) SpecByID(id string) (IntentSpec, bool) {
|
||||||
if idx, ok := s.byID[id]; ok {
|
if idx, ok := s.byID[id]; ok {
|
||||||
return s.specs[idx].spec, true
|
return cloneIntentSpec(s.specs[idx].spec), true
|
||||||
}
|
}
|
||||||
return IntentSpec{}, false
|
return IntentSpec{}, false
|
||||||
}
|
}
|
||||||
@@ -367,7 +368,9 @@ func (s *Snapshot) evalIDs(ids []string, input, folded string) []specHit {
|
|||||||
return hits
|
return hits
|
||||||
}
|
}
|
||||||
|
|
||||||
// evalRules 评估全部非守卫 spec,返回过阈命中(已排序)。
|
// evalRules 对全部非守卫 spec 做线性扫描,返回过阈命中(已排序)。
|
||||||
|
// 规模上限是 Options.MaxSpecs(默认 DefaultMaxSpecs = 2048)。倒排索引留到
|
||||||
|
// 超过该上限仍不够用时再做;构建期已经拒绝超过上限的快照。
|
||||||
func (s *Snapshot) evalRules(input, folded string) []specHit {
|
func (s *Snapshot) evalRules(input, folded string) []specHit {
|
||||||
var hits []specHit
|
var hits []specHit
|
||||||
for _, cs := range s.specs {
|
for _, cs := range s.specs {
|
||||||
@@ -632,6 +635,7 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry,
|
|||||||
disabled: disabledList,
|
disabled: disabledList,
|
||||||
sources: sources,
|
sources: sources,
|
||||||
total: total,
|
total: total,
|
||||||
|
nfkc: opts.NFKC,
|
||||||
}
|
}
|
||||||
nextIndex := 0
|
nextIndex := 0
|
||||||
for _, spec := range ordered {
|
for _, spec := range ordered {
|
||||||
@@ -639,7 +643,7 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry,
|
|||||||
snap.disabled = append(snap.disabled, spec.ID)
|
snap.disabled = append(snap.disabled, spec.ID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cs, err := compileSpec(spec, guards[spec.Domain], nextIndex, opts.SlotValidators)
|
cs, err := compileSpec(spec, guards[spec.Domain], nextIndex, opts.SlotValidators, opts.NFKC)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-9
@@ -87,33 +87,82 @@ func (s *compiledSlot) validate(v string) (string, bool) {
|
|||||||
return "", true
|
return "", true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// slotCapture 一次槽位抽取的捕获区间。多槽位各自第一次匹配;区间重叠时
|
||||||
|
// 两边都标 span overlap,不写入 slots。库内不做重叠消解。
|
||||||
|
type slotCapture struct {
|
||||||
|
key string
|
||||||
|
start int
|
||||||
|
end int
|
||||||
|
value string
|
||||||
|
ok bool
|
||||||
|
reason string
|
||||||
|
required bool
|
||||||
|
matched bool
|
||||||
|
}
|
||||||
|
|
||||||
// extractSlots 抽取并归一化槽位。
|
// extractSlots 抽取并归一化槽位。
|
||||||
// missing:必填槽位未捕获(模式没匹配上);invalid:捕获到但校验不合法(key→原因)。
|
// missing:必填槽位未捕获(模式没匹配上);invalid:捕获到但校验不合法,或与另一槽位区间重叠(key→原因)。
|
||||||
// 必填且非法只进 invalid(宿主可区分"没给"与"给错了"来决定澄清话术)。
|
// 必填且非法只进 invalid(宿主可区分"没给"与"给错了"来决定澄清话术)。
|
||||||
func (cs *compiledSpec) extractSlots(input string) (slots map[string]string, missing []string, invalid map[string]string) {
|
func (cs *compiledSpec) extractSlots(input string) (slots map[string]string, missing []string, invalid map[string]string) {
|
||||||
if len(cs.slots) == 0 {
|
if len(cs.slots) == 0 {
|
||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
|
caps := make([]slotCapture, 0, len(cs.slots))
|
||||||
for _, s := range cs.slots {
|
for _, s := range cs.slots {
|
||||||
m := s.re.FindStringSubmatch(input)
|
loc := s.re.FindStringSubmatchIndex(input)
|
||||||
if m == nil || s.group >= len(m) || m[s.group] == "" {
|
g := s.group
|
||||||
if s.required {
|
if loc == nil || 2*g+1 >= len(loc) || loc[2*g] < 0 || loc[2*g] == loc[2*g+1] {
|
||||||
missing = append(missing, s.key)
|
caps = append(caps, slotCapture{key: s.key, required: s.required})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw := input[loc[2*g]:loc[2*g+1]]
|
||||||
|
v := normalizeSlotValue(raw, s.normalize)
|
||||||
|
reason, ok := s.validate(v)
|
||||||
|
caps = append(caps, slotCapture{
|
||||||
|
key: s.key, start: loc[2*g], end: loc[2*g+1], value: v,
|
||||||
|
ok: ok, reason: reason, required: s.required, matched: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
overlap := map[int]bool{}
|
||||||
|
for i := range caps {
|
||||||
|
if !caps[i].matched {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for j := i + 1; j < len(caps); j++ {
|
||||||
|
if !caps[j].matched {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if caps[i].start < caps[j].end && caps[j].start < caps[i].end {
|
||||||
|
overlap[i] = true
|
||||||
|
overlap[j] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, c := range caps {
|
||||||
|
if !c.matched {
|
||||||
|
if c.required {
|
||||||
|
missing = append(missing, c.key)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
v := normalizeSlotValue(m[s.group], s.normalize)
|
if overlap[i] {
|
||||||
if reason, ok := s.validate(v); !ok {
|
|
||||||
if invalid == nil {
|
if invalid == nil {
|
||||||
invalid = map[string]string{}
|
invalid = map[string]string{}
|
||||||
}
|
}
|
||||||
invalid[s.key] = reason
|
invalid[c.key] = "span overlap"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !c.ok {
|
||||||
|
if invalid == nil {
|
||||||
|
invalid = map[string]string{}
|
||||||
|
}
|
||||||
|
invalid[c.key] = c.reason
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if slots == nil {
|
if slots == nil {
|
||||||
slots = map[string]string{}
|
slots = map[string]string{}
|
||||||
}
|
}
|
||||||
slots[s.key] = v
|
slots[c.key] = c.value
|
||||||
}
|
}
|
||||||
return slots, missing, invalid
|
return slots, missing, invalid
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user