Why: F09 - invalid slot information was dropped at the gate, the router saw only Start, and the command entrance passed untyped text, so invalid-slot recognitions could still reach business execution paths. Evidence: intentgate/route/server tests pass (verify-u6.json); admission regression cases added. Impact: invalid or missing slots never start executable actions; clarification flows unchanged; cmdbus confirmation untouched. Refs: feature/agent-stack-audit-remediation-b1 U6
253 lines
8.1 KiB
Go
253 lines
8.1 KiB
Go
// Package intentgate 接入 agent-intent(生态仓):插件意图 overlay → 注册表 →
|
||
// HITL 自由输入路由的确定性识别(设计 4.5 三分支之「命中其他意图→转轨」)。
|
||
// 未配置/未装载意图 = 行为回退(T0:直接按澄清回填),识别失败不阻断。
|
||
package intentgate
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
|
||
agentintent "agent-intent/intent"
|
||
)
|
||
|
||
// SlotQuestion 是命中意图后仍缺的必填槽位,以及建议的提问。
|
||
type SlotQuestion struct {
|
||
Key string `json:"key"`
|
||
Question string `json:"question"`
|
||
}
|
||
|
||
// SlotDefect 是一条槽位缺陷,来自权威识别结果的校验(F09):
|
||
// - Invalid=true:捕获到了值但没通过确定性校验(枚举/格式/校验器/跨度重叠/
|
||
// 生成路径的未知键),Reason 是库层原因原文;
|
||
// - Invalid=false:必填槽位根本没给(等价于 SlotQuestion,Reason 为空)。
|
||
//
|
||
// 宿主据此区分「没给」与「给错了」,两种情形都不派发业务动作。
|
||
type SlotDefect struct {
|
||
Key string `json:"key"`
|
||
Reason string `json:"reason,omitempty"`
|
||
Question string `json:"question"`
|
||
Invalid bool `json:"invalid"`
|
||
}
|
||
|
||
// MatchResult 是一次识别。Branch 与 Match 相同。
|
||
type MatchResult struct {
|
||
Branch string
|
||
ID string
|
||
Slots map[string]string
|
||
Missing []SlotQuestion
|
||
// Invalid 是捕获到但未通过权威校验的槽位(含生成路径的未知键)。
|
||
// 与 Missing 分开投影:必填且非法只进此处,不重复计入 Missing。
|
||
Invalid []SlotDefect
|
||
// Risk/RequiresConfirm/Action 是权威候选的声明(规则路径与生成路径同款):
|
||
// 风险档、是否要求显式确认、spec.action 显式动作引用。nil Action 表示未声明。
|
||
Risk string
|
||
RequiresConfirm bool
|
||
Action *ActionRef
|
||
// Commands 是库层展开的命令列表(I1);Router 消费它做唯一裁决。
|
||
// guard_hit/expectation_hit 被折叠为 fallback 时为空。
|
||
Commands []Command
|
||
}
|
||
|
||
// Complete 报告槽位是否齐全且全部通过权威校验:false 时业务动作不得派发。
|
||
func (m MatchResult) Complete() bool { return len(m.Missing) == 0 && len(m.Invalid) == 0 }
|
||
|
||
// 命令类型经闸门投影(别名):生态库按边界规则只许本包导入,
|
||
// Router/runmgr 等消费方只依赖本包表面。
|
||
type (
|
||
Command = agentintent.Command
|
||
ActionRef = agentintent.ActionRef
|
||
)
|
||
|
||
const (
|
||
CommandStart = agentintent.CommandStart
|
||
CommandSetSlot = agentintent.CommandSetSlot
|
||
CommandChangeSlot = agentintent.CommandChangeSlot
|
||
CommandCancel = agentintent.CommandCancel
|
||
CommandClarify = agentintent.CommandClarify
|
||
CommandChitchat = agentintent.CommandChitchat
|
||
CommandHandoff = agentintent.CommandHandoff
|
||
ActionAuto = agentintent.ActionAuto
|
||
ActionCommand = agentintent.ActionCommand
|
||
ActionWorkflow = agentintent.ActionWorkflow
|
||
ActionBlueprint = agentintent.ActionBlueprint
|
||
ActionHandler = agentintent.ActionHandler
|
||
)
|
||
|
||
type Gate struct {
|
||
mu sync.RWMutex
|
||
reg *agentintent.Registry
|
||
rec *agentintent.FunnelRecognizer
|
||
specs int
|
||
specsByID map[string]agentintent.IntentSpec
|
||
// 慢通道(I2):nil = 不启用(默认,行为与 I1 一致)。
|
||
sem agentintent.SemanticScorer
|
||
semThresh float64
|
||
gen agentintent.CommandGenerator
|
||
}
|
||
|
||
func New() *Gate {
|
||
return &Gate{rec: agentintent.NewRecognizer(agentintent.Options{}, nil, nil)}
|
||
}
|
||
|
||
// SetSemantic 接线语义打分器(慢通道中位)。识别器随之重建;
|
||
// 传 nil 关闭语义层。阈值 ≤0 用库默认(0.8)。
|
||
func (g *Gate) SetSemantic(s agentintent.SemanticScorer, threshold float64) {
|
||
g.mu.Lock()
|
||
defer g.mu.Unlock()
|
||
g.sem, g.semThresh = s, threshold
|
||
g.rebuildRecognizerLocked()
|
||
}
|
||
|
||
// SetGenerator 接线模型命令生成器(慢通道末位)。识别器随之重建;
|
||
// 传 nil 关闭生成层。
|
||
func (g *Gate) SetGenerator(gen agentintent.CommandGenerator) {
|
||
g.mu.Lock()
|
||
defer g.mu.Unlock()
|
||
g.gen = gen
|
||
g.rebuildRecognizerLocked()
|
||
}
|
||
|
||
// rebuildRecognizerLocked 按当前慢通道配置重建识别器(调用方持写锁)。
|
||
func (g *Gate) rebuildRecognizerLocked() {
|
||
if g.sem == nil && g.gen == nil {
|
||
g.rec = agentintent.NewRecognizer(agentintent.Options{}, nil, nil)
|
||
return
|
||
}
|
||
g.rec = agentintent.NewRecognizer(agentintent.Options{
|
||
Semantic: g.sem,
|
||
SemanticThreshold: g.semThresh,
|
||
Generator: g.gen,
|
||
}, nil, nil)
|
||
}
|
||
|
||
// Rebuild 用插件意图 overlay 重建注册表;失败保留上一快照(fail-closed)并返回错误。
|
||
func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error {
|
||
if len(overlay) == 0 {
|
||
g.mu.Lock()
|
||
g.reg, g.specs, g.specsByID = nil, 0, nil
|
||
g.mu.Unlock()
|
||
return nil
|
||
}
|
||
specs := make([]agentintent.IntentSpec, 0, len(overlay))
|
||
for i, raw := range overlay {
|
||
var s agentintent.IntentSpec
|
||
if err := json.Unmarshal(raw, &s); err != nil {
|
||
return fmt.Errorf("intentgate: spec[%d]: %w", i, err)
|
||
}
|
||
specs = append(specs, s)
|
||
}
|
||
b := agentintent.NewRegistryBuilder(agentintent.Options{TopN: 3, NFKC: true})
|
||
if err := b.Register(specs...); err != nil {
|
||
return fmt.Errorf("intentgate: register: %w", err)
|
||
}
|
||
reg, err := b.Build()
|
||
if err != nil {
|
||
return fmt.Errorf("intentgate: build: %w", err)
|
||
}
|
||
byID := make(map[string]agentintent.IntentSpec, len(specs))
|
||
for _, s := range specs {
|
||
byID[s.ID] = s
|
||
}
|
||
g.mu.Lock()
|
||
g.reg, g.specs, g.specsByID = reg, len(specs), byID
|
||
g.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// Match 识别自由输入;返回 (branch, topID)。
|
||
// branch:matched | ambiguous | fallback | ""(未配置/系统级错误 → 回退通道)。
|
||
func (g *Gate) Match(ctx context.Context, input string) (string, string) {
|
||
res := g.Recognize(ctx, input)
|
||
return res.Branch, res.ID
|
||
}
|
||
|
||
// Recognize 在 Match 的结果上带上槽位、缺失提问、非法原因与风险/确认/动作声明。
|
||
func (g *Gate) Recognize(ctx context.Context, input string) MatchResult {
|
||
g.mu.RLock()
|
||
reg, rec, byID := g.reg, g.rec, g.specsByID
|
||
g.mu.RUnlock()
|
||
if reg == nil {
|
||
return MatchResult{}
|
||
}
|
||
res, err := rec.Recognize(ctx, input, reg.Snapshot())
|
||
if err != nil {
|
||
return MatchResult{}
|
||
}
|
||
out := MatchResult{Branch: "fallback"}
|
||
switch res.Branch {
|
||
case agentintent.BranchMatched:
|
||
out.Branch = "matched"
|
||
out.Commands = res.Commands
|
||
case agentintent.BranchAmbiguous:
|
||
out.Branch = "ambiguous"
|
||
}
|
||
if res.Top == nil {
|
||
return out
|
||
}
|
||
out.ID = res.Top.ID
|
||
if len(res.Top.Slots) > 0 {
|
||
out.Slots = map[string]string{}
|
||
for k, v := range res.Top.Slots {
|
||
out.Slots[k] = v
|
||
}
|
||
}
|
||
// 风险/确认/动作声明:规则路径与生成路径(candidateFromGenerated)同款字段,
|
||
// 宿主据此决定是否必须走确认,不在本包内裁决。
|
||
out.Risk = res.Top.Risk
|
||
out.RequiresConfirm = res.Top.RequiresConfirm
|
||
if res.Top.Action != nil {
|
||
action := *res.Top.Action
|
||
out.Action = &action
|
||
}
|
||
spec := byID[out.ID]
|
||
for _, key := range res.Top.MissingRequiredSlots {
|
||
out.Missing = append(out.Missing, SlotQuestion{Key: key, Question: questionOf(spec, key)})
|
||
}
|
||
// 非法槽位按键名排序(识别结果里的 map 无序,投影必须确定)。
|
||
for _, key := range sortedKeys(res.Top.InvalidSlots) {
|
||
out.Invalid = append(out.Invalid, SlotDefect{
|
||
Key: key, Reason: res.Top.InvalidSlots[key], Invalid: true,
|
||
Question: questionOf(spec, key),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// questionOf 取 spec 为该槽位声明的建议提问;没有就回落键名本身。
|
||
func questionOf(spec agentintent.IntentSpec, key string) string {
|
||
for _, sl := range spec.Slots {
|
||
if sl.Key == key && strings.TrimSpace(sl.Question) != "" {
|
||
return sl.Question
|
||
}
|
||
}
|
||
return key
|
||
}
|
||
|
||
// sortedKeys 按键名排序,保证同一识别结果的投影顺序确定。
|
||
func sortedKeys(m map[string]string) []string {
|
||
out := make([]string, 0, len(m))
|
||
for k := range m {
|
||
out = append(out, k)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// Enabled 报告是否装载了意图(诊断/设置展示用)。
|
||
func (g *Gate) Enabled() bool {
|
||
g.mu.RLock()
|
||
defer g.mu.RUnlock()
|
||
return g.reg != nil && g.specs > 0
|
||
}
|
||
|
||
// SpecCount 返回已装载意图数。
|
||
func (g *Gate) SpecCount() int {
|
||
g.mu.RLock()
|
||
defer g.mu.RUnlock()
|
||
return g.specs
|
||
}
|