Files
Agent-System-Backend/internal/intentgate/gate.go
T
weichen 49c1259e00 Keep the server admission test inside the intent boundary
Why: the U6 admission test imported agent-intent directly from
internal/server, which lint-boundaries forbids (only intentgate,
intentsem and the squad intent adapter may import it).
Evidence: repair receipt success; server/intentgate/route tests pass
(verify-u6r.json); fixtures moved behind intentgate helpers.
Impact: test-only rework plus a small intentgate fixture helper.
Refs: feature/agent-stack-audit-remediation-b1 U6 repair
2026-10-21 17:35:53 +00:00

256 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 等消费方只依赖本包表面。CatalogSpec/CatalogSlot 随生成器端口
// (SetGenerator 的入参)投影,实现生成器不必越过本包直接引用生态库。
type (
Command = agentintent.Command
ActionRef = agentintent.ActionRef
CatalogSpec = agentintent.CatalogSpec
CatalogSlot = agentintent.CatalogSlot
)
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
}