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 }