Unify intent routing behind the route package and enable NFKC (I1 WP3/WP4) #74
Vendored
+1
-1
Submodule deps/agent-intent updated: 65aaebca1e...2554b509e8
Vendored
+1
-1
Submodule deps/agent-squad updated: 7ecc04ee61...6b7a991750
@@ -24,6 +24,9 @@ type MatchResult struct {
|
||||
ID string
|
||||
Slots map[string]string
|
||||
Missing []SlotQuestion
|
||||
// Commands 是库层展开的命令列表(I1);Router 消费它做唯一裁决。
|
||||
// guard_hit/expectation_hit 被折叠为 fallback 时为空。
|
||||
Commands []agentintent.Command
|
||||
}
|
||||
|
||||
type Gate struct {
|
||||
@@ -54,7 +57,7 @@ func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error {
|
||||
}
|
||||
specs = append(specs, s)
|
||||
}
|
||||
b := agentintent.NewRegistryBuilder(agentintent.Options{TopN: 3})
|
||||
b := agentintent.NewRegistryBuilder(agentintent.Options{TopN: 3, NFKC: true})
|
||||
if err := b.Register(specs...); err != nil {
|
||||
return fmt.Errorf("intentgate: register: %w", err)
|
||||
}
|
||||
@@ -95,6 +98,7 @@ func (g *Gate) Recognize(ctx context.Context, input string) MatchResult {
|
||||
switch res.Branch {
|
||||
case agentintent.BranchMatched:
|
||||
out.Branch = "matched"
|
||||
out.Commands = res.Commands
|
||||
case agentintent.BranchAmbiguous:
|
||||
out.Branch = "ambiguous"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Package route 是 Backend 的唯一意图 Router(I1 WP3)。
|
||||
// 它把识别器产出的命令列表统一裁决为路由决策,收编原散落在
|
||||
// server(chat 内联分支)与 runmgr(HITL 自由输入转轨)的两套逻辑。
|
||||
//
|
||||
// 优先级明文(提案 §6 要求 I1 写明):
|
||||
// - auto/handler:命令总线 > 工作流 > 普通回合(与现网行为一致)。
|
||||
// - 显式 command/workflow/blueprint:按声明执行;声明目标在当前环境
|
||||
// 不可解析(命令不存在、工作流未注册、小队未启用)时不静默错配——
|
||||
// 回落普通回合并把原因写进 Reason。
|
||||
// - PromptTrigger(技能触发)是提示词装配、不参与路由决胜;
|
||||
// 意图产出 Start/Clarify 类命令时路由优先。
|
||||
//
|
||||
// 当前版本的已知边界(如实记录,不静默):
|
||||
// - squad 决策需要 runmgr 的 per-run entry 覆盖才能真正执行(独立设计项);
|
||||
// 本版本调用方按普通回合处理并保留 Decision 供审计。
|
||||
// - SetSlot/Cancel/Handoff 命令由库层产出,但 Backend 尚无会话槽位状态机
|
||||
// 与语义层,Router v1 不消费它们(expectation_hit 在 intentgate 已被折叠为
|
||||
// fallback,Start 只来自 matched)。
|
||||
package route
|
||||
|
||||
import (
|
||||
agentintent "agent-intent/intent"
|
||||
|
||||
"agent-system-backend/internal/intentgate"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// Kind 是路由决策类型。
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindCommand Kind = "command"
|
||||
KindWorkflow Kind = "workflow"
|
||||
KindSquad Kind = "squad"
|
||||
KindTurn Kind = "turn"
|
||||
)
|
||||
|
||||
// Decision 是一次路由裁决。
|
||||
type Decision struct {
|
||||
Kind Kind
|
||||
CommandID string
|
||||
Workflow core.WorkflowDef
|
||||
Blueprint string
|
||||
Slots map[string]string
|
||||
Missing []intentgate.SlotQuestion
|
||||
Reason string // 回落 turn 的原因(显式声明不可解析时)
|
||||
StartFound bool // 是否存在 Start 命令(runmgr 转轨判定用)
|
||||
}
|
||||
|
||||
// Env 是路由裁决的环境解析器(由调用方注入;nil 视为不可解析)。
|
||||
type Env struct {
|
||||
HasCommand func(id string) bool
|
||||
WorkflowFor func(intentID string) (core.WorkflowDef, bool)
|
||||
SquadReady bool
|
||||
}
|
||||
|
||||
// Decide 裁决一次识别结果。规则:
|
||||
// 1. 非 matched(fallback/guard/ambiguous 经闸门折叠后)→ turn。
|
||||
// 2. 找到首个 Start 命令;没有时按 TopID 走 auto(兼容兜底,与现网一致)。
|
||||
// 3. 按 ActionRef.Kind 解析:command→命令总线;workflow→工作流;
|
||||
// blueprint→squad 决策;handler/auto→命令总线 > 工作流 > turn。
|
||||
func Decide(res intentgate.MatchResult, env Env) Decision {
|
||||
d := Decision{Kind: KindTurn, Slots: res.Slots, Missing: res.Missing}
|
||||
if res.Branch != "matched" {
|
||||
return d
|
||||
}
|
||||
var start *agentintent.Command
|
||||
for i := range res.Commands {
|
||||
if res.Commands[i].Kind == agentintent.CommandStart {
|
||||
start = &res.Commands[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
action := agentintent.ActionRef{Kind: agentintent.ActionAuto, ID: res.ID}
|
||||
if start != nil {
|
||||
d.StartFound = true
|
||||
action = start.Action
|
||||
if len(start.Slots) > 0 {
|
||||
d.Slots = start.Slots
|
||||
}
|
||||
}
|
||||
switch action.Kind {
|
||||
case agentintent.ActionCommand:
|
||||
if env.HasCommand != nil && env.HasCommand(action.ID) {
|
||||
d.Kind, d.CommandID = KindCommand, action.ID
|
||||
return d
|
||||
}
|
||||
d.Reason = "声明的命令不存在:" + action.ID
|
||||
case agentintent.ActionWorkflow:
|
||||
if env.WorkflowFor != nil {
|
||||
if def, ok := env.WorkflowFor(action.ID); ok {
|
||||
d.Kind, d.Workflow = KindWorkflow, def
|
||||
return d
|
||||
}
|
||||
}
|
||||
d.Reason = "声明的工作流未注册:" + action.ID
|
||||
case agentintent.ActionBlueprint:
|
||||
if env.SquadReady {
|
||||
d.Kind, d.Blueprint = KindSquad, action.ID
|
||||
return d
|
||||
}
|
||||
d.Reason = "声明的小队未启用:" + action.ID
|
||||
default: // auto / handler
|
||||
if env.HasCommand != nil && env.HasCommand(action.ID) {
|
||||
d.Kind, d.CommandID = KindCommand, action.ID
|
||||
return d
|
||||
}
|
||||
if env.WorkflowFor != nil {
|
||||
if def, ok := env.WorkflowFor(action.ID); ok {
|
||||
d.Kind, d.Workflow = KindWorkflow, def
|
||||
return d
|
||||
}
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
agentintent "agent-intent/intent"
|
||||
|
||||
"agent-system-backend/internal/intentgate"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func startCmd(action agentintent.ActionRef) agentintent.Command {
|
||||
return agentintent.Command{Kind: agentintent.CommandStart, Action: action}
|
||||
}
|
||||
|
||||
func TestDecideAutoPrefersCommandThenWorkflow(t *testing.T) {
|
||||
in := intentgate.MatchResult{Branch: "matched", ID: "demo.x",
|
||||
Commands: []agentintent.Command{startCmd(agentintent.ActionRef{Kind: agentintent.ActionAuto, ID: "demo.x"})}}
|
||||
d := Decide(in, Env{HasCommand: func(string) bool { return true }})
|
||||
if d.Kind != KindCommand || d.CommandID != "demo.x" {
|
||||
t.Fatalf("auto command = %+v", d)
|
||||
}
|
||||
d = Decide(in, Env{
|
||||
HasCommand: func(string) bool { return false },
|
||||
WorkflowFor: func(string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: "wf"}, true },
|
||||
})
|
||||
if d.Kind != KindWorkflow || d.Workflow.ID != "wf" {
|
||||
t.Fatalf("auto workflow = %+v", d)
|
||||
}
|
||||
d = Decide(in, Env{})
|
||||
if d.Kind != KindTurn || !d.StartFound {
|
||||
t.Fatalf("auto turn = %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideExplicitKinds(t *testing.T) {
|
||||
mk := func(kind, id string) intentgate.MatchResult {
|
||||
return intentgate.MatchResult{Branch: "matched", ID: "demo.x",
|
||||
Commands: []agentintent.Command{startCmd(agentintent.ActionRef{Kind: kind, ID: id})}}
|
||||
}
|
||||
if d := Decide(mk(agentintent.ActionCommand, "cmd-1"), Env{HasCommand: func(id string) bool { return id == "cmd-1" }}); d.Kind != KindCommand || d.CommandID != "cmd-1" {
|
||||
t.Fatalf("explicit command = %+v", d)
|
||||
}
|
||||
d := Decide(mk(agentintent.ActionCommand, "nope"), Env{
|
||||
WorkflowFor: func(string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: "wf"}, true },
|
||||
})
|
||||
if d.Kind != KindTurn || d.Reason == "" {
|
||||
t.Fatalf("unresolvable command = %+v", d)
|
||||
}
|
||||
if d := Decide(mk(agentintent.ActionWorkflow, "wf-1"), Env{
|
||||
WorkflowFor: func(id string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: id}, true },
|
||||
}); d.Kind != KindWorkflow || d.Workflow.ID != "wf-1" {
|
||||
t.Fatalf("explicit workflow = %+v", d)
|
||||
}
|
||||
if d := Decide(mk(agentintent.ActionBlueprint, "bp-1"), Env{SquadReady: true}); d.Kind != KindSquad || d.Blueprint != "bp-1" {
|
||||
t.Fatalf("blueprint = %+v", d)
|
||||
}
|
||||
if d := Decide(mk(agentintent.ActionBlueprint, "bp-1"), Env{}); d.Kind != KindTurn || d.Reason == "" {
|
||||
t.Fatalf("blueprint without squad = %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideNonMatchedAndLegacyFallback(t *testing.T) {
|
||||
if d := Decide(intentgate.MatchResult{Branch: "fallback"}, Env{}); d.Kind != KindTurn {
|
||||
t.Fatalf("fallback = %+v", d)
|
||||
}
|
||||
// matched 但命令列表为空(旧消费方):按 TopID 走 auto,与现网一致。
|
||||
d := Decide(intentgate.MatchResult{Branch: "matched", ID: "demo.x"}, Env{HasCommand: func(string) bool { return true }})
|
||||
if d.Kind != KindCommand || d.StartFound {
|
||||
t.Fatalf("legacy auto = %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotsFromStartCommand(t *testing.T) {
|
||||
in := intentgate.MatchResult{Branch: "matched", ID: "demo.x",
|
||||
Commands: []agentintent.Command{{
|
||||
Kind: agentintent.CommandStart,
|
||||
Action: agentintent.ActionRef{Kind: agentintent.ActionAuto, ID: "demo.x"},
|
||||
Slots: map[string]string{"topic": "电机"},
|
||||
}}}
|
||||
d := Decide(in, Env{HasCommand: func(string) bool { return true }})
|
||||
if d.Slots["topic"] != "电机" {
|
||||
t.Fatalf("slots = %v", d.Slots)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,9 @@ import (
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/cardsvc"
|
||||
"agent-system-backend/internal/ingest"
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/route"
|
||||
"agent-system-backend/internal/runtime"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
@@ -1269,12 +1271,21 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
|
||||
answer = resp.FreeText
|
||||
trimmed := strings.TrimSpace(resp.FreeText)
|
||||
// 转轨判定(两源):/ 命令,或意图闸门命中其他已注册意图。
|
||||
// 命中与否经唯一 Router(internal/route)判定,与 chat 路由同一裁决(I1 WP3)。
|
||||
pivot := false
|
||||
pivotReason := ""
|
||||
if strings.HasPrefix(trimmed, "/") {
|
||||
pivot, pivotReason = true, "command"
|
||||
} else if m.gate != nil {
|
||||
if branch, topID := m.gate.Match(context.Background(), trimmed); branch == "matched" {
|
||||
if rec, ok := m.gate.(interface {
|
||||
Recognize(ctx context.Context, input string) intentgate.MatchResult
|
||||
}); ok {
|
||||
res := rec.Recognize(context.Background(), trimmed)
|
||||
dec := route.Decide(res, route.Env{})
|
||||
if dec.StartFound {
|
||||
pivot, pivotReason = true, "intent:" + res.ID
|
||||
}
|
||||
} else if branch, topID := m.gate.Match(context.Background(), trimmed); branch == "matched" {
|
||||
pivot, pivotReason = true, "intent:"+topID
|
||||
}
|
||||
}
|
||||
|
||||
+27
-12
@@ -27,6 +27,7 @@ import (
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/prompts"
|
||||
"agent-system-backend/internal/ratelimit"
|
||||
"agent-system-backend/internal/route"
|
||||
"agent-system-backend/internal/runmgr"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
@@ -646,20 +647,35 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 先路由再落库。回合和工作流要先抢槽位,忙则 409 且不写任何数据。
|
||||
// 意图命中的命令不占槽位,会话忙时也能用。
|
||||
route := "turn"
|
||||
// 路由裁决统一走唯一 Router(internal/route;I1 WP3)。
|
||||
routeKind := route.KindTurn
|
||||
var recognized intentgate.MatchResult
|
||||
var workflowDef core.WorkflowDef
|
||||
if s.Gate != nil && !strings.HasPrefix(text, "/") {
|
||||
recognized = s.recognize(r.Context(), text)
|
||||
if recognized.Branch == "matched" && s.Bus != nil && s.Bus.HasCommand(recognized.ID) {
|
||||
route = "command"
|
||||
} else if recognized.Branch == "matched" && s.Workflows != nil {
|
||||
if _, ok := s.Workflows.ByIntent(recognized.ID); ok {
|
||||
route = "workflow"
|
||||
}
|
||||
dec := route.Decide(recognized, route.Env{
|
||||
HasCommand: func(id string) bool { return s.Bus != nil && s.Bus.HasCommand(id) },
|
||||
WorkflowFor: func(intentID string) (core.WorkflowDef, bool) {
|
||||
if s.Workflows == nil {
|
||||
return core.WorkflowDef{}, false
|
||||
}
|
||||
return s.Workflows.ByIntent(intentID)
|
||||
},
|
||||
// SquadReady 保持 false:per-run entry 覆盖未就位(见 route 包注释的已知边界)。
|
||||
})
|
||||
switch dec.Kind {
|
||||
case route.KindCommand:
|
||||
routeKind = route.KindCommand
|
||||
recognized.ID = dec.CommandID
|
||||
recognized.Slots = dec.Slots
|
||||
case route.KindWorkflow:
|
||||
routeKind = route.KindWorkflow
|
||||
workflowDef = dec.Workflow
|
||||
recognized.Slots = dec.Slots
|
||||
}
|
||||
}
|
||||
claimed := false
|
||||
if route == "turn" || route == "workflow" {
|
||||
if routeKind == route.KindTurn || routeKind == route.KindWorkflow {
|
||||
if err := s.mgr.ClaimConversation(convID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -682,7 +698,7 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if route == "command" {
|
||||
if routeKind == route.KindCommand {
|
||||
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
|
||||
ID: recognized.ID, Params: map[string]any{"text": text}, Entrance: "chat", Actor: owner,
|
||||
Owner: owner, ConversationID: convID, AnchorMessageID: msg.ID,
|
||||
@@ -701,13 +717,12 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var run core.Run
|
||||
var startErr error
|
||||
if route == "workflow" {
|
||||
def, _ := s.Workflows.ByIntent(recognized.ID)
|
||||
if routeKind == route.KindWorkflow {
|
||||
missing := make([]workflow.SlotQuestion, len(recognized.Missing))
|
||||
for i, q := range recognized.Missing {
|
||||
missing[i] = workflow.SlotQuestion{Key: q.Key, Question: q.Question}
|
||||
}
|
||||
run, startErr = s.mgr.StartClaimedWorkflowRun(conv, msg, def, recognized.Slots, missing)
|
||||
run, startErr = s.mgr.StartClaimedWorkflowRun(conv, msg, workflowDef, recognized.Slots, missing)
|
||||
} else {
|
||||
run, startErr = s.mgr.StartClaimedConversationRun(conv, msg, parsedDocs, inputs)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user