Files
Agent-System/internal/coordinator/router.go
T

297 lines
11 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 coordinator
// ============================================================================
// 任务路由层 (TaskRouter) — 设计规范 §4
// 语义维度(L1 意图,已有)× 执行维度(任务画像,新增)的二维判定。
// 防回归三原则:
// 1. 默认即现状:R0 确定性路由表与既有意图分发完全等价,路由层只新增"升级建议"路径;
// 2. 确定性优先:画像由可单测的规则信号加权,不引入路由玄学;
// 3. 升级需确认 + 冷却:长任务通道切换必须经用户确认,每会话至多主动建议一次。
// ============================================================================
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/squad"
)
// DeliverableForm 交付形态
type DeliverableForm string
const (
FormAnswer DeliverableForm = "answer" // 对话答复
FormDocument DeliverableForm = "document" // 受控文档产物
FormData DeliverableForm = "data" // 结构化表格/多文件
)
// TaskProfile 任务画像(确定性规则信号加权,可解释可测试)
type TaskProfile struct {
ComplexityScore int `json:"complexity_score"`
ComplexitySignals []string `json:"complexity_signals"`
DeliverableForm DeliverableForm `json:"deliverable_form"`
}
// 信号词典(与 matchTriggerTask 标准任务词典分离维护,避免置信度互相污染)
var (
deliverableDocWords = []string{"报告", "方案", "文档", "清单", "手册", "大纲", "说明书", "立项材料", "评估表", "对比分析", "分析报告"}
deliverableDataWords = []string{"表格", "对比表", "核算", "Excel", "excel", "矩阵", "参数表"}
retrievalWords = []string{"检索", "文献", "标准号", "查一下最新", "最新法规", "调研", "竞品"}
depthWords = []string{"详尽", "深入", "全面", "成体系", "系统性", "完整", "立项级", "逐条", "正式"}
crossDomainLexicons = map[string][]string{
"regulatory": {"法规", "注册", "审评", "临床评价", "标准", "合规", "NMPA", "nmpa", "CE", "ce认证"},
"engineering": {"架构", "功耗", "散热", "结构", "材料", "设计", "电源", "电池", "算法", "传感器", "探头"},
"business": {"报价", "成本", "预算", "定价", "商务", "市场", "毛利", "销售", "付费"},
}
multiObjectWords = []string{"对比", "横向", "竞品", "各家", "多个", "逐一"}
)
// ProfileTask 基于确定性规则为输入构建任务画像
func ProfileTask(input string, userCtx *UserProjectContext) TaskProfile {
profile := TaskProfile{DeliverableForm: FormAnswer}
if strings.TrimSpace(input) == "" {
return profile
}
lower := strings.ToLower(input)
add := func(score int, signal string) {
profile.ComplexityScore += score
profile.ComplexitySignals = append(profile.ComplexitySignals, signal)
}
for _, w := range deliverableDataWords {
if strings.Contains(input, w) {
profile.DeliverableForm = FormData
add(35, "结构化数据交付物") // 数据交付(表格/核算)天然隐含多源信息汇总,权重略高于普通文档词
break
}
}
if profile.DeliverableForm == FormAnswer {
for _, w := range deliverableDocWords {
if strings.Contains(input, w) {
profile.DeliverableForm = FormDocument
add(30, "明确文档交付物")
break
}
}
}
for _, w := range retrievalWords {
if strings.Contains(lower, strings.ToLower(w)) {
add(10, "需要外部检索")
break
}
}
domainsHit := 0
for _, words := range crossDomainLexicons {
for _, w := range words {
if strings.Contains(lower, strings.ToLower(w)) {
domainsHit++
break
}
}
}
if domainsHit >= 2 {
add(20, fmt.Sprintf("多领域交叉(%d域)", domainsHit))
}
for _, w := range multiObjectWords {
if strings.Contains(input, w) {
add(15, "多对象横向展开")
break
}
}
for _, w := range depthWords {
if strings.Contains(input, w) {
add(10, "深度/体系化要求")
break
}
}
if userCtx != nil && len(userCtx.RecentDialogue) >= 2 {
add(10, "多轮追问深化")
}
if profile.ComplexityScore > 100 {
profile.ComplexityScore = 100
}
return profile
}
// RouteChannel 执行通道
type RouteChannel string
const (
ChannelDialogue RouteChannel = "DIALOGUE"
ChannelTemplate RouteChannel = "TEMPLATE"
ChannelSquadFixed RouteChannel = "SQUAD_FIXED"
ChannelSquadAdhoc RouteChannel = "SQUAD_ADHOC"
)
// RouteDecision 路由判定(通道 + 置信 + 理由 + 升级建议)
type RouteDecision struct {
Channel RouteChannel
Confidence float64
Reason string
UpgradeHint bool
Profile TaskProfile
}
// RouteIntent R0/R1 确定性路由(R2 低置信 LLM 仲裁与 R3 用户终审不在本函数范围)
func RouteIntent(it intent.IntentType, profile TaskProfile) RouteDecision {
// R0:确定性路由表——与既有意图分发完全等价(默认即现状)
switch it {
case intent.IntentTriggerTask, intent.IntentAdvanceStage:
return RouteDecision{Channel: ChannelSquadFixed, Confidence: 0.95, Reason: "标准任务/阶段推进指令,进入小队受控执行", Profile: profile}
case intent.IntentTriggerAdhocTask:
return RouteDecision{Channel: ChannelSquadAdhoc, Confidence: 0.95, Reason: "自由任务升级确认,进入特设小队执行", Profile: profile}
case intent.IntentSecurityForbidden,
intent.IntentDocGovClaim, intent.IntentDocGovSubmitReview, intent.IntentDocGovReviewApprove,
intent.IntentDocGovReviewReject, intent.IntentDocGovFinalApprove, intent.IntentDocGovFinalReject,
intent.IntentDocGovProposalDecide, intent.IntentQueryRejectReason,
intent.IntentQueryProgress, intent.IntentQueryRegulations, intent.IntentQuerySimilarCases,
intent.IntentQueryUserRights, intent.IntentSupplementInfo, intent.IntentDeclineExpectation:
return RouteDecision{Channel: ChannelTemplate, Confidence: 0.95, Reason: "查询/治理/补充类意图,走规则模板与知识库直答", Profile: profile}
}
// 对话通道意图(CASUAL_CHAT 等):R1 规则画像升级判定
d := RouteDecision{Channel: ChannelDialogue, Confidence: 0.9, Reason: "对话类意图,由项目助手即时作答", Profile: profile}
if profile.ComplexityScore >= 60 && profile.DeliverableForm != FormAnswer {
d.UpgradeHint = true
d.Reason = fmt.Sprintf("对话类意图但画像偏重(%d 分: %s),先即时初答并附升级建议",
profile.ComplexityScore, strings.Join(profile.ComplexitySignals, "、"))
}
return d
}
// upgradeHintParagraph 升级建议话术(追加在即时初答之后;不强推、可拒绝)
func upgradeHintParagraph(profile TaskProfile) string {
return fmt.Sprintf(
"🚀 顺手提一句:您这个请求命中了复杂任务信号(%s,画像 %d 分)。"+
"如果您希望把它升级为正式的小队任务——多角色交叉论证、受控文档质量门禁、可追溯引用与归档锁定,"+
"可以直接回复「好」或「1」,我立即组建特设智能体小队为您执行;也可以继续和我对话细化需求。",
strings.Join(profile.ComplexitySignals, "、"), profile.ComplexityScore)
}
// buildUpgradeExpectation 构造升级挂起期望(完全复用 PendingExpectation 机制,零新交互范式)
func buildUpgradeExpectation(rawInput string, profile TaskProfile) *intent.PendingExpectation {
estSteps := 4
if profile.ComplexityScore >= 80 {
estSteps = 6
}
options := []intent.ExpectationOption{
{
OptionID: "1",
Title: fmt.Sprintf("升级为小队正式任务(预计 %d 步,多角色交叉论证 + 质量门禁)", estSteps),
Description: "组建特设智能体小队受控执行本任务:动态规划、黑板事实沉淀、三层验收、归档锁定",
Intent: intent.IntentTriggerAdhocTask,
Slots: map[string]string{intent.SlotTaskBrief: rawInput},
},
{
OptionID: "2",
Title: "先继续对话,深化需求后再决定",
Description: "继续与我讨论细化目标、约束与交付形态",
Intent: intent.IntentCasualChat,
},
{
OptionID: "3",
Title: "暂不升级,维持对话答复",
Description: "当前初答已够用",
Intent: intent.IntentDeclineExpectation,
},
}
return &intent.PendingExpectation{
ExpectationID: fmt.Sprintf("upgrade_adhoc_%d", time.Now().UnixNano()),
ExpectedIntent: intent.IntentTriggerAdhocTask,
TargetAction: "launch_adhoc_task",
Prompt: "是否将本请求升级为小队正式任务?",
AffirmativeWord: "升级",
ContextSnippet: fmt.Sprintf("检测到复杂自由任务信号(%s),已给出即时初答并建议升级", strings.Join(profile.ComplexitySignals, "、")),
DefaultOption: &options[0],
Options: options,
AllowAffirmative: true,
CreatedAt: time.Now(),
}
}
// maybeAttachUpgradeHint 在对话通道回复上附加升级建议(冷却:每会话至多主动建议一次)
func maybeAttachUpgradeHint(rawInput string, userCtx *UserProjectContext, reply string, baseExp *intent.PendingExpectation) (string, *intent.PendingExpectation) {
if userCtx == nil || userCtx.UpgradeOffered {
return reply, baseExp
}
profile := ProfileTask(rawInput, userCtx)
decision := RouteIntent(intent.IntentCasualChat, profile)
if !decision.UpgradeHint {
return reply, baseExp
}
userCtx.UpgradeOffered = true
return reply + "\n\n" + upgradeHintParagraph(profile), buildUpgradeExpectation(rawInput, profile)
}
// GovernanceSummaryForReport 导出治理审计摘要(供 e2e 质量评估 harness 等外部调用使用)
func GovernanceSummaryForReport(workDir string) string {
return governanceSummary(workDir)
}
// governanceSummary 汇总工作区治理审计信息(轨迹 + 归档 + 预算用量)
func governanceSummary(workDir string) string {
if workDir == "" {
return ""
}
ledger := squad.LoadOrCreateLedger(workDir, squad.DefaultBudgetPolicy())
entries := squad.LoadTrace(workDir)
frozen := 0
if data, err := os.ReadFile(filepath.Join(workDir, "workflow.json")); err == nil {
var wf squad.WorkflowPlan
if json.Unmarshal(data, &wf) == nil {
for _, s := range wf.Steps {
if s.Frozen {
frozen++
}
}
}
}
return fmt.Sprintf("轨迹 %d 条 | 归档锁定 %d 步 | %s", len(entries), frozen, ledger.UsageSummary())
}
// deriveAdhocTitle 从任务简报提炼人类可读标题(首个分隔符前,截断 24 字)
func deriveAdhocTitle(brief string) string {
t := strings.TrimSpace(brief)
if t == "" {
t = "自由任务"
}
for _, sep := range []string{",", "。", ";", ",", ".", "\n"} {
if idx := strings.Index(t, sep); idx > 4 {
t = t[:idx]
break
}
}
r := []rune(t)
if len(r) > 24 {
r = r[:24]
}
return string(r)
}
// sanitizeAdhocTaskKey 生成合法任务键(纯 ASCII 安全字符,用于工作区目录命名)。
// 注意:目录名会进入 pi-agent 容器的路径映射,含全角标点/长中文的目录名会触发
// 容器文件可见性异常(实测 context/、template.md 等在容器内 ENOENT)——
// 目录名只用 ASCII,中文标题经 req.Title 元数据流转展示。
func sanitizeAdhocTaskKey(title string) string {
var b strings.Builder
for _, r := range strings.TrimSpace(title) {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
b.WriteRune(r)
}
}
key := strings.Trim(b.String(), "_-")
if key == "" {
key = "free_task"
}
if len(key) > 24 {
key = key[:24]
}
return "adhoc_" + key
}