Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70b39a9f70 | ||
|
|
765a7e7177 |
@@ -33,7 +33,7 @@
|
||||
(baseline 硬边界 2:只记录来源、字段与可用状态)。
|
||||
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
|
||||
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
|
||||
`~/.ssh/config` 已有 `gitea.naitangbaobao.cn(端口 52222)` 主机条目,克隆/推送直接可用。
|
||||
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
|
||||
|
||||
## Agent 执行与新项目准入(强制)
|
||||
|
||||
|
||||
+4
-3
@@ -33,6 +33,7 @@ type CatalogSpec struct {
|
||||
|
||||
// Catalog 返回能力目录:全部启用的非守卫 spec,按合并序(确定性)。
|
||||
// 守卫域 spec 是拦截器而非可调用能力,不进目录;需要全量时用 SpecIDs/SpecByID。
|
||||
// 返回值为深拷贝:调用方改切片/枚举/metadata 不会改到快照。
|
||||
func (s *Snapshot) Catalog() []CatalogSpec {
|
||||
out := make([]CatalogSpec, 0, len(s.specs))
|
||||
for _, cs := range s.specs {
|
||||
@@ -55,7 +56,7 @@ func (s *Snapshot) Catalog() []CatalogSpec {
|
||||
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),
|
||||
Enum: cloneStrings(sl.enum), Format: specSlotFormat(cs.spec, sl.key),
|
||||
})
|
||||
}
|
||||
out = append(out, view)
|
||||
@@ -124,7 +125,7 @@ func specSlotFormat(spec IntentSpec, key string) string {
|
||||
|
||||
// CatalogTopK 取目录的前 k 条:按 Priority 降序、ID 升序(确定性排序;
|
||||
// 排序口径是 priority,不是语义相关度——相关度排序由宿主经 examples 自行计算)。
|
||||
// k <= 0 时等同 Catalog() 全量。
|
||||
// k <= 0 时等同 Catalog() 全量。返回值与 Catalog 一样是深拷贝,不共享底层数组。
|
||||
func (s *Snapshot) CatalogTopK(k int) []CatalogSpec {
|
||||
all := s.Catalog()
|
||||
if k <= 0 || len(all) <= k {
|
||||
@@ -141,5 +142,5 @@ func (s *Snapshot) CatalogTopK(k int) []CatalogSpec {
|
||||
}
|
||||
return all[i].ID < all[j].ID
|
||||
})
|
||||
return all[:k]
|
||||
return cloneCatalogSpecs(all[:k])
|
||||
}
|
||||
|
||||
+85
-4
@@ -34,6 +34,26 @@ func cloneStrings(in []string) []string {
|
||||
return append([]string(nil), in...)
|
||||
}
|
||||
|
||||
func cloneStringMap(in map[string]string) map[string]string {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneActionRef(in *ActionRef) *ActionRef {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *in
|
||||
return &cp
|
||||
}
|
||||
|
||||
// cloneIntentSpec 深拷贝 IntentSpec,切断切片/map/指针与调用方或快照内部的共享。
|
||||
func cloneIntentSpec(in IntentSpec) IntentSpec {
|
||||
out := in
|
||||
out.Matcher.IncludesAny = cloneStrings(in.Matcher.IncludesAny)
|
||||
@@ -47,15 +67,76 @@ func cloneIntentSpec(in IntentSpec) IntentSpec {
|
||||
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
|
||||
out.Metadata = cloneStringMap(in.Metadata)
|
||||
out.Action = cloneActionRef(in.Action)
|
||||
out.Examples = cloneStrings(in.Examples)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneCatalogSlot(in CatalogSlot) CatalogSlot {
|
||||
out := in
|
||||
out.Enum = cloneStrings(in.Enum)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneCatalogSpec(in CatalogSpec) CatalogSpec {
|
||||
out := in
|
||||
out.Metadata = cloneStringMap(in.Metadata)
|
||||
if in.Slots != nil {
|
||||
out.Slots = make([]CatalogSlot, len(in.Slots))
|
||||
for i := range in.Slots {
|
||||
out.Slots[i] = cloneCatalogSlot(in.Slots[i])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneCatalogSpecs(in []CatalogSpec) []CatalogSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]CatalogSpec, len(in))
|
||||
for i := range in {
|
||||
out[i] = cloneCatalogSpec(in[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneOptions(in Options) Options {
|
||||
out := in
|
||||
out.ProtectedDomains = cloneStrings(in.ProtectedDomains)
|
||||
out.GuardDomains = cloneStrings(in.GuardDomains)
|
||||
if in.SlotValidators != nil {
|
||||
cp := make(map[string]SlotValidator, len(in.SlotValidators))
|
||||
for k, v := range in.SlotValidators {
|
||||
cp[k] = v
|
||||
}
|
||||
out.SlotValidators = cp
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneCandidate(in Candidate) Candidate {
|
||||
out := in
|
||||
out.Metadata = cloneStringMap(in.Metadata)
|
||||
out.Slots = cloneStringMap(in.Slots)
|
||||
out.MissingRequiredSlots = cloneStrings(in.MissingRequiredSlots)
|
||||
out.InvalidSlots = cloneStringMap(in.InvalidSlots)
|
||||
out.Action = cloneActionRef(in.Action)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneCandidates(in []Candidate) []Candidate {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Candidate, len(in))
|
||||
for i := range in {
|
||||
out[i] = cloneCandidate(in[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// catalogMetadataAllow 是目录与 Markdown 允许带出的 metadata 键。
|
||||
// handler 仍留在 CatalogSpec 供执行器按名分派,但不写入 Markdown。
|
||||
var catalogMetadataAllow = map[string]bool{
|
||||
|
||||
+4
-4
@@ -7,10 +7,10 @@ import "context"
|
||||
// 只在规则与语义都未命中(fallback)时被调用;实现自担延迟与成本。
|
||||
// 返回错误时识别保持 fallback,不会使 Recognize 失败。
|
||||
//
|
||||
// 库侧校验与 deriveCommands 同款:start 命令的 ActionRef 必须可解析——
|
||||
// auto/command/workflow/blueprint 的目标必须在 catalog 的 spec ID 集合内,
|
||||
// handler 的目标必须在 catalog 的 Handler 集合内;槽位键必须在目标 spec 的
|
||||
// 槽位键集合内(可解析时)。生成器幻觉一律 fail-closed 回 fallback。
|
||||
// 库侧校验:start 的 ActionRef 必须能从同一快照唯一解析回权威 spec
|
||||
// (spec ID、Action 别名、或唯一 handler 反查);槽位键必须在目标 spec
|
||||
// 键集内,随后跑与规则路径同款的必填/枚举/format 校验并挂上元数据。
|
||||
// 解析失败或未知键一律 fail-closed 回 fallback。
|
||||
type CommandGenerator interface {
|
||||
// Generate 基于输入与能力目录产出命令列表(catalog 由调用方按 top-k 给)。
|
||||
Generate(ctx context.Context, input string, catalog []CatalogSpec) ([]Command, error)
|
||||
|
||||
+137
-61
@@ -120,7 +120,8 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
|
||||
}
|
||||
}
|
||||
if r.cls != nil {
|
||||
refined, err := r.cls.Refine(ctx, input, cands)
|
||||
// 传入候选副本:分类器就地改写不会污染原候选(失败时仍返回未篡改集)。
|
||||
refined, err := r.cls.Refine(ctx, input, cloneCandidates(cands))
|
||||
if err != nil {
|
||||
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}
|
||||
@@ -175,10 +176,11 @@ func validateRefined(original, refined []Candidate) ([]Candidate, error) {
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown refined id %s", got.ID)
|
||||
}
|
||||
if !validConfidence(got.Confidence) {
|
||||
norm, ok := normalizeScore(got.Confidence)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("confidence out of range for %s", got.ID)
|
||||
}
|
||||
src.Confidence = got.Confidence
|
||||
src.Confidence = norm
|
||||
out[i] = src
|
||||
}
|
||||
return out, nil
|
||||
@@ -188,6 +190,15 @@ func validConfidence(v float64) bool {
|
||||
return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v <= 1
|
||||
}
|
||||
|
||||
// normalizeScore 拒绝非有限/越界分数,并通过与规则计分同款的定点舍入后再交给阈值比较,
|
||||
// 防止篡改或噪声分数绕过阈值进入 matched。
|
||||
func normalizeScore(v float64) (float64, bool) {
|
||||
if !validConfidence(v) {
|
||||
return 0, false
|
||||
}
|
||||
return math.Round(v*10000) / 10000, true
|
||||
}
|
||||
|
||||
// adjudicateRefined 对分类器结果重排后应用同一裁决规则;分差仍贴近则返回 false。
|
||||
func adjudicateRefined(refined []Candidate, snap *Snapshot) (Candidate, bool) {
|
||||
if len(refined) == 0 {
|
||||
@@ -289,6 +300,7 @@ func sortRejected(rs []RejectedSpec) {
|
||||
// 规则:候选必须来自当前候选集(构造上不可能引入未知 ID);
|
||||
// 只取相似度 ≥ 阈值的候选——唯一过阈或前两名分差 ≥ margin 才判 matched,
|
||||
// 否则保持 ambiguous(与规则裁决同款语义)。打分错误一律记日志并保持 ambiguous。
|
||||
// examples 以副本传入打分器;非有限/越界分数拒绝;定点舍入后再与阈值比较。
|
||||
func (r *FunnelRecognizer) semanticRerank(ctx context.Context, input string, cands []Candidate, snap *Snapshot) (Candidate, bool) {
|
||||
type scored struct {
|
||||
cand Candidate
|
||||
@@ -300,14 +312,21 @@ func (r *FunnelRecognizer) semanticRerank(ctx context.Context, input string, can
|
||||
if !ok || len(spec.Examples) == 0 {
|
||||
continue
|
||||
}
|
||||
s, err := r.sem.Score(ctx, input, spec.Examples)
|
||||
examples := cloneStrings(spec.Examples)
|
||||
s, err := r.sem.Score(ctx, input, examples)
|
||||
if err != nil {
|
||||
r.log.Module("intent").WithContext(ctx).Warn("intent semantic scorer failed; keep ambiguous",
|
||||
"error", err.Error(), "id", c.ID)
|
||||
continue
|
||||
}
|
||||
if s >= r.thr {
|
||||
above = append(above, scored{cand: c, score: s})
|
||||
norm, ok := normalizeScore(s)
|
||||
if !ok {
|
||||
r.log.Module("intent").WithContext(ctx).Warn("intent semantic score rejected; keep ambiguous",
|
||||
"id", c.ID, "score", s)
|
||||
continue
|
||||
}
|
||||
if norm >= r.thr {
|
||||
above = append(above, scored{cand: c, score: norm})
|
||||
}
|
||||
}
|
||||
if len(above) == 0 {
|
||||
@@ -327,9 +346,9 @@ func (r *FunnelRecognizer) semanticRerank(ctx context.Context, input string, can
|
||||
}
|
||||
|
||||
// generateCommands 经模型命令生成端口兜底(I2)。校验:
|
||||
// start 的 ActionRef 可解析(spec ID 集或 Handler 集内)、命令种类受限、
|
||||
// 槽位键在目标 spec 的键集内(可解析时);全部通过则把首个 start 的目标
|
||||
// spec 合成 Top(refined_by=generator 标注),任一项不过则保持 fallback。
|
||||
// start 的 ActionRef 必须能从同一快照唯一解析回权威 spec(含 Action 别名与
|
||||
// handler→spec 反查)、命令种类受限、槽位键合法;解析成功后对生成槽位跑与
|
||||
// 规则路径同款的全量校验并挂上 risk/confirm/action 元数据。任一项不过则保持 fallback(无 Top)。
|
||||
func (r *FunnelRecognizer) generateCommands(ctx context.Context, input string, snap *Snapshot) (Result, bool) {
|
||||
catalog := snap.CatalogTopK(DefaultGeneratorCatalogK)
|
||||
cmds, err := r.gen.Generate(ctx, input, catalog)
|
||||
@@ -337,7 +356,7 @@ func (r *FunnelRecognizer) generateCommands(ctx context.Context, input string, s
|
||||
r.log.Module("intent").WithContext(ctx).Warn("intent command generator failed; keep fallback", "error", err.Error())
|
||||
return Result{}, false
|
||||
}
|
||||
if err := validateGenerated(cmds, catalog); err != nil {
|
||||
if err := validateGenerated(cmds, snap); err != nil {
|
||||
r.log.Module("intent").WithContext(ctx).Warn("intent generator output rejected; keep fallback", "error", err.Error())
|
||||
return Result{}, false
|
||||
}
|
||||
@@ -346,16 +365,14 @@ func (r *FunnelRecognizer) generateCommands(ctx context.Context, input string, s
|
||||
if c.Kind != CommandStart {
|
||||
continue
|
||||
}
|
||||
if spec, ok := generatedSpecOf(c.Action, catalog); ok {
|
||||
top := Candidate{
|
||||
ID: spec.ID, Domain: spec.Domain, Version: spec.Version,
|
||||
Confidence: 0, Reason: "generated_by=command_generator",
|
||||
Slots: c.Slots,
|
||||
}
|
||||
res.Top = &top
|
||||
res.Branch = BranchMatched
|
||||
res.Candidates = []Candidate{top}
|
||||
cs, err := snap.resolveActionRef(c.Action)
|
||||
if err != nil || cs == nil {
|
||||
break
|
||||
}
|
||||
top := candidateFromGenerated(cs, c.Slots)
|
||||
res.Top = &top
|
||||
res.Branch = BranchMatched
|
||||
res.Candidates = []Candidate{top}
|
||||
break
|
||||
}
|
||||
return res, true
|
||||
@@ -364,46 +381,53 @@ func (r *FunnelRecognizer) generateCommands(ctx context.Context, input string, s
|
||||
// DefaultGeneratorCatalogK 生成器目录默认 top-k。
|
||||
const DefaultGeneratorCatalogK = 8
|
||||
|
||||
// candidateFromGenerated 用权威 spec 元数据 + 生成槽位的全量校验合成 Top(与规则路径同款字段)。
|
||||
func candidateFromGenerated(cs *compiledSpec, provided map[string]string) Candidate {
|
||||
c := Candidate{
|
||||
ID: cs.spec.ID,
|
||||
Domain: cs.spec.Domain,
|
||||
Version: cs.spec.Version,
|
||||
Confidence: 0,
|
||||
Priority: cs.spec.Priority,
|
||||
Risk: cs.spec.Risk,
|
||||
RequiresConfirm: cs.spec.RequiresConfirm,
|
||||
Handler: cs.spec.Handler,
|
||||
Reason: "generated_by=command_generator",
|
||||
}
|
||||
if c.Risk == "" {
|
||||
c.Risk = RiskLow
|
||||
}
|
||||
if cs.spec.Action != nil {
|
||||
ref := *cs.spec.Action
|
||||
c.Action = &ref
|
||||
}
|
||||
if len(cs.spec.Metadata) > 0 {
|
||||
c.Metadata = cloneStringMap(cs.spec.Metadata)
|
||||
}
|
||||
c.Slots, c.MissingRequiredSlots, c.InvalidSlots = cs.validateProvidedSlots(provided)
|
||||
return c
|
||||
}
|
||||
|
||||
// validateGenerated 校验生成命令(I2;与 spec 级校验同款 fail-closed)。
|
||||
func validateGenerated(cmds []Command, catalog []CatalogSpec) error {
|
||||
// ActionRef 经快照唯一解析;槽位未知键在此拒绝(与规则路径「键集外」同款 fail-closed)。
|
||||
func validateGenerated(cmds []Command, snap *Snapshot) error {
|
||||
if len(cmds) == 0 {
|
||||
return fmt.Errorf("empty command list")
|
||||
}
|
||||
specIDs := map[string]bool{}
|
||||
handlers := map[string]bool{}
|
||||
slotKeys := map[string]map[string]bool{}
|
||||
for _, cs := range catalog {
|
||||
specIDs[cs.ID] = true
|
||||
if cs.Handler != "" {
|
||||
handlers[cs.Handler] = true
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
for _, sl := range cs.Slots {
|
||||
keys[sl.Key] = true
|
||||
}
|
||||
slotKeys[cs.ID] = keys
|
||||
}
|
||||
for i, c := range cmds {
|
||||
switch c.Kind {
|
||||
case CommandStart:
|
||||
ref := c.Action
|
||||
switch ref.Kind {
|
||||
case ActionAuto, ActionCommand, ActionWorkflow, ActionBlueprint:
|
||||
if !specIDs[ref.ID] {
|
||||
return fmt.Errorf("cmd[%d] start: action.id %q 不在目录内", i, ref.ID)
|
||||
}
|
||||
case ActionHandler:
|
||||
if !handlers[ref.ID] {
|
||||
return fmt.Errorf("cmd[%d] start: handler %q 不在目录内", i, ref.ID)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("cmd[%d] start: 未知 action.kind %q", i, ref.Kind)
|
||||
cs, err := snap.resolveActionRef(c.Action)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cmd[%d] start: %w", i, err)
|
||||
}
|
||||
if keys, ok := slotKeys[ref.ID]; ok {
|
||||
for k := range c.Slots {
|
||||
if !keys[k] {
|
||||
return fmt.Errorf("cmd[%d] start: 槽位 %q 不在 %s 的槽位键集内", i, k, ref.ID)
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
for _, sl := range cs.slots {
|
||||
keys[sl.key] = true
|
||||
}
|
||||
for k := range c.Slots {
|
||||
if !keys[k] {
|
||||
return fmt.Errorf("cmd[%d] start: 槽位 %q 不在 %s 的槽位键集内", i, k, cs.spec.ID)
|
||||
}
|
||||
}
|
||||
case CommandClarify, CommandCancel, CommandSetSlot, CommandChangeSlot, CommandChitchat, CommandHandoff:
|
||||
@@ -414,18 +438,70 @@ func validateGenerated(cmds []Command, catalog []CatalogSpec) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// generatedSpecOf 把 start 的 ActionRef 解析回目录 spec(handler 键按 Handler 反查)。
|
||||
func generatedSpecOf(ref ActionRef, catalog []CatalogSpec) (CatalogSpec, bool) {
|
||||
for _, cs := range catalog {
|
||||
if ref.Kind == ActionHandler {
|
||||
if cs.Handler == ref.ID {
|
||||
return cs, true
|
||||
}
|
||||
// resolveActionRef 把 start 的 ActionRef 解析回快照内唯一权威 compiledSpec。
|
||||
// ActionCommand/Workflow/Blueprint/Auto:先按 spec ID,再按 spec.Action 声明的别名;
|
||||
// ActionHandler:经 handler→spec 反查,重名拒绝。
|
||||
func (s *Snapshot) resolveActionRef(ref ActionRef) (*compiledSpec, error) {
|
||||
switch ref.Kind {
|
||||
case ActionHandler:
|
||||
return s.resolveByHandler(ref.ID)
|
||||
case ActionAuto, ActionCommand, ActionWorkflow, ActionBlueprint:
|
||||
return s.resolveBySpecOrAlias(ref)
|
||||
default:
|
||||
return nil, fmt.Errorf("未知 action.kind %q", ref.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Snapshot) resolveByHandler(handler string) (*compiledSpec, error) {
|
||||
if strings.TrimSpace(handler) == "" {
|
||||
return nil, fmt.Errorf("handler 为空")
|
||||
}
|
||||
var found *compiledSpec
|
||||
for _, cs := range s.specs {
|
||||
if cs.guard {
|
||||
continue
|
||||
}
|
||||
if cs.ID == ref.ID {
|
||||
return cs, true
|
||||
if cs.spec.Handler != handler {
|
||||
continue
|
||||
}
|
||||
if found != nil {
|
||||
return nil, fmt.Errorf("handler %q 对应多个 spec(歧义)", handler)
|
||||
}
|
||||
found = cs
|
||||
}
|
||||
return CatalogSpec{}, false
|
||||
if found == nil {
|
||||
return nil, fmt.Errorf("handler %q 不在快照内", handler)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (s *Snapshot) resolveBySpecOrAlias(ref ActionRef) (*compiledSpec, error) {
|
||||
if strings.TrimSpace(ref.ID) == "" {
|
||||
return nil, fmt.Errorf("action.id 为空")
|
||||
}
|
||||
var found *compiledSpec
|
||||
for _, cs := range s.specs {
|
||||
if cs.guard {
|
||||
continue
|
||||
}
|
||||
match := cs.spec.ID == ref.ID
|
||||
if !match && cs.spec.Action != nil && cs.spec.Action.Kind == ref.Kind && cs.spec.Action.ID == ref.ID {
|
||||
match = true
|
||||
}
|
||||
// ActionAuto 也接受「合法别名」:显式 Action 的 ID 与 kind 匹配任一声明。
|
||||
if !match && ref.Kind == ActionAuto && cs.spec.Action != nil && cs.spec.Action.ID == ref.ID {
|
||||
match = true
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
if found != nil && found != cs {
|
||||
return nil, fmt.Errorf("action.id %q 对应多个 spec(歧义)", ref.ID)
|
||||
}
|
||||
found = cs
|
||||
}
|
||||
if found == nil {
|
||||
return nil, fmt.Errorf("action.id %q 不在快照内", ref.ID)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
+17
-16
@@ -60,7 +60,7 @@ const (
|
||||
)
|
||||
|
||||
func (o Options) normalized() Options {
|
||||
n := o
|
||||
n := cloneOptions(o)
|
||||
if n.TopN <= 0 {
|
||||
n.TopN = DefaultTopN
|
||||
}
|
||||
@@ -85,13 +85,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -203,7 +196,7 @@ func compileSpec(spec IntentSpec, guard bool, index int, scoped map[string]SlotV
|
||||
return out
|
||||
}
|
||||
cs := &compiledSpec{
|
||||
spec: spec,
|
||||
spec: cloneIntentSpec(spec),
|
||||
termsAny: fold(m.IncludesAny),
|
||||
termsAll: fold(m.IncludesAll),
|
||||
termsEx: fold(m.ExcludesAny),
|
||||
@@ -481,6 +474,7 @@ func NewRegistryBuilder(opts Options) *RegistryBuilder {
|
||||
}
|
||||
|
||||
// Register 登记内置基线 spec,立即校验;重复 id 报 InvalidSpec。
|
||||
// 登记时深拷贝:调用方之后改入参不会影响已登记定义。
|
||||
func (b *RegistryBuilder) Register(specs ...IntentSpec) error {
|
||||
lim := b.opts.limits()
|
||||
for _, spec := range specs {
|
||||
@@ -491,7 +485,7 @@ func (b *RegistryBuilder) Register(specs ...IntentSpec) error {
|
||||
return err
|
||||
}
|
||||
b.seen[spec.ID] = true
|
||||
b.builtin = append(b.builtin, spec)
|
||||
b.builtin = append(b.builtin, cloneIntentSpec(spec))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -526,9 +520,13 @@ func (b *RegistryBuilder) buildWithSource(fsys fs.FS, roots []string) (*Registry
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
builtin := make([]IntentSpec, len(b.builtin))
|
||||
for i, spec := range b.builtin {
|
||||
builtin[i] = cloneIntentSpec(spec)
|
||||
}
|
||||
r := &Registry{
|
||||
opts: b.opts,
|
||||
builtin: append([]IntentSpec(nil), b.builtin...),
|
||||
opts: cloneOptions(b.opts),
|
||||
builtin: builtin,
|
||||
fsys: fsys,
|
||||
roots: append([]string(nil), roots...),
|
||||
hasSource: fsys != nil,
|
||||
@@ -553,8 +551,8 @@ type Registry struct {
|
||||
// Snapshot 返回当前生效快照。永不为 nil。
|
||||
func (r *Registry) Snapshot() *Snapshot { return r.snap.Load() }
|
||||
|
||||
// Options 返回生效选项(已填默认值)。
|
||||
func (r *Registry) Options() Options { return r.opts }
|
||||
// Options 返回生效选项(已填默认值)的深拷贝:改切片/校验器表不影响 Registry。
|
||||
func (r *Registry) Options() Options { return cloneOptions(r.opts) }
|
||||
|
||||
// Reload 重读配置来源并构建新快照;成功后原子替换并返回新快照。
|
||||
// 任一步失败保留旧快照,revision 不变,返回带来源与原因的错误。
|
||||
@@ -590,7 +588,10 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry,
|
||||
guards := opts.guardSet()
|
||||
lim := opts.limits()
|
||||
|
||||
ordered := append([]IntentSpec(nil), baseline...)
|
||||
ordered := make([]IntentSpec, len(baseline))
|
||||
for i, spec := range baseline {
|
||||
ordered[i] = cloneIntentSpec(spec)
|
||||
}
|
||||
pos := make(map[string]int, len(ordered))
|
||||
sources := make(map[string]string, len(ordered))
|
||||
for i, spec := range ordered {
|
||||
@@ -620,7 +621,7 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry,
|
||||
sources[e.id] = e.source + " (disabled)"
|
||||
continue
|
||||
}
|
||||
spec := e.spec
|
||||
spec := cloneIntentSpec(e.spec)
|
||||
if err := validateSpec(spec, lim, opts.SlotValidators); err != nil {
|
||||
return nil, err.(*Error).with("source", e.source)
|
||||
}
|
||||
|
||||
@@ -167,6 +167,56 @@ func (cs *compiledSpec) extractSlots(input string) (slots map[string]string, mis
|
||||
return slots, missing, invalid
|
||||
}
|
||||
|
||||
// validateProvidedSlots 校验调用方直接给出的槽位表(生成器路径)。
|
||||
// 与 extractSlots 同款结果形态:归一化 → format/enum/validator;必填缺失进 missing;
|
||||
// 捕获到但不合法进 invalid;未知键进 invalid(reason=unknown slot key)。
|
||||
// 非法或未知键的值不写入 slots。
|
||||
func (cs *compiledSpec) validateProvidedSlots(provided map[string]string) (slots map[string]string, missing []string, invalid map[string]string) {
|
||||
byKey := make(map[string]*compiledSlot, len(cs.slots))
|
||||
for i := range cs.slots {
|
||||
byKey[cs.slots[i].key] = &cs.slots[i]
|
||||
}
|
||||
for k, raw := range provided {
|
||||
s, ok := byKey[k]
|
||||
if !ok {
|
||||
if invalid == nil {
|
||||
invalid = map[string]string{}
|
||||
}
|
||||
invalid[k] = "unknown slot key"
|
||||
continue
|
||||
}
|
||||
v := normalizeSlotValue(raw, s.normalize)
|
||||
reason, valid := s.validate(v)
|
||||
if !valid {
|
||||
if invalid == nil {
|
||||
invalid = map[string]string{}
|
||||
}
|
||||
invalid[k] = reason
|
||||
continue
|
||||
}
|
||||
if slots == nil {
|
||||
slots = map[string]string{}
|
||||
}
|
||||
slots[k] = v
|
||||
}
|
||||
for _, s := range cs.slots {
|
||||
if !s.required {
|
||||
continue
|
||||
}
|
||||
if _, ok := slots[s.key]; ok {
|
||||
continue
|
||||
}
|
||||
// 必填且已标 invalid 的不重复进 missing(与 extractSlots 同款)。
|
||||
if invalid != nil {
|
||||
if _, bad := invalid[s.key]; bad {
|
||||
continue
|
||||
}
|
||||
}
|
||||
missing = append(missing, s.key)
|
||||
}
|
||||
return slots, missing, invalid
|
||||
}
|
||||
|
||||
func compileSlot(spec SlotSpec, scoped map[string]SlotValidator) (compiledSlot, error) {
|
||||
re, err := regexp.Compile(spec.Pattern)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestU5RegisterMutationDoesNotAffectSnapshot:登记后改原 spec 不影响 SpecByID/Catalog。
|
||||
func TestU5RegisterMutationDoesNotAffectSnapshot(t *testing.T) {
|
||||
spec := IntentSpec{
|
||||
ID: "demo.iso.reg", Domain: "demo", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"隔离登记"}},
|
||||
Risk: RiskHigh,
|
||||
RequiresConfirm: true,
|
||||
Handler: "iso.handler",
|
||||
Examples: []string{"示例甲"},
|
||||
Action: &ActionRef{Kind: ActionWorkflow, ID: "wf-iso"},
|
||||
Slots: []SlotSpec{{
|
||||
Key: "env", Pattern: `env[: ]*(\w+)`, Group: 1,
|
||||
Enum: []string{"prod", "dev"}, Normalize: []string{"trim"},
|
||||
}},
|
||||
Metadata: map[string]string{"guidance": "keep", "token": "secret"},
|
||||
}
|
||||
b := NewRegistryBuilder(Options{ProtectedDomains: []string{"demo"}, GuardDomains: []string{"guard"}})
|
||||
if err := b.Register(spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 登记后篡改原对象与嵌套可变字段。
|
||||
spec.Risk = RiskLow
|
||||
spec.RequiresConfirm = false
|
||||
spec.Handler = "tampered"
|
||||
spec.Examples[0] = "被改"
|
||||
spec.Action.ID = "wf-tampered"
|
||||
spec.Slots[0].Enum[0] = "evil"
|
||||
spec.Metadata["guidance"] = "mutated"
|
||||
spec.Matcher.IncludesAny[0] = "被改词"
|
||||
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := reg.Snapshot()
|
||||
got, ok := snap.SpecByID("demo.iso.reg")
|
||||
if !ok {
|
||||
t.Fatal("missing spec")
|
||||
}
|
||||
if got.Risk != RiskHigh || !got.RequiresConfirm || got.Handler != "iso.handler" {
|
||||
t.Fatalf("register isolation broken: %+v", got)
|
||||
}
|
||||
if got.Examples[0] != "示例甲" || got.Action == nil || got.Action.ID != "wf-iso" {
|
||||
t.Fatalf("nested isolation broken: examples=%v action=%+v", got.Examples, got.Action)
|
||||
}
|
||||
if got.Slots[0].Enum[0] != "prod" || got.Metadata["guidance"] != "keep" {
|
||||
t.Fatalf("slot/meta isolation broken: %+v", got)
|
||||
}
|
||||
cat := snap.Catalog()
|
||||
if len(cat) != 1 || cat[0].Handler != "iso.handler" || cat[0].Slots[0].Enum[0] != "prod" {
|
||||
t.Fatalf("catalog isolation after register mutate: %+v", cat)
|
||||
}
|
||||
opts := reg.Options()
|
||||
opts.ProtectedDomains[0] = "hacked"
|
||||
opts2 := reg.Options()
|
||||
if opts2.ProtectedDomains[0] != "demo" {
|
||||
t.Fatalf("Options isolation broken: %v", opts2.ProtectedDomains)
|
||||
}
|
||||
}
|
||||
|
||||
// TestU5ReturnedSpecMutationDoesNotAffectRegistry:改返回值不影响快照。
|
||||
func TestU5ReturnedSpecMutationDoesNotAffectRegistry(t *testing.T) {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(IntentSpec{
|
||||
ID: "demo.iso.ret", Domain: "demo", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"隔离返回"}, Patterns: []string{`隔离返回`}},
|
||||
Examples: []string{"例"},
|
||||
Action: &ActionRef{Kind: ActionCommand, ID: "cmd.iso"},
|
||||
Slots: []SlotSpec{{Key: "x", Pattern: `(x)`, Enum: []string{"a", "b"}}},
|
||||
Metadata: map[string]string{"guidance": "g"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := reg.Snapshot()
|
||||
got, _ := snap.SpecByID("demo.iso.ret")
|
||||
got.Examples[0] = "mut"
|
||||
got.Action.ID = "cmd.mut"
|
||||
got.Slots[0].Enum[0] = "z"
|
||||
got.Metadata["guidance"] = "mut"
|
||||
got.Matcher.Patterns[0] = "mut"
|
||||
|
||||
again, _ := snap.SpecByID("demo.iso.ret")
|
||||
if again.Examples[0] != "例" || again.Action.ID != "cmd.iso" || again.Slots[0].Enum[0] != "a" {
|
||||
t.Fatalf("SpecByID leak: %+v", again)
|
||||
}
|
||||
if again.Metadata["guidance"] != "g" || again.Matcher.Patterns[0] != `隔离返回` {
|
||||
t.Fatalf("SpecByID matcher/meta leak: %+v", again)
|
||||
}
|
||||
|
||||
cat := snap.Catalog()
|
||||
cat[0].Slots[0].Enum[0] = "z"
|
||||
cat[0].Metadata["guidance"] = "mut"
|
||||
cat2 := snap.Catalog()
|
||||
if cat2[0].Slots[0].Enum[0] != "a" || cat2[0].Metadata["guidance"] != "g" {
|
||||
t.Fatalf("Catalog leak: %+v", cat2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestU5GeneratedMatchesRuleValidationAndMetadata:同 action+slots 时生成路径与规则路径校验/元数据一致。
|
||||
func TestU5GeneratedMatchesRuleValidationAndMetadata(t *testing.T) {
|
||||
spec := IntentSpec{
|
||||
ID: "report.generate", Domain: "report", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
|
||||
Slots: []SlotSpec{{
|
||||
Key: "topic", Required: true, Pattern: `主题是(.+)`, Group: 1,
|
||||
Enum: []string{"电机", "泵"}, Normalize: []string{"trim"},
|
||||
}},
|
||||
Risk: RiskMedium,
|
||||
RequiresConfirm: true,
|
||||
Handler: "reportHandler",
|
||||
Action: &ActionRef{Kind: ActionWorkflow, ID: "wf-report"},
|
||||
Metadata: map[string]string{"guidance": "ask topic", "blueprint": "bp"},
|
||||
}
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := reg.Snapshot()
|
||||
rec := NewRecognizer(Options{}, nil, nil)
|
||||
rule, err := rec.Recognize(context.Background(), "生成报告 主题是电机", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rule.Branch != BranchMatched || rule.Top == nil {
|
||||
t.Fatalf("rule: %+v", rule)
|
||||
}
|
||||
|
||||
gen := stubGenerator{cmds: []Command{
|
||||
{Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "wf-report"},
|
||||
Slots: map[string]string{"topic": "电机"}},
|
||||
}}
|
||||
genRec := NewRecognizer(Options{Generator: gen}, nil, nil)
|
||||
out, err := genRec.Recognize(context.Background(), "完全不相关的一句话", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Branch != BranchMatched || out.Top == nil {
|
||||
t.Fatalf("generated: %+v", out)
|
||||
}
|
||||
if out.Top.ID != rule.Top.ID || out.Top.Risk != rule.Top.Risk || out.Top.RequiresConfirm != rule.Top.RequiresConfirm {
|
||||
t.Fatalf("metadata mismatch rule=%+v gen=%+v", rule.Top, out.Top)
|
||||
}
|
||||
if out.Top.Handler != rule.Top.Handler {
|
||||
t.Fatalf("handler mismatch rule=%q gen=%q", rule.Top.Handler, out.Top.Handler)
|
||||
}
|
||||
if out.Top.Action == nil || rule.Top.Action == nil || out.Top.Action.Kind != rule.Top.Action.Kind || out.Top.Action.ID != rule.Top.Action.ID {
|
||||
t.Fatalf("action mismatch rule=%+v gen=%+v", rule.Top.Action, out.Top.Action)
|
||||
}
|
||||
if out.Top.Slots["topic"] != rule.Top.Slots["topic"] {
|
||||
t.Fatalf("slots mismatch rule=%v gen=%v", rule.Top.Slots, out.Top.Slots)
|
||||
}
|
||||
if len(out.Top.MissingRequiredSlots) != 0 || len(out.Top.InvalidSlots) != 0 {
|
||||
t.Fatalf("unexpected missing/invalid: %+v", out.Top)
|
||||
}
|
||||
}
|
||||
|
||||
// TestU5GeneratedSlotValidationStable:缺必填/非法枚举/未知键/重复 handler 在生成路径稳定处理。
|
||||
func TestU5GeneratedSlotValidationStable(t *testing.T) {
|
||||
mk := func(t *testing.T, specs ...IntentSpec) *Snapshot {
|
||||
t.Helper()
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(specs...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reg.Snapshot()
|
||||
}
|
||||
base := IntentSpec{
|
||||
ID: "report.generate", Domain: "report", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
|
||||
Slots: []SlotSpec{{
|
||||
Key: "topic", Required: true, Pattern: `主题是(.+)`,
|
||||
Enum: []string{"电机", "泵"},
|
||||
}},
|
||||
Risk: RiskHigh, RequiresConfirm: true, Handler: "reportHandler",
|
||||
Action: &ActionRef{Kind: ActionCommand, ID: "cmd.report"},
|
||||
}
|
||||
|
||||
t.Run("missing_required", func(t *testing.T) {
|
||||
snap := mk(t, base)
|
||||
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
|
||||
{Kind: CommandStart, Action: ActionRef{Kind: ActionCommand, ID: "cmd.report"}, Slots: map[string]string{}},
|
||||
}}}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "无关输入", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchMatched || res.Top == nil {
|
||||
t.Fatalf("want matched with missing: %+v", res)
|
||||
}
|
||||
if len(res.Top.MissingRequiredSlots) != 1 || res.Top.MissingRequiredSlots[0] != "topic" {
|
||||
t.Fatalf("missing: %+v", res.Top.MissingRequiredSlots)
|
||||
}
|
||||
if res.Top.Risk != RiskHigh || !res.Top.RequiresConfirm {
|
||||
t.Fatalf("metadata lost: %+v", res.Top)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid_enum", func(t *testing.T) {
|
||||
snap := mk(t, base)
|
||||
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
|
||||
{Kind: CommandStart, Action: ActionRef{Kind: ActionHandler, ID: "reportHandler"},
|
||||
Slots: map[string]string{"topic": "涡轮"}},
|
||||
}}}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "无关输入", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchMatched || res.Top == nil {
|
||||
t.Fatalf("want matched with invalid: %+v", res)
|
||||
}
|
||||
if res.Top.InvalidSlots["topic"] == "" {
|
||||
t.Fatalf("invalid slots: %+v", res.Top.InvalidSlots)
|
||||
}
|
||||
if res.Top.Slots["topic"] != "" {
|
||||
t.Fatalf("invalid value must not fill slots: %+v", res.Top.Slots)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown_slot", func(t *testing.T) {
|
||||
snap := mk(t, base)
|
||||
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
|
||||
{Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "report.generate"},
|
||||
Slots: map[string]string{"bogus": "v"}},
|
||||
}}}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "无关输入", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchFallback || res.Top != nil {
|
||||
t.Fatalf("unknown slot must fallback: %+v", res)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate_handler", func(t *testing.T) {
|
||||
a := base
|
||||
a.ID = "report.a"
|
||||
a.Handler = "shared"
|
||||
bSpec := base
|
||||
bSpec.ID = "report.b"
|
||||
bSpec.Handler = "shared"
|
||||
bSpec.Action = &ActionRef{Kind: ActionCommand, ID: "cmd.b"}
|
||||
snap := mk(t, a, bSpec)
|
||||
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
|
||||
{Kind: CommandStart, Action: ActionRef{Kind: ActionHandler, ID: "shared"},
|
||||
Slots: map[string]string{"topic": "电机"}},
|
||||
}}}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "无关输入", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchFallback || res.Top != nil {
|
||||
t.Fatalf("ambiguous handler must fallback: %+v", res)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestU5GeneratedActionAliasRoutesToSpec:Action 指向不同 command ID 时仍解析到权威 spec。
|
||||
func TestU5GeneratedActionAliasRoutesToSpec(t *testing.T) {
|
||||
b := NewRegistryBuilder(Options{})
|
||||
if err := b.Register(IntentSpec{
|
||||
ID: "report.generate", Domain: "report", Version: 1,
|
||||
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
|
||||
Slots: []SlotSpec{{Key: "topic", Pattern: `(.+)`}},
|
||||
Action: &ActionRef{Kind: ActionCommand, ID: "cmd.other-id"},
|
||||
Handler: "h",
|
||||
Risk: RiskLow,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
|
||||
{Kind: CommandStart, Action: ActionRef{Kind: ActionCommand, ID: "cmd.other-id"},
|
||||
Slots: map[string]string{"topic": "x"}},
|
||||
}}}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "无关输入", reg.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "report.generate" {
|
||||
t.Fatalf("alias resolve: %+v", res)
|
||||
}
|
||||
if res.Top.Action == nil || res.Top.Action.ID != "cmd.other-id" {
|
||||
t.Fatalf("action metadata: %+v", res.Top.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// TestU5ClassifierAndSemanticRejectBadScores:非有限/越界分数不得变成 matched。
|
||||
func TestU5ClassifierAndSemanticRejectBadScores(t *testing.T) {
|
||||
snap := ambiguousFixture(t, []string{"查运行进度"}, []string{"闲聊问候"})
|
||||
|
||||
t.Run("classifier_inf", func(t *testing.T) {
|
||||
rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: func(cands []Candidate) []Candidate {
|
||||
out := append([]Candidate(nil), cands...)
|
||||
out[0].Confidence = math.Inf(1)
|
||||
out[1].Confidence = 0.1
|
||||
return out
|
||||
}})
|
||||
res, err := rec.Recognize(context.Background(), "模糊词", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchAmbiguous || res.Top != nil {
|
||||
t.Fatalf("inf must stay ambiguous: %+v", res)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("classifier_in_place_mutation", func(t *testing.T) {
|
||||
rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: func(cands []Candidate) []Candidate {
|
||||
cands[0].Handler = "tampered"
|
||||
cands[0].Confidence = 0.99
|
||||
cands[1].Confidence = 0.1
|
||||
return cands
|
||||
}})
|
||||
res, err := rec.Recognize(context.Background(), "模糊词", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchMatched || res.Top == nil {
|
||||
t.Fatalf("expected matched: %+v", res)
|
||||
}
|
||||
if res.Top.Handler == "tampered" {
|
||||
t.Fatalf("classifier in-place mutation leaked into top: %+v", res.Top)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("semantic_nan", func(t *testing.T) {
|
||||
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": math.NaN()}}}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "模糊词", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchAmbiguous || res.Top != nil {
|
||||
t.Fatalf("nan semantic must stay ambiguous: %+v", res)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("semantic_above_one", func(t *testing.T) {
|
||||
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": 1.5}}}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "模糊词", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchAmbiguous || res.Top != nil {
|
||||
t.Fatalf("out-of-range semantic must stay ambiguous: %+v", res)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("semantic_inf_not_matched", func(t *testing.T) {
|
||||
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": math.Inf(1)}}}, nil, nil)
|
||||
res, err := rec.Recognize(context.Background(), "模糊词", snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Branch != BranchAmbiguous || res.Top != nil {
|
||||
t.Fatalf("inf semantic must stay ambiguous: %+v", res)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user