Wire intent→coordinator DocResolver confirm cards→action_executor kinds for comment, reopen, pending-supplements query, and real submit-review execution.
312 lines
12 KiB
Go
312 lines
12 KiB
Go
package coordinator
|
||
|
||
// ============================================================================
|
||
// 任务路由层 (TaskRouter) — 设计规范 §4
|
||
// 语义维度(L1 意图,已有)× 执行维度(任务画像,新增)的二维判定。
|
||
// 防回归三原则:
|
||
// 1. 默认即现状:R0 确定性路由表与既有意图分发完全等价,路由层只新增"升级建议"路径;
|
||
// 2. 确定性优先:画像由可单测的规则信号加权,不引入路由玄学;
|
||
// 3. 升级需确认 + 冷却:长任务通道切换必须经用户确认,每会话至多主动建议一次。
|
||
// ============================================================================
|
||
|
||
import (
|
||
"context"
|
||
"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, intent.IntentTriggerWorkflow,
|
||
intent.IntentRunResume, intent.IntentRunRestart:
|
||
return RouteDecision{Channel: ChannelSquadFixed, Confidence: 0.95, Reason: "标准任务/阶段推进/工作流运行/长任务续跑指令,进入小队受控执行", Profile: profile}
|
||
case intent.IntentSwitchWorkflow:
|
||
return RouteDecision{Channel: ChannelTemplate, 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.IntentDocGovCommentAdd, intent.IntentDocGovCommentResolve,
|
||
intent.IntentDocGovReopenRequest, intent.IntentDocGovReopenApprove,
|
||
intent.IntentQueryRejectReason, intent.IntentQueryPendingSupplements,
|
||
intent.IntentQueryProgress, intent.IntentQueryRegulations, intent.IntentQuerySimilarCases,
|
||
intent.IntentQueryUserRights, intent.IntentQueryWorkflowStatus, intent.IntentQueryDocStatus,
|
||
intent.IntentHelpCapabilities, intent.IntentUnsupportedAction, intent.IntentRunStatusQuery,
|
||
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) {
|
||
return maybeAttachUpgradeHintWithEngine(nil, context.Background(), rawInput, userCtx, reply, baseExp)
|
||
}
|
||
|
||
// maybeAttachUpgradeHintWithEngine is the engine-aware variant that can shadow-observe jevdecide.
|
||
func maybeAttachUpgradeHintWithEngine(e *CoordinatorEngine, ctx context.Context, rawInput string, userCtx *UserProjectContext, reply string, baseExp *intent.PendingExpectation) (string, *intent.PendingExpectation) {
|
||
if userCtx == nil || userCtx.UpgradeOffered {
|
||
return reply, baseExp
|
||
}
|
||
profile := ProfileTask(rawInput, userCtx)
|
||
if e != nil {
|
||
e.shadowProfile(ctx, rawInput, profile)
|
||
}
|
||
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
|
||
}
|