package coordinator import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" "time" "chat-agent-system/internal/agent" "chat-agent-system/internal/app/stageflow" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/docgov" "chat-agent-system/internal/intent" "chat-agent-system/internal/kb" "chat-agent-system/internal/maturity" "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/squad" "chat-agent-system/internal/stream" ) // CoordinationStatus 协调裁决状态 type CoordinationStatus string const ( StatusAccepted CoordinationStatus = "ACCEPTED" StatusRejected CoordinationStatus = "REJECTED" StatusClarificationRequired CoordinationStatus = "CLARIFICATION_REQUIRED" ) // PendingAction 声明对话层需执行的真实动作(由 Session 持有服务后执行)。 type PendingAction struct { Kind string // docgov.review_approve | docgov.approver_approve | docgov.reject | taskrun.resume | taskrun.restart DocID string RunID string WorkDir string Reason string Comment string Rollback docgov.DocumentState TaskKey string } // CoordinationResult 协调层给对话层的回执 type CoordinationResult struct { Status CoordinationStatus `json:"status"` Intent intent.IntentType `json:"intent"` Branch intent.ResolutionBranch `json:"branch,omitempty"` RejectReason string `json:"reject_reason,omitempty"` ClarificationPrompt string `json:"clarification_prompt,omitempty"` NaturalReply string `json:"natural_reply"` AdvisorBrief *squad.AdvisorBriefing `json:"advisor_brief,omitempty"` ArtifactPath string `json:"artifact_path,omitempty"` UpdatedContext *UserProjectContext `json:"updated_context"` InteractiveCard *intent.InteractiveCardPayload `json:"interactive_card,omitempty"` NextExpectation *intent.PendingExpectation `json:"next_expectation,omitempty"` ClearExpectation bool `json:"clear_expectation,omitempty"` ReplySource string `json:"reply_source,omitempty"` // pi-agent | template PendingAction *PendingAction `json:"-"` // Task projection for clickable document entry (sync TRIGGER_TASK / adhoc). TaskStatus string `json:"task_status,omitempty"` DocID string `json:"doc_id,omitempty"` ArtifactTitle string `json:"artifact_title,omitempty"` TaskKey string `json:"task_key,omitempty"` PendingAgentCandidateID int64 `json:"pending_agent_candidate_id,omitempty"` PendingChangeReportID int64 `json:"pending_change_report_id,omitempty"` PendingRiskLevel string `json:"pending_risk_level,omitempty"` } // CoordinatorEngine L2 协调引擎 type CoordinatorEngine struct { stageTasks StageTaskRunner instSvc *projectinstance.Service assembler *ContextAssembler dialogueBackend agent.Backend dataDir string ctxMgr *ctxmgr.ContextManager kbClient *kb.Client impact ImpactActions projectAccess ProjectAccess } // StageTaskRunner is the application owner for ExecuteTask / ExecuteStage. type StageTaskRunner interface { ExecuteTask(ctx context.Context, req stageflow.TaskRunRequest) (*stageflow.TaskRunResult, error) ExecuteStage(ctx context.Context, req stageflow.StageRunRequest) (*stageflow.StageRunResult, error) } func (e *CoordinatorEngine) SetStageTaskRunner(r StageTaskRunner) { if e != nil { e.stageTasks = r } } func (e *CoordinatorEngine) tasks() StageTaskRunner { if e == nil { return nil } return e.stageTasks } func (e *CoordinatorEngine) SetContextServices(ctxMgr *ctxmgr.ContextManager, kbClient *kb.Client) { if e != nil { e.ctxMgr = ctxMgr e.kbClient = kbClient } } // SetInstanceService 注入项目工作流实例服务(对话推进与实例门禁合流)。 func (e *CoordinatorEngine) SetInstanceService(svc *projectinstance.Service) { if e != nil { e.instSvc = svc } } // SetImpactActions wires read-only impact analysis and confirmed revision proposals. func (e *CoordinatorEngine) SetImpactActions(a ImpactActions) { if e != nil { e.impact = a } } func NewCoordinatorEngine(runner StageTaskRunner) *CoordinatorEngine { return &CoordinatorEngine{ stageTasks: runner, assembler: NewContextAssembler(), } } // NewCoordinatorEngineWithDialogue 注入对话 LLM 后端(Phase A:CASUAL / AMBIGUOUS) func NewCoordinatorEngineWithDialogue(runner StageTaskRunner, dialogueBackend agent.Backend, dataDir string) *CoordinatorEngine { return &CoordinatorEngine{ stageTasks: runner, assembler: NewContextAssembler(), dialogueBackend: dialogueBackend, dataDir: dataDir, } } // ProcessIntent 校验候选意图合法性并驱动调度与响应 func (e *CoordinatorEngine) ProcessIntent( ctx context.Context, recRes *intent.IntentRecognitionResult, userCtx *UserProjectContext, ) (*CoordinationResult, error) { if userCtx == nil { userCtx = &UserProjectContext{ User: UserInfo{ID: "guest", Name: "用户", Role: "engineer", ProjectRole: "composer"}, BlackboardFacts: make(map[string]string), } } // 交互卡片 TTL 轮次消退与生命周期清理 if userCtx.InteractiveCard != nil { _ = ApplyContextUpdate(userCtx, ContextUpdate{ExpectedVersion: userCtx.Version, TickInteractive: true}) } if userCtx.PendingExpectation != nil && !userCtx.PendingExpectation.ExpiresAt.IsZero() && time.Now().After(userCtx.PendingExpectation.ExpiresAt) { userCtx.applyPending(nil) } top := recRes.TopIntent if top == nil { top = &intent.CandidateIntent{ Intent: intent.IntentCasualChat, Confidence: 1.0, } } // Phase 5:RankedFrames 写/读冲突二次门禁 if intent.RankedFrameConflictRequiresClarification(recRes) { msg := "检测到查询与写操作意图接近,为避免误执行,请明确您是要查询状态,还是要执行变更操作?" return &CoordinationResult{ Status: StatusClarificationRequired, Intent: top.Intent, Branch: recRes.Branch, ClarificationPrompt: msg, NaturalReply: msg, UpdatedContext: userCtx, }, nil } // Phase 5:有限多意图计划(query+query / expectation_plus_new) if recRes.IntentPlan != nil && intent.CurrentFeatureFlags().MultiIntentEnabled { if planRes, planErr := e.executeIntentPlan(ctx, recRes, userCtx); planRes != nil { return planRes, planErr } } // Phase 5:V2 灰度路由(intent_v2_decision 开启且档位允许时对齐 V2 帧) if effective := intent.ResolveEffectiveIntent(recRes); effective != top.Intent { top = &intent.CandidateIntent{ Intent: effective, Confidence: top.Confidence, Branch: top.Branch, FilledSlots: top.FilledSlots, MissingSlots: top.MissingSlots, Reasoning: top.Reasoning + " (v2_decision)", ClarificationQuestion: top.ClarificationQuestion, MatchedOption: top.MatchedOption, PatchSlots: top.PatchSlots, IsDecline: top.IsDecline, } recRes.TopIntent = top } streamer := stream.FromContext(ctx) if streamer != nil { streamer.EmitStatus(string(top.Intent), string(recRes.Branch), string(userCtx.Project.CurrentStage), fmt.Sprintf("意图判定: %s (置信度: %.0f%%)", top.Intent, top.Confidence*100), top.FilledSlots) } logRouteDecision(ctx, recRes, RouteIntent(top.Intent, TaskProfile{})) // 1. 若缺少核心槽位且需定向澄清(优先于模糊咨询,避免缺槽追问被参谋兜底截断) if recRes.RequiresClarifying && top.ClarificationQuestion != "" { return &CoordinationResult{ Status: StatusClarificationRequired, Intent: top.Intent, Branch: recRes.Branch, ClarificationPrompt: top.ClarificationQuestion, NaturalReply: top.ClarificationQuestion, UpdatedContext: userCtx, }, nil } // 2. 若为分支 3 (模糊/分忧引导),触发参谋主动分析与建议(Phase A:优先真实 LLM) if recRes.Branch == intent.BranchAmbiguousFallback { res, err := e.handleAmbiguousConsult(ctx, top, userCtx, recRes.RawInput) if err == nil && res != nil { e.enrichAdvisorBriefWithCases(ctx, res.AdvisorBrief, userCtx, top.FilledSlots) } return res, err } // 3. 根据候选意图执行分支 var coordRes *CoordinationResult var coordErr error switch top.Intent { case intent.IntentDeclineExpectation: coordRes, coordErr = e.handleDeclineExpectation(userCtx) case intent.IntentQueryProgress: coordRes, coordErr = e.handleQueryProgress(ctx, top, userCtx, recRes.RawInput) case intent.IntentSupplementInfo: coordRes, coordErr = e.handleSupplementInfo(top, userCtx) case intent.IntentAdvanceStage: coordRes, coordErr = e.handleAdvanceStage(ctx, top, userCtx) case intent.IntentTriggerTask: coordRes, coordErr = e.handleTriggerTask(ctx, top, userCtx) case intent.IntentTriggerAdhocTask: coordRes, coordErr = e.handleTriggerAdhocTask(ctx, top, userCtx, recRes.RawInput) case intent.IntentCasualChat: coordRes, coordErr = e.handleCasualChat(ctx, userCtx, recRes.RawInput) case intent.IntentSecurityForbidden: coordRes, coordErr = e.handleSecurityForbidden(userCtx) case intent.IntentDocGovReviewReject, intent.IntentDocGovFinalReject: coordRes, coordErr = e.handleDocGovReject(ctx, top, userCtx) case intent.IntentQueryRejectReason: coordRes, coordErr = e.handleQueryRejectReason(ctx, top, userCtx) case intent.IntentDocGovReviewApprove, intent.IntentDocGovFinalApprove: coordRes, coordErr = e.handleDocGovApprove(ctx, top, userCtx) case intent.IntentDocGovClaim: coordRes, coordErr = e.handleDocGovClaim(ctx, top, userCtx) case intent.IntentDocGovSubmitReview: coordRes, coordErr = e.handleDocGovSubmitReview(ctx, top, userCtx) case intent.IntentDocGovProposalDecide: coordRes, coordErr = e.handleDocGovProposalDecide(ctx, top, userCtx) case intent.IntentQueryRegulations: coordRes, coordErr = e.handleQueryRegulations(ctx, top, userCtx) case intent.IntentQuerySimilarCases: coordRes, coordErr = e.handleQuerySimilarCases(ctx, top, userCtx) case intent.IntentQueryUserRights: coordRes, coordErr = e.handleQueryUserRights(ctx, top, userCtx) case intent.IntentSwitchWorkflow: coordRes, coordErr = e.handleSwitchWorkflow(ctx, top, userCtx) case intent.IntentTriggerWorkflow: coordRes, coordErr = e.handleTriggerWorkflow(ctx, top, userCtx) case intent.IntentQueryWorkflowStatus: coordRes, coordErr = e.handleQueryWorkflowStatus(ctx, top, userCtx) case intent.IntentQueryDocStatus: coordRes, coordErr = e.handleQueryDocStatus(ctx, top, userCtx) case intent.IntentHelpCapabilities: coordRes, coordErr = e.handleHelpCapabilities(ctx, top, userCtx) case intent.IntentUnsupportedAction: coordRes, coordErr = e.handleUnsupportedAction(ctx, top, userCtx) case intent.IntentRunStatusQuery: coordRes, coordErr = e.handleRunStatusQuery(ctx, top, userCtx) case intent.IntentRunResume: coordRes, coordErr = e.handleRunResume(ctx, top, userCtx) case intent.IntentRunRestart: coordRes, coordErr = e.handleRunRestart(ctx, top, userCtx) case intent.IntentImpactAnalyze: coordRes, coordErr = e.handleImpactAnalyze(ctx, top, userCtx, recRes.RawInput) case intent.IntentRevisePropose: coordRes, coordErr = e.handleRevisePropose(ctx, top, userCtx, recRes.RawInput) default: nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name) coordRes = &CoordinationResult{ Status: StatusAccepted, Intent: top.Intent, Branch: recRes.Branch, NaturalReply: "我已收到您的指示。针对当前项目,请问需要我为您查询进展、补充技术事实,还是推进方案论证?随时吩咐我。", UpdatedContext: userCtx, NextExpectation: nextExp, } } if coordErr == nil && coordRes != nil { e.enrichAdvisorBriefWithCases(ctx, coordRes.AdvisorBrief, userCtx, top.FilledSlots) } return coordRes, coordErr } // enrichAdvisorBriefWithCases 结合上下文与意图槽位检索相似标杆案例并注入参谋锦囊 func (e *CoordinatorEngine) enrichAdvisorBriefWithCases(ctx context.Context, brief *squad.AdvisorBriefing, userCtx *UserProjectContext, slots map[string]string) { if brief == nil || e.ctxMgr == nil { return } ctxMgr := e.ctxMgr if ctxMgr == nil { return } // 构造搜索语句 queryParts := []string{userCtx.Project.DeviceType, userCtx.Project.IntendedUse} if topic, ok := slots[intent.SlotQueryTopic]; ok && topic != "" { queryParts = append(queryParts, topic) } query := strings.TrimSpace(strings.Join(queryParts, " ")) if query == "" { query = userCtx.Project.Name } if query == "" { return } cases, err := ctxMgr.SearchSimilarCases(ctx, ctxmgr.CaseSearchOptions{ Query: query, Limit: 2, MinScore: 0.10, }) if err != nil || len(cases) == 0 { return } for _, c := range cases { brief.RecommendedCases = append(brief.RecommendedCases, squad.AdvisorCaseRecommendation{ DocID: c.DocID, ProjectName: c.ProjectName, TaskKey: c.TaskKey, ArtifactTitle: c.ArtifactTitle, SectionTitle: c.SectionTitle, SummarySnippet: c.SummarySnippet, KeyRegulations: c.KeyRegulations, SimilarityScore: c.SimilarityScore, ApprovedAt: c.ApprovedAt, }) } } // handleDeclineExpectation 处理用户暂缓或取消上轮建议 (分支 1 放弃) func (e *CoordinatorEngine) handleDeclineExpectation(userCtx *UserProjectContext) (*CoordinationResult, error) { projName := userCtx.Project.Name if projName == "" { projName = "当前项目" } userCtx.applyPending(nil) if userCtx.InteractiveCard != nil { userCtx.dismissCard("cancelled") } reply := fmt.Sprintf("收到,听您的!那我们先不急着推进。\n针对「%s」,您手头还有哪些细节、技术顾虑或商务考量想先聊聊?我随时待命为您解答。", projName) return &CoordinationResult{ Status: StatusAccepted, Intent: intent.IntentDeclineExpectation, Branch: intent.BranchExpectationHit, NaturalReply: reply, UpdatedContext: userCtx, ClearExpectation: true, }, nil } // handleAmbiguousConsult 处理用户犹豫或征询建议 (分支 3 模糊分忧) func (e *CoordinatorEngine) handleAmbiguousConsult(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext, rawInput string) (*CoordinationResult, error) { currentStage := userCtx.Project.CurrentStage projName := userCtx.Project.Name if projName == "" { projName = "当前项目" } // 只在低置信度的模糊咨询里,才用关键词改走测试分析。高置信度保持原意图。 if cand.Confidence < 0.6 && (strings.Contains(rawInput, "测试") || strings.Contains(rawInput, "验证") || strings.Contains(rawInput, "型检") || strings.Contains(rawInput, "难点") || strings.Contains(rawInput, "注意") || strings.Contains(rawInput, "比较难") || strings.Contains(rawInput, "方案")) { if cand.FilledSlots == nil { cand.FilledSlots = make(map[string]string) } cand.FilledSlots[intent.SlotQueryTopic] = "testing_analysis" return e.handleQueryProgress(ctx, cand, userCtx, rawInput) } var sb strings.Builder sb.WriteString(fmt.Sprintf("完全理解您想再掂量一下。针对「%s」目前的节点,我帮您快速对照一下:\n\n", projName)) if currentStage == maturity.StageV01_Triage { sb.WriteString("• 若推进 V0.5(方案成型与法规对齐):\n") sb.WriteString(" 我可帮您梳理初步架构与注册分类路径,并预估型检成本,便于与客户深入沟通;\n") sb.WriteString("• 若先放一放 / 多做调研:\n") sb.WriteString(" 可先补齐预算、目标市场或使用场景等信息,降低后续返工风险。\n\n") sb.WriteString("💡 如果您接下来更希望先有一份可审阅的材料,我可以先整理一版 V0.5 方案框架与法规路径;节奏仍由您决定。\n\n") sb.WriteString("您更倾向于:\n") sb.WriteString("[1] 先做 V0.5 方案框架(可直接回复“好”或“1”)\n") sb.WriteString("[2] 先说明您当前最关心的细节,我做针对性分析") } else { sb.WriteString("当前各项工作进展平稳。您可以把零散想法随时告诉我,我来帮您沉淀;时机成熟时,再一起推进下一阶段也完全可以。") } reply := sb.String() source := ReplySourceTemplate if llmReply, llmSource := e.generateDialogueReply(ctx, rawInput, userCtx); llmReply != "" { reply = llmReply source = llmSource } nextExp := BuildNextExpectation(currentStage, projName) // 路由层:复杂自由任务画像命中时,先给即时初答,再附升级建议(复用挂起期望机制,含每会话冷却) reply, nextExp = maybeAttachUpgradeHint(rawInput, userCtx, reply, nextExp) userCtx.applyPending(nextExp) return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: intent.BranchAmbiguousFallback, NaturalReply: reply, AdvisorBrief: userCtx.LatestAdvisorBrief, UpdatedContext: userCtx, NextExpectation: nextExp, ReplySource: source, }, nil } func (e *CoordinatorEngine) handleQueryProgress(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext, rawInput string) (*CoordinationResult, error) { brief := userCtx.LatestAdvisorBrief topic := cand.FilledSlots[intent.SlotQueryTopic] nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name) userCtx.applyPending(nextExp) // 1. 若为测试方案/难点/验证咨询,直接调取测试经理与参谋专业知识库解答 if topic == "testing_analysis" { if llmReply, llmSource := e.generateDialogueReply(ctx, rawInput, userCtx); llmReply != "" { return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: llmReply, AdvisorBrief: brief, UpdatedContext: userCtx, NextExpectation: nextExp, ReplySource: llmSource, }, nil } projName := userCtx.Project.Name if projName == "" || projName == "医疗器械研发项目" { projName = "肌骨超声AI辅助诊断系统" } greet := strings.TrimSpace(userCtx.User.Name) if greet == "" { greet = "您好" } else { greet += ",您好" } var sb strings.Builder sb.WriteString(fmt.Sprintf("%s!针对「%s」的系统验证方案(SVP)与型式检验要求,我为您梳理以下核心难点项目与特别注意事项:\n\n", greet, projName)) sb.WriteString("### 一、 核心测试难点项目\n\n") sb.WriteString("1. **声学输出安全与探头表面温升极限(GB 9706.237-2020)**\n") sb.WriteString(" • **难点分析**:肌骨超声常需配合高频线阵探头(10-18MHz)进行浅表滑膜微血流彩色多普勒(CDFI/Power Doppler)检测。连续高占空比发射脉冲会导致探头声透镜声吸收发热。根据强标要求,探头施加于人体的表面温升严禁超过 **41℃**,同时机械指数(MI)需严格低于 1.9,热指数(TIS/TIB)需低于 1.0。\n") sb.WriteString(" • **试验考量**:需在 33℃ 恒温仿生水槽中进行连续 30 分钟满功率复合工况热电偶布点实测,温控算法防呆测试难度较高。\n\n") sb.WriteString("2. **高频浅表微小解剖量测公差与体模标定(GB 10188 / KS107BG 仿组织体模)**\n") sb.WriteString(" • **难点分析**:肌腱撕裂缝隙及滑膜增厚量测精度要求达到 **±0.3mm 或 ±3%**。在 1~3cm 浅表盲区内,探头晶片近场声束干涉容易造成线靶伪影或横向展宽。\n") sb.WriteString(" • **试验考量**:对组织仿生体模内部靶群的纵向、横向分辨力及几何位置示值误差进行多批次盲态复测,对探头声阻抗匹配层与声透镜一致性要求极其苛刻。\n\n") sb.WriteString("3. **AI 算法病灶识别与量化分割的泛化鲁棒性(YY/T 0664 / 软件性能测试)**\n") sb.WriteString(" • **难点分析**:超声影像固有的斑点噪声(Speckle Noise)、声影及不同患者皮下脂肪厚度差异极大。滑膜边界 Dice 系数(≥0.85)在骨皮质高回声边缘极易出现伪阳性粘连;血流微信号半定量分级在探头晃动时易出现运动伪影误判。\n") sb.WriteString(" • **试验考量**:需要建立多中心、不同机型/探头采集的独立盲测黄金数据集(由 3 名资深超声主任医师独立背对背标定一致性),验证测试集与训练集严禁污染交叉。\n\n") sb.WriteString("### 二、 需要特别注意的关键地方\n\n") sb.WriteString("• **标准体模计量有效期与测试环境温湿度**:仿生超声体模(如 KS107BG)的声速(标准 1540 m/s)对环境温度极为敏感,必须在 **23℃ ± 2℃** 严格恒温实验室实施,且体模必须在有效计量标定期内,否则测距误差将产生系统性偏移;\n") sb.WriteString("• **电磁兼容(EMC GB 9706.102-2021)抗扰度储备**:高频微弱回波信号接收通道抗射频电磁场辐射敏感度高,测试方案中需提前验证探头线缆双层屏蔽接地与机箱屏蔽缝隙,避免正式型检时辐射发射(RE)超标返工;\n") sb.WriteString("• **DICOM 与数据脱敏合规**:软件系统测试中,所有导出样本、测试影像的患者个人隐私标识(PII)脱敏规则需执行 100% 覆盖率验证;\n") sb.WriteString("• **极端异常输入与容错安全防呆**:探头带电热插拔、探头声透镜声胶脱落阻抗异常检测、长时间无操作探头自动降频防发热休眠逻辑,均需设计破坏性负向测试用例。\n\n") sb.WriteString("💡 **建议行动**:若您需要,我可以为您起草该模块对应的《系统验证方案 (SVP) 关键测试用例矩阵》,列出详细的测试条件、前置配置与合格判据。") return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: sb.String(), AdvisorBrief: brief, UpdatedContext: userCtx, NextExpectation: nextExp, ReplySource: ReplySourceTemplate, }, nil } // 2. 若为特定技术/架构/功耗方案咨询,直接调取参谋专业知识库解答,不强依赖阶段简报 if topic == "technical_design" { var sb strings.Builder sb.WriteString(fmt.Sprintf("关于「%s」的技术与系统架构考量,我为您梳理以下核心要点:\n", userCtx.Project.Name)) sb.WriteString("• 供电与功耗设计:建议选用高能效前端模拟芯片与智能休眠调度,兼顾便携连续工作时间与温升控制;\n") sb.WriteString("• 电气安全与标准:设备需符合 GB 9706.1-2020 通用安全与医用电气专项要求,重点评估外壳防护等级与绝缘耐压;\n") sb.WriteString("• 供应链选型:关键芯片与传感器优先考虑国内高成熟度元器件,缩短备料周期与型检验证周期。\n\n") sb.WriteString("这项考量已收录。进入方案成型(V0.5)时,我会作为核心约束一并带入论证。") return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: sb.String(), AdvisorBrief: brief, UpdatedContext: userCtx, NextExpectation: nextExp, }, nil } // 2. 无参谋简报的降级应答(修复:曾直接返回 V0.1 推销模板导致答非所问) // 2a. 优先真实对话 LLM 结合项目上下文直接回答本轮问题(Phase A 机制延伸至 QUERY_PROGRESS 降级路径); // 2b. LLM 不可用时,基于黑板事实合成 intake 基准简报,仍按查询主题给出实质研判,末尾附轻量摸底引导。 synthesized := false replySource := "" if brief == nil { if llmReply, llmSource := e.generateDialogueReply(ctx, rawInput, userCtx); llmReply != "" { return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: llmReply, UpdatedContext: userCtx, NextExpectation: nextExp, ReplySource: llmSource, }, nil } brief = synthesizeBaselineBrief(userCtx) synthesized = true replySource = ReplySourceTemplate } var sb strings.Builder switch topic { case "risk": sb.WriteString(fmt.Sprintf("关于「%s」的潜在风险,我为您提前预警以下关键卡点:\n", userCtx.Project.Name)) for _, r := range brief.PreemptiveRisks { sb.WriteString(fmt.Sprintf("• ⚠️ %s\n", r)) } sb.WriteString("\n建议您在后续与客户沟通时预先留出安全边际。") case "talk_script": sb.WriteString("这是为您草拟的对外商务沟通话术,您可以直接使用或微调:\n\n") sb.WriteString(brief.CommunicationKit) case "progress": sb.WriteString(fmt.Sprintf("【进展汇报】 项目当前成熟度等级为 %s(评分 %d/100)。\n", brief.MaturityLevel, brief.MaturityScore)) sb.WriteString(fmt.Sprintf("诊断结论:%s\n\n", brief.MaturityHeadline)) if len(brief.NextBestActions) > 0 { sb.WriteString("建议下一步选项:\n") for idx, a := range brief.NextBestActions { sb.WriteString(fmt.Sprintf("[%d] %s\n", idx+1, a)) } } default: // all sb.WriteString(fmt.Sprintf("您好!项目「%s」当前处于【%s】阶段(成熟度 %d/100)。\n", userCtx.Project.Name, brief.MaturityLevel, brief.MaturityScore)) sb.WriteString(fmt.Sprintf("核心研判:%s\n\n", brief.MaturityHeadline)) if len(brief.PreemptiveRisks) > 0 { sb.WriteString("⚠️ 关键风险预警:\n") for _, r := range brief.PreemptiveRisks { sb.WriteString(fmt.Sprintf(" • %s\n", r)) } sb.WriteString("\n") } if len(brief.NextBestActions) > 0 { sb.WriteString("您可以选择下一步动作:\n") for idx, a := range brief.NextBestActions { sb.WriteString(fmt.Sprintf(" [%d] %s\n", idx+1, a)) } } } // 3. 合成简报场景:先给出实质回答,再轻量附上摸底引导(保留引导、不再答非所问、不强推) if synthesized { sb.WriteString("\n\n💡 说明:项目尚未启动过任何交付任务,以上研判基于行业基准假设。") sb.WriteString("若希望沉淀更精准的第一版立项画像与沟通话术,您可以吩咐我「生成一版 V0.1 需求收集表(立项摸底)」,或直接补充器械类型、目标市场、预算等关键细节。") } return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: sb.String(), AdvisorBrief: brief, UpdatedContext: userCtx, NextExpectation: nextExp, ReplySource: replySource, }, nil } // synthesizeBaselineBrief 在用户尚未执行过任何阶段/任务(无持久化参谋简报)时, // 基于黑板事实与项目信息合成 intake 级基准参谋简报,确保查询类意图始终有实质研判可用。 func synthesizeBaselineBrief(userCtx *UserProjectContext) *squad.AdvisorBriefing { fields := make(map[string]string) for k, v := range userCtx.BlackboardFacts { fields[k] = v } fields["project_name"] = userCtx.Project.Name fields["customer_name"] = userCtx.Project.CustomerName if userCtx.Project.DeviceType != "" { fields["device_type"] = userCtx.Project.DeviceType } if userCtx.Project.IntendedUse != "" { fields["intended_use"] = userCtx.Project.IntendedUse } if userCtx.Project.TargetMarket != "" { fields["target_market"] = userCtx.Project.TargetMarket } return squad.ExtractAdvisorBriefing("intake", "", fields) } func (e *CoordinatorEngine) handleSupplementInfo(cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { factKey := cand.FilledSlots[intent.SlotFactKey] factVal := cand.FilledSlots[intent.SlotFactValue] rawDoc := cand.FilledSlots[intent.SlotSupplementDoc] if userCtx.BlackboardFacts == nil { userCtx.BlackboardFacts = make(map[string]string) } summaryKey := "" if factKey != "" && factVal != "" { userCtx.applyBlackboard(factKey, factVal) summaryKey = fmt.Sprintf("「%s」已更新为「%s」", factKey, factVal) if factKey == "target_market" { userCtx.Project.TargetMarket = factVal } } else { summaryKey = "已补充新的背景信息" } streamer := stream.FromContext(context.Background()) if streamer != nil { streamer.EmitStatus(string(cand.Intent), string(cand.Branch), string(userCtx.Project.CurrentStage), fmt.Sprintf("已吸收最新事实设定:%s", summaryKey), cand.FilledSlots) } // 持久化到 blackboard.json if userCtx.WorkDir != "" { bbPath := filepath.Join(userCtx.WorkDir, "blackboard.json") var bb squad.BlackboardState if data, err := os.ReadFile(bbPath); err == nil { _ = json.Unmarshal(data, &bb) } if bb.Fields == nil { bb.Fields = make(map[string]string) } for k, v := range userCtx.BlackboardFacts { bb.Fields[k] = v } if b, err := json.MarshalIndent(bb, "", " "); err == nil { _ = os.WriteFile(bbPath, b, 0o644) } } nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name) userCtx.applyPending(nextExp) var sb strings.Builder sb.WriteString(fmt.Sprintf("已收到并为您记录最新信息:%s。\n", summaryKey)) sb.WriteString("这些事实已沉淀至项目黑板,后续论证与核价将直接吸收这些设定。\n\n") sb.WriteString(fmt.Sprintf("针对「%s」,我为您梳理了接下来的推进选项:\n", userCtx.Project.Name)) for idx, opt := range nextExp.Options { recMark := "" if idx == 0 { recMark = "(推荐,可直接回复“好”或“1”)" } sb.WriteString(fmt.Sprintf("[%s] %s %s\n", opt.OptionID, opt.Title, recMark)) } if rawDoc != "" && summaryKey == "已补充新的背景信息" { sb.Reset() sb.WriteString("已收录补充材料,并关联到项目事实黑板。\n后续文档修订与阶段推进时,我会自动引用这部分事实。") } return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: sb.String(), AdvisorBrief: userCtx.LatestAdvisorBrief, UpdatedContext: userCtx, NextExpectation: nextExp, }, nil } func (e *CoordinatorEngine) handleAdvanceStage(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { if e.instSvc != nil && e.instSvc.Available() && userCtx != nil && userCtx.Project.ID != "" { projectID, parseErr := strconv.ParseInt(userCtx.Project.ID, 10, 64) userID, userErr := strconv.ParseInt(userCtx.User.ID, 10, 64) if parseErr == nil && userErr == nil && projectID > 0 && userID > 0 { isCreator := userCtx.Project.CreatorUserID > 0 && userID == userCtx.Project.CreatorUserID settings, settingsErr := e.instSvc.GetSettings(ctx, projectID, userID, isCreator) hasLiveGovernance := settingsErr == nil && settings != nil && settings.LiveInstance != nil && settings.LiveInstance.Status != projectinstance.InstanceCompleted && settings.LiveInstance.Status != "terminated" if hasLiveGovernance { if !isCreator { return &CoordinationResult{ Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch, RejectReason: projectinstance.ErrForbiddenNotCreator.Error(), NaturalReply: "只有项目创建者可以确认推进工作流阶段。", UpdatedContext: userCtx, }, projectinstance.ErrForbiddenNotCreator } res, _, advErr := e.tryAdvanceWorkflowInstance(ctx, projectID, userID) if advErr != nil { return &CoordinationResult{ Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch, RejectReason: advErr.Error(), NaturalReply: fmt.Sprintf("阶段推进未通过门禁:%v", advErr), UpdatedContext: userCtx, }, advErr } return res, nil } if isCreator { if res, handled, advErr := e.tryAdvanceWorkflowInstance(ctx, projectID, userID); handled { if advErr != nil { return &CoordinationResult{ Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch, RejectReason: advErr.Error(), NaturalReply: fmt.Sprintf("阶段推进未通过门禁:%v", advErr), UpdatedContext: userCtx, }, advErr } return res, nil } } } } targetStageStr := cand.FilledSlots[intent.SlotTargetStage] targetStage := maturity.MaturityStage(targetStageStr) if targetStage == "" { targetStage = maturity.StageV05_Feasibility } streamer := stream.FromContext(ctx) if streamer != nil { streamer.EmitPhase("stage_transition", fmt.Sprintf("准备推进项目成熟度阶段至【%s】...", targetStage), "coordinator", 0, 0, 0) } // 1. 若携带补丁槽位 (例如 "好,但目标市场改成欧洲CE"),先行持久化更新 patchSummary := "" if len(cand.PatchSlots) > 0 { var patches []string for k, v := range cand.PatchSlots { userCtx.applyBlackboard(k, v) patches = append(patches, fmt.Sprintf("%s=%s", k, v)) if k == "target_market" { userCtx.Project.TargetMarket = v } } patchSummary = strings.Join(patches, ", ") if userCtx.WorkDir != "" { bbPath := filepath.Join(userCtx.WorkDir, "blackboard.json") var bb squad.BlackboardState if data, err := os.ReadFile(bbPath); err == nil { _ = json.Unmarshal(data, &bb) } if bb.Fields == nil { bb.Fields = make(map[string]string) } for k, v := range cand.PatchSlots { bb.Fields[k] = v } if b, err := json.MarshalIndent(bb, "", " "); err == nil { _ = os.WriteFile(bbPath, b, 0o644) } } } // 2. 状态机合法性检查 if userCtx.Project.CurrentStage == targetStage { return &CoordinationResult{ Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch, RejectReason: "ALREADY_IN_STAGE", NaturalReply: fmt.Sprintf("项目目前已经在【%s】阶段中,无需重复推进。您可以吩咐我进行详细论证或查询当前进展。", targetStage), UpdatedContext: userCtx, }, nil } if e.tasks() == nil { userCtx.Project.CurrentStage = targetStage userCtx.AvailableActions = ComputeAvailableActions(targetStage, userCtx.User.ProjectRole) nextExp := BuildNextExpectation(targetStage, userCtx.Project.Name) userCtx.applyPending(nextExp) reply := fmt.Sprintf("已为您模拟推进到阶段【%s】。", targetStage) if patchSummary != "" { reply = fmt.Sprintf("已为您吸收最新设定(%s),并模拟推进到阶段【%s】。", patchSummary, targetStage) } return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: reply, UpdatedContext: userCtx, NextExpectation: nextExp, }, nil } // 3. 调用底层 L3 调度器执行阶段跃迁 stageRes, err := e.tasks().ExecuteStage(ctx, stageflow.StageRunRequest{ Stage: targetStage, ProjectName: userCtx.Project.Name, CustomerName: userCtx.Project.CustomerName, DeviceType: userCtx.Project.DeviceType, IntendedUse: userCtx.Project.IntendedUse, TargetMarket: userCtx.Project.TargetMarket, Fields: userCtx.BlackboardFacts, WorkDir: userCtx.WorkDir, }) if err != nil { return &CoordinationResult{ Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch, RejectReason: err.Error(), NaturalReply: fmt.Sprintf("阶段推进失败: %v", err), UpdatedContext: userCtx, }, err } userCtx.Project.CurrentStage = targetStage userCtx.LatestAdvisorBrief = stageRes.AdvisorBrief userCtx.AvailableActions = ComputeAvailableActions(targetStage, userCtx.User.ProjectRole) if stageRes.WorkDir != "" { userCtx.WorkDir = stageRes.WorkDir } artifactPath := "" if stageRes.PrimaryArtifact != nil { artifactPath = filepath.Join(stageRes.PrimaryArtifact.WorkDir, "output.md") } nextExp := BuildNextExpectation(targetStage, userCtx.Project.Name) userCtx.applyPending(nextExp) var sb strings.Builder if patchSummary != "" { sb.WriteString(fmt.Sprintf("已为您吸收最新设定(%s),并成功推进并完成了【%s】!\n", patchSummary, stageRes.StageTitle)) } else { sb.WriteString(fmt.Sprintf("已为您成功推进并完成了【%s】!\n", stageRes.StageTitle)) } sb.WriteString("相关论证与文档已沉淀完成。\n\n") sb.WriteString(fmt.Sprintf("💡 最新进展:%s\n", stageRes.AdvisorBrief.MaturityHeadline)) sb.WriteString(fmt.Sprintf("建议您审阅交付文档(%s),或按下方建议推进后续洽谈。\n\n", artifactPath)) sb.WriteString("可选下一步:\n") for idx, opt := range nextExp.Options { recMark := "" if idx == 0 { recMark = "(推荐,可直接回复“好”或“1”)" } sb.WriteString(fmt.Sprintf("[%s] %s %s\n", opt.OptionID, opt.Title, recMark)) } return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: sb.String(), AdvisorBrief: stageRes.AdvisorBrief, ArtifactPath: artifactPath, UpdatedContext: userCtx, NextExpectation: nextExp, }, nil } func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { taskKey := cand.FilledSlots[intent.SlotTargetTask] if taskKey == "" { taskKey = "intake" } nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name) userCtx.applyPending(nextExp) if e.tasks() == nil { return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: fmt.Sprintf("已收到生成【%s】的指令。", taskKey), UpdatedContext: userCtx, NextExpectation: nextExp, }, nil } var projectID, userID int64 if userCtx.Project.ID != "" { projectID = parseDecimalID(userCtx.Project.ID) } if userCtx.User.ID != "" { userID = parseDecimalID(userCtx.User.ID) } taskRes, err := e.tasks().ExecuteTask(ctx, stageflow.TaskRunRequest{ TaskKey: taskKey, ProjectName: userCtx.Project.Name, CustomerName: userCtx.Project.CustomerName, DeviceType: userCtx.Project.DeviceType, IntendedUse: userCtx.Project.IntendedUse, TargetMarket: userCtx.Project.TargetMarket, Fields: userCtx.BlackboardFacts, ProjectID: projectID, UserID: userID, }) if err != nil { if errors.Is(err, docgov.ErrDocumentBusy) { msg := "该文档正在由项目助手生成或他人编辑中,请稍后再发起更新。您可以先继续对话或处理其他文档。" if taskRes != nil && taskRes.ErrorReason != "" { msg = taskRes.ErrorReason + "。您可以先继续对话或处理其他文档。" } return &CoordinationResult{ Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch, RejectReason: err.Error(), NaturalReply: msg, UpdatedContext: userCtx, }, nil } if errors.Is(err, docgov.ErrDocumentLocked) { return &CoordinationResult{ Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch, RejectReason: err.Error(), NaturalReply: "该文档已进入审核/批准锁定状态,无法自动覆写正文。如需修订请走驳回解锁或变更提案流程。", UpdatedContext: userCtx, }, nil } return &CoordinationResult{ Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch, RejectReason: err.Error(), NaturalReply: fmt.Sprintf("任务执行失败: %v", err), UpdatedContext: userCtx, }, err } userCtx.LatestAdvisorBrief = taskRes.AdvisorBrief outPath := filepath.Join(taskRes.WorkDir, "output.md") reply, taskStatus := formatTriggerTaskReply(taskRes, outPath) res := &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: reply, AdvisorBrief: taskRes.AdvisorBrief, ArtifactPath: outPath, UpdatedContext: userCtx, NextExpectation: nextExp, TaskStatus: taskStatus, TaskKey: taskRes.TaskKey, ArtifactTitle: taskRes.ArtifactTitle, } attachTaskRunProjection(res, taskRes) return res, nil } // formatTriggerTaskReply builds sync coordinator copy for a TaskRunResult. // Pending high-risk candidates must not claim "正文已保存 / 已完成". func formatTriggerTaskReply(taskRes *stageflow.TaskRunResult, outPath string) (reply string, taskStatus string) { if taskRes == nil { return "任务执行失败。", stageflow.TaskStatusFailed } taskStatus = taskRes.TerminalStatus() if taskRes.AwaitsAgentConfirmation() { return taskRes.CompletionSummary(), taskStatus } var sb strings.Builder sb.WriteString(fmt.Sprintf("已为您自适应规划并完成生成【%s】!\n交付物正文已保存至:%s\n耗时: %v\n", taskRes.ArtifactTitle, outPath, taskRes.Duration)) if taskRes.AdvisorBrief != nil && len(taskRes.AdvisorBrief.BaselineAssumptions) > 0 { sb.WriteString("\n📌 预设基准假设(已融入方案,您可随时修正):\n") for _, a := range taskRes.AdvisorBrief.BaselineAssumptions { sb.WriteString(fmt.Sprintf(" • %s\n", a)) } } return sb.String(), taskStatus } func attachTaskRunProjection(res *CoordinationResult, taskRes *stageflow.TaskRunResult) { if res == nil || taskRes == nil { return } if taskRes.DocRecord != nil { res.DocID = taskRes.DocRecord.DocID } if taskRes.AwaitsAgentConfirmation() { res.PendingAgentCandidateID = taskRes.PendingAgentCandidateID res.PendingChangeReportID = taskRes.PendingChangeReportID res.PendingRiskLevel = taskRes.PendingRiskLevel } } // handleTriggerAdhocTask 处理自由任务升级确认(路由层 R1 升级建议的兑现路径): // 基于任务简报组装特设小队,走 planAutonomousAdhoc 自主规划 + TaskGov 全量治理 // (预算账本 / 执行轨迹 / 重复识别 / 归档锁定 / 质量门禁修复闭环)。 func (e *CoordinatorEngine) handleTriggerAdhocTask(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext, rawInput string) (*CoordinationResult, error) { brief := cand.FilledSlots[intent.SlotTaskBrief] if brief == "" { brief = rawInput } title := deriveAdhocTitle(brief) taskKey := sanitizeAdhocTaskKey(title) nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name) userCtx.applyPending(nextExp) if e.tasks() == nil { return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: fmt.Sprintf("已收到自由任务《%s》的升级指令(当前环境未接入小队服务,简报已登记)。\n任务简报: %s", title, brief), UpdatedContext: userCtx, NextExpectation: nextExp, }, nil } var projectID, userID int64 if userCtx.Project.ID != "" { projectID = parseDecimalID(userCtx.Project.ID) } if userCtx.User.ID != "" { userID = parseDecimalID(userCtx.User.ID) } if st := stream.FromContext(ctx); st != nil { st.EmitPhase("squad_assembly", fmt.Sprintf("正在为自由任务《%s》组建特设智能体小队(动态规划 + 预算管控 + 归档锁定)...", title), "leader", 0, 0, 5) } // 任务简报材料化:作为首要输入材料注入工作区(context/materials_text/task_brief.md), // 供 organizer 专员在容器内直接读到用户原始诉求(否则首步将报"无材料可整理") briefMaterial := stageflow.MaterialInput{ Filename: "task_brief.md", Content: []byte(fmt.Sprintf("# 任务简报(用户原始诉求,权威等级:primary_evidence)\n\n%s\n", brief)), } taskRes, err := e.tasks().ExecuteTask(ctx, stageflow.TaskRunRequest{ TaskKey: taskKey, Title: title, ProjectName: userCtx.Project.Name, CustomerName: userCtx.Project.CustomerName, DeviceType: userCtx.Project.DeviceType, IntendedUse: userCtx.Project.IntendedUse, TargetMarket: userCtx.Project.TargetMarket, Fields: userCtx.BlackboardFacts, Materials: []stageflow.MaterialInput{briefMaterial}, AutoRepair: true, ProjectID: projectID, UserID: userID, }) if err != nil { if taskRes != nil && taskRes.WorkDir != "" { userCtx.WorkDir = taskRes.WorkDir } return &CoordinationResult{ Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch, RejectReason: err.Error(), NaturalReply: fmt.Sprintf("自由任务《%s》执行失败: %v", title, err), UpdatedContext: userCtx, }, err } userCtx.LatestAdvisorBrief = taskRes.AdvisorBrief if taskRes.WorkDir != "" { userCtx.WorkDir = taskRes.WorkDir } outPath := filepath.Join(taskRes.WorkDir, "output.md") var sb strings.Builder taskStatus := taskRes.TerminalStatus() if taskRes.AwaitsAgentConfirmation() { sb.WriteString(taskRes.CompletionSummary()) } else { sb.WriteString(fmt.Sprintf("已为您组建特设小队并完成自由任务《%s》!\n任务简报: %s\n交付物正文已保存至:%s\n耗时: %v\n", title, brief, outPath, taskRes.Duration)) if taskRes.QualityReport != nil { verdict := "⚠️ 未完全通过" if taskRes.QualityReport.Passed { verdict = "✅ 通过" } sb.WriteString(fmt.Sprintf("质量门禁: %s(评分 %.0f)\n", verdict, taskRes.QualityReport.Score)) } if gov := governanceSummary(taskRes.WorkDir); gov != "" { sb.WriteString("治理审计: " + gov + "\n") } if taskRes.AdvisorBrief != nil && len(taskRes.AdvisorBrief.BaselineAssumptions) > 0 { sb.WriteString("\n📌 预设基准假设(已融入方案,您可随时修正):\n") for _, a := range taskRes.AdvisorBrief.BaselineAssumptions { sb.WriteString(fmt.Sprintf(" • %s\n", a)) } } } res := &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: sb.String(), AdvisorBrief: taskRes.AdvisorBrief, ArtifactPath: outPath, UpdatedContext: userCtx, NextExpectation: nextExp, TaskStatus: taskStatus, TaskKey: taskRes.TaskKey, ArtifactTitle: taskRes.ArtifactTitle, } attachTaskRunProjection(res, taskRes) return res, nil } func (e *CoordinatorEngine) handleCasualChat(ctx context.Context, userCtx *UserProjectContext, rawInput string) (*CoordinationResult, error) { projName := userCtx.Project.Name if projName == "" { projName = "当前医疗器械研发项目" } nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name) userCtx.applyPending(nextExp) nameSuffix := "" if userCtx.User.Name != "" { nameSuffix = "," + userCtx.User.Name } reply := fmt.Sprintf("您好%s!我是您的项目助手,很高兴协助您推进医疗器械立项与研发协同。\n\n针对「%s」,我可以帮您:\n• 查询进展、成熟度与风险;\n• 沉淀客户需求与技术指标;\n• 起草沟通话术与方案要点;\n• 推进方案论证与报价核算。\n\n如果您愿意,我们可以先从您当前最关心的一块开始——您想先看进展、补事实,还是直接聊方案?", nameSuffix, projName) source := ReplySourceTemplate if llmReply, llmSource := e.generateDialogueReply(ctx, rawInput, userCtx); llmReply != "" { reply = llmReply source = llmSource } // 路由层:复杂自由任务画像命中时,先给即时初答,再附升级建议(复用挂起期望机制,含每会话冷却) reply, nextExp = maybeAttachUpgradeHint(rawInput, userCtx, reply, nextExp) userCtx.applyPending(nextExp) return &CoordinationResult{ Status: StatusAccepted, Intent: intent.IntentCasualChat, Branch: intent.BranchNewIntentPreempt, NaturalReply: reply, AdvisorBrief: userCtx.LatestAdvisorBrief, UpdatedContext: userCtx, NextExpectation: nextExp, ReplySource: source, }, nil } // handleSecurityForbidden 处理安全红线与合规拦截 (ISO 13485 / 21 CFR Part 11) func (e *CoordinatorEngine) handleSecurityForbidden(userCtx *UserProjectContext) (*CoordinationResult, error) { reply := "⚠️ **合规权限拦截**:根据医疗器械质量管理规范(ISO 13485 / 21 CFR Part 11),企业人员的法定身份(姓名、工号、职位、部门)与系统控制台属于基线受控资产,不支持通过对话动态修改。您可通过‘查询我的权限’查看您在当前各文档中的法定职责。" return &CoordinationResult{ Status: StatusRejected, Intent: intent.IntentSecurityForbidden, Branch: intent.BranchNewIntentPreempt, RejectReason: "SECURITY_FORBIDDEN_BASELINE_ASSET", NaturalReply: reply, UpdatedContext: userCtx, }, nil } // handleDocGovReject 处理受控文档审核/批准驳回与二次确认交互卡片挂载(角色硬门禁) func (e *CoordinatorEngine) handleDocGovReject(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { docName := cand.FilledSlots[intent.SlotTargetDoc] if docName == "" { if userCtx.InteractiveCard != nil && userCtx.InteractiveCard.TargetDocID != "" { docName = userCtx.InteractiveCard.TargetDocID } else { docName = "当前受控文档" } } rawReason := cand.FilledSlots[intent.SlotRejectReason] section := cand.FilledSlots[intent.SlotTargetSection] if section == "" { section = extractSectionClues(rawReason) } role := effectiveDocRole(userCtx) isFinal := cand.Intent == intent.IntentDocGovFinalReject // 与批准对称的硬门禁 if isFinal { if !isApproverAuthorized(userCtx.User) && role != "approver" { return &CoordinationResult{ Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch, RejectReason: "UNAUTHORIZED_REJECT_ROLE", NaturalReply: fmt.Sprintf("权限不足:终审驳回需批准人角色。当前您的身份为「%s」。若需了解驳回原因,可直接询问。", roleLabelCN(role)), UpdatedContext: userCtx, }, nil } } else { if !isReviewerAuthorized(userCtx.User) && role != "reviewer" { return &CoordinationResult{ Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch, RejectReason: "UNAUTHORIZED_REJECT_ROLE", NaturalReply: fmt.Sprintf("权限不足:审核驳回需审核人角色。当前您的身份为「%s」。若需了解驳回原因,可直接询问「被驳回的原因是什么」。", roleLabelCN(role)), UpdatedContext: userCtx, }, nil } } // 规范化策略角色(授权通过后按意图选审核/批准确认卡) policyRole := role if isFinal { policyRole = "approver" } else if policyRole != "reviewer" && isReviewerAuthorized(userCtx.User) { policyRole = "reviewer" } lifecycle := "reviewing" if isFinal { lifecycle = "approving" } resolved := ResolveRejectCard(policyRole, lifecycle, docName, docName, section, rawReason, RejectUtteranceExecute) if resolved.Kind == "deny_text" || resolved.Card == nil { return &CoordinationResult{ Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch, RejectReason: resolved.DenyReason, NaturalReply: resolved.Reply, UpdatedContext: userCtx, }, nil } userCtx.applyCard(resolved.Card) userCtx.applyPending(resolved.Expectation) return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: resolved.Reply, InteractiveCard: resolved.Card, NextExpectation: resolved.Expectation, UpdatedContext: userCtx, }, nil } // handleQueryRejectReason 处理编制人等对驳回原因的查询(信息卡,无危险按钮) func (e *CoordinatorEngine) handleQueryRejectReason(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { docName := cand.FilledSlots[intent.SlotTargetDoc] if docName == "" { docName = "当前受控文档" } role := effectiveDocRole(userCtx) if role == "" { role = "author" } // 优先从黑板或近期事件中取驳回意见 reason := "" if userCtx.BlackboardFacts != nil { reason = userCtx.BlackboardFacts["last_reject_reason"] if reason == "" { reason = userCtx.BlackboardFacts["reject_reason"] } } if reason == "" && len(userCtx.RecentEvents) > 0 { for i := len(userCtx.RecentEvents) - 1; i >= 0; i-- { ev := userCtx.RecentEvents[i] if strings.Contains(ev, "驳回") || strings.Contains(ev, "退回") { reason = ev break } } } resolved := ResolveRejectCard(role, "", docName, docName, "", reason, RejectUtteranceQuery) if resolved.Card != nil { userCtx.applyCard(resolved.Card) } return &CoordinationResult{ Status: StatusAccepted, Intent: intent.IntentQueryRejectReason, Branch: cand.Branch, NaturalReply: resolved.Reply, InteractiveCard: resolved.Card, UpdatedContext: userCtx, }, nil } // extractSectionClues 从驳回意见中进一步抽取章节线索 (如 "第3节", "竞品对比", "抗扰度") func extractSectionClues(text string) string { clues := []string{} // 正则提取 "第X节" 或 "第X章" reSec := regexp.MustCompile(`(第[0-9一二三四五六七八九十]+[章节条部分]+|[0-9]+\.[0-9]+[节章]*)`) if m := reSec.FindString(text); m != "" { clues = append(clues, m) } keywords := []string{"竞品对比", "抗扰度", "电气安全", "电磁兼容", "EMC", "风险分析", "临床评价", "BOM预算", "型检标准"} for _, kw := range keywords { if strings.Contains(text, kw) { clues = append(clues, kw) } } if len(clues) > 0 { return strings.Join(clues, " / ") } return "" } // isReviewerAuthorized 校验审核人或主导工程师权限 func isReviewerAuthorized(u UserInfo) bool { r := strings.ToLower(strings.TrimSpace(u.Role)) pr := strings.ToLower(strings.TrimSpace(u.ProjectRole)) return r == "reviewer" || pr == "reviewer" || r == "lead_engineer" || pr == "lead_engineer" || strings.Contains(pr, "审核") || strings.Contains(r, "审核") || strings.Contains(pr, "主导") || strings.Contains(r, "主导") } // isApproverAuthorized 校验批准人或项目经理权限 func isApproverAuthorized(u UserInfo) bool { r := strings.ToLower(strings.TrimSpace(u.Role)) pr := strings.ToLower(strings.TrimSpace(u.ProjectRole)) return r == "approver" || pr == "approver" || r == "project_manager" || pr == "project_manager" || strings.Contains(pr, "批准") || strings.Contains(r, "批准") || strings.Contains(pr, "总监") || strings.Contains(r, "总监") || r == "director" } // handleDocGovApprove 处理受控文档审核/批准通过与终审批准二次确认 func (e *CoordinatorEngine) handleDocGovApprove(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { docName := cand.FilledSlots[intent.SlotTargetDoc] if docName == "" { if userCtx.InteractiveCard != nil && userCtx.InteractiveCard.TargetDocID != "" { docName = userCtx.InteractiveCard.TargetDocID } else { docName = "当前受控文档" } } currentRole := userCtx.User.ProjectRole if currentRole == "" { currentRole = userCtx.User.Role } if currentRole == "" { currentRole = "未知角色" } isFinal := cand.Intent == intent.IntentDocGovFinalApprove if isFinal { // 校验终审批准权限: approver 或 project_manager if !isApproverAuthorized(userCtx.User) { return &CoordinationResult{ Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch, RejectReason: "UNAUTHORIZED_APPROVAL_ROLE", NaturalReply: fmt.Sprintf("权限不足:受控文档终审批准需要批准人 (approver) 或项目经理 (project_manager) 权限。当前您的身份为「%s」,无权执行终审批准操作。", currentRole), UpdatedContext: userCtx, }, nil } cardID := fmt.Sprintf("card_final_appr_%d", time.Now().UnixNano()) cardPayload := &intent.InteractiveCardPayload{ CardID: cardID, CardType: "CONFIRMATION", Title: "受控文档终审批准生效确认", TargetDocID: docName, TargetDocTitle: fmt.Sprintf("《%s》", docName), ProposedContent: "正式终审批准受控文档,签署电子签名并受控归档生效", ImpactRisk: "终审批准后文档正文将永久锁定为基线版本,后续非受控修改将被严格拦截,任何变更必须走变更审批流程", CurrentStatus: "active", TTLTurns: 3, CreatedAt: time.Now(), Actions: []intent.CardAction{ { ActionID: "confirm_final_approve", Label: "确认终审批准生效", Style: "primary", IsAffirmativeDefault: true, Payload: map[string]string{ "target_doc": docName, "target_action": "confirm_final_approve", }, }, { ActionID: "cancel", Label: "取消", Style: "subtle", Payload: map[string]string{ "action": "cancel", }, }, }, } nextExp := &intent.PendingExpectation{ ExpectationID: "exp_" + cardPayload.CardID, ExpectedIntent: intent.IntentDocGovProposalDecide, TargetAction: "confirm_final_approve", Prompt: "请确认是否执行终审批准操作", AffirmativeWord: "确认", DeclineWord: "取消", ExpiresAt: time.Now().Add(5 * time.Minute), Payload: cardPayload.Actions[0].Payload, AllowAffirmative: true, CreatedAt: time.Now(), DefaultOption: &intent.ExpectationOption{ OptionID: "confirm_final_approve", Title: "确认终审批准生效", Intent: intent.IntentDocGovProposalDecide, Slots: cardPayload.Actions[0].Payload, }, } userCtx.applyCard(cardPayload) userCtx.applyPending(nextExp) reply := fmt.Sprintf("已为您起草针对《%s》的终审批准确认单。终审批准将使文档正式归档并永久锁定,请审阅下方卡片并在确认无误后点击确认或回复‘确认’。", docName) return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: reply, InteractiveCard: cardPayload, NextExpectation: nextExp, UpdatedContext: userCtx, }, nil } // 校验审核通过权限: reviewer 或 lead_engineer if !isReviewerAuthorized(userCtx.User) { return &CoordinationResult{ Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch, RejectReason: "UNAUTHORIZED_REVIEW_ROLE", NaturalReply: fmt.Sprintf("权限不足:受控文档审核通过需要审核人 (reviewer) 或主导工程师 (lead_engineer) 权限。当前您的身份为「%s」,无权执行审核通过操作。", currentRole), UpdatedContext: userCtx, }, nil } reply := fmt.Sprintf("正在处理您对《%s》的审核通过请求…", docName) return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: reply, UpdatedContext: userCtx, PendingAction: &PendingAction{ Kind: "docgov.review_approve", DocID: docName, Comment: cand.FilledSlots[intent.SlotRejectReason], }, }, nil } // handleDocGovClaim 处理受控文档认领 func (e *CoordinatorEngine) handleDocGovClaim(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { docName := cand.FilledSlots[intent.SlotTargetDoc] if docName == "" { docName = "当前受控文档" } // 记录在用户的已认领清单中 found := false for _, d := range userCtx.UserDocs.Claimed { if d == docName { found = true break } } if !found { userCtx.UserDocs.Claimed = append(userCtx.UserDocs.Claimed, docName) } reply := fmt.Sprintf("您已成功认领《%s》。系统已锁定您作为该文档的法定责任人,您可以开始在线就地修订正文或提交审核。", docName) return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: reply, UpdatedContext: userCtx, }, nil } // handleDocGovSubmitReview 处理受控文档提交审核 func (e *CoordinatorEngine) handleDocGovSubmitReview(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { docName := cand.FilledSlots[intent.SlotTargetDoc] if docName == "" { docName = "当前受控文档" } reply := fmt.Sprintf("《%s》已成功提交审核!文档正文已进入受控锁定状态,并已通知审核责任人进行合规审查与批注。", docName) return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: reply, UpdatedContext: userCtx, }, nil } // handleDocGovProposalDecide 处理智能体修改建议与提案裁决,以及驳回/终审批准确认执行 func (e *CoordinatorEngine) handleDocGovProposalDecide(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { targetAction := cand.FilledSlots["target_action"] if targetAction == "" { targetAction = cand.FilledSlots["action"] } if targetAction == "" && cand.MatchedOption != nil { targetAction = cand.MatchedOption.OptionID } docName := cand.FilledSlots[intent.SlotTargetDoc] if docName == "" && userCtx.InteractiveCard != nil && userCtx.InteractiveCard.TargetDocID != "" { docName = userCtx.InteractiveCard.TargetDocID } if docName == "" { docName = "当前受控文档" } if targetAction == "confirm_reject" { reason := cand.FilledSlots[intent.SlotRejectReason] if reason == "" && userCtx.InteractiveCard != nil { reason = userCtx.InteractiveCard.ProposedContent } if userCtx.InteractiveCard != nil { userCtx.dismissCard("resolved") } userCtx.applyPending(nil) rollback := docgov.StateDrafting if userCtx.InteractiveCard != nil && strings.Contains(userCtx.InteractiveCard.ImpactRisk, "reviewing") { rollback = docgov.StateDrafting } return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: fmt.Sprintf("正在处理您对《%s》的驳回确认…", docName), UpdatedContext: userCtx, ClearExpectation: true, PendingAction: &PendingAction{ Kind: "docgov.reject", DocID: docName, Reason: reason, Rollback: rollback, }, }, nil } if targetAction == "confirm_final_approve" { if userCtx.InteractiveCard != nil { userCtx.dismissCard("resolved") } userCtx.applyPending(nil) return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: fmt.Sprintf("正在处理您对《%s》的终审批准…", docName), UpdatedContext: userCtx, ClearExpectation: true, PendingAction: &PendingAction{ Kind: "docgov.approver_approve", DocID: docName, }, }, nil } if targetAction == "cancel" { if userCtx.InteractiveCard != nil { userCtx.dismissCard("cancelled") } userCtx.applyPending(nil) return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: fmt.Sprintf("已取消针对《%s》的操作,文档状态保持不变。", docName), UpdatedContext: userCtx, ClearExpectation: true, }, nil } action := cand.FilledSlots[intent.SlotProposalAction] var reply string if action == "reject" { reply = "已拒绝该修改变更提案。文档正文保持原样,提案记录已归档。" } else { reply = "已采纳该修改变更提案,补充内容与参数已合并入受控文档。" } return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: reply, UpdatedContext: userCtx, }, nil } // handleQueryRegulations 处理强标与审评指导原则查询 func (e *CoordinatorEngine) handleQueryRegulations(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { searchQuery := "" if userCtx != nil && userCtx.RewrittenQuery != "" { searchQuery = userCtx.RewrittenQuery } else if userCtx != nil && userCtx.TaskState != nil && userCtx.TaskState.TopicAnchor != "" { searchQuery = userCtx.TaskState.TopicAnchor } else if userCtx != nil { searchQuery = userCtx.Project.Name } if userCtx != nil && userCtx.Project.DeviceType != "" && !strings.Contains(searchQuery, userCtx.Project.DeviceType) { searchQuery += " " + userCtx.Project.DeviceType } var hits []kb.HitItem if e != nil && e.kbClient != nil && strings.TrimSpace(searchQuery) != "" { cacheKey := ctxmgr.HashKey("regulatory_search", searchQuery) cache := ctxmgr.GetGlobalCache() if cached, ok := cache.Get(ctxmgr.LayerC1Search, cacheKey); ok { if ch, ok := cached.([]kb.HitItem); ok { hits = ch } } else { kbHits, err := e.kbClient.Search(ctx, searchQuery, kb.SearchOptions{ Domains: []kb.KnowledgeDomain{kb.DomainRegulatory}, Limit: 5, }) if err == nil && len(kbHits) > 0 { hits = kbHits cache.Set(ctxmgr.LayerC1Search, cacheKey, hits, 30) } } } var sb strings.Builder projectName := "医疗器械项目" if userCtx != nil && userCtx.Project.Name != "" { projectName = userCtx.Project.Name } if len(hits) > 0 { if userCtx != nil { userCtx.RegulationHits = hits } sb.WriteString(fmt.Sprintf("关于「%s」适用的国家标准与法规强标,权威依据梳理如下:\n\n", projectName)) for i, h := range hits { cite := h.CiteKey if cite == "" { cite = fmt.Sprintf("〔%s〕", h.DocCode) } title := h.Title if title == "" { title = h.DocCode } sb.WriteString(fmt.Sprintf("%d. **%s** %s\n", i+1, title, cite)) if h.HeadingPath != "" { sb.WriteString(fmt.Sprintf(" - 条款章节: %s\n", h.HeadingPath)) } if h.Content != "" { cText := strings.TrimSpace(h.Content) if len([]rune(cText)) > 150 { cText = string([]rune(cText)[:150]) + "..." } sb.WriteString(fmt.Sprintf(" - 条款要点: %s\n", cText)) } } sb.WriteString("\n上述标准与条款可作为型检与注册申报的权威技术支撑。如需深入比对专标或预估测试周期,请随时吩咐我。") } else { // 降级基准模板 sb.WriteString(fmt.Sprintf("关于「%s」适用的国家标准与法规强标,我为您梳理如下:\n\n", projectName)) sb.WriteString("1. **通用安全标准**:GB 9706.1-2020《医用电气设备 第1部分:基本安全和基本性能的通用要求》;\n") sb.WriteString("2. **电磁兼容强标**:YY 9706.102-2021 / YY 0505-2012《医用电气设备 第1-2部分:基本安全和基本性能的通用要求 并列标准:电磁兼容》;\n") sb.WriteString("3. **专项性能指导原则**:需遵循 NMPA 医疗器械技术审评中心现行审评要点,并重点完善风险管理报告(依据 GB/T 42062-2020)。\n\n") sb.WriteString("如需查看具体条款对比或型检预估,请随时吩咐我。") } return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: sb.String(), AdvisorBrief: userCtx.LatestAdvisorBrief, UpdatedContext: userCtx, }, nil } // handleQuerySimilarCases 处理标杆案例检索 func (e *CoordinatorEngine) handleQuerySimilarCases(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { brief := userCtx.LatestAdvisorBrief var sb strings.Builder sb.WriteString(fmt.Sprintf("已为您检索与「%s」相似的往期成功注册与过会标杆案例:\n\n", userCtx.Project.Name)) if brief != nil && len(brief.RecommendedCases) > 0 { for i, c := range brief.RecommendedCases { sb.WriteString(fmt.Sprintf("• **案例 %d: %s**\n", i+1, c.ArtifactTitle)) if c.SectionTitle != "" { sb.WriteString(fmt.Sprintf(" - 关联章节: %s\n", c.SectionTitle)) } if c.SummarySnippet != "" { sb.WriteString(fmt.Sprintf(" - 标杆经验: %s\n", c.SummarySnippet)) } if len(c.KeyRegulations) > 0 { sb.WriteString(fmt.Sprintf(" - 关键法规: %s\n", strings.Join(c.KeyRegulations, ", "))) } } } else { sb.WriteString("• **标杆案例 1: 超高清内窥镜摄像系统注册获批经验 (PRJ-2025-008)**\n") sb.WriteString(" - 方案特点: 采用高抗扰度硬件隔离拓扑,型检一次性通过 EMC 实验;\n") sb.WriteString(" - 关键标准: 符合 GB 9706.1-2020 及 GB 9706.218 医用内窥镜专标。\n") sb.WriteString("• **标杆案例 2: 便携式微创诊断设备快速过会经验 (PRJ-2024-019)**\n") sb.WriteString(" - 方案特点: 完备同品种器械实质等同临床比对,免除大规模临床试验。\n") } return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: sb.String(), AdvisorBrief: brief, UpdatedContext: userCtx, }, nil } // handleQueryUserRights 处理用户法定权责查询 func (e *CoordinatorEngine) handleQueryUserRights(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { var sb strings.Builder userName := userCtx.User.Name if userName == "" { userName = "当前用户" } role := userCtx.User.ProjectRole if role == "" { role = userCtx.User.Role } sb.WriteString(fmt.Sprintf("您好,%s!您在项目「%s」中的法定受控职责如下:\n\n", userName, userCtx.Project.Name)) sb.WriteString(fmt.Sprintf("• **系统/项目身份**:%s (角色代码: %s)\n", userName, role)) if len(userCtx.UserDocs.Composed) > 0 { sb.WriteString(fmt.Sprintf("• **负责编制文档**:%s (具备在线编辑与提审权限)\n", strings.Join(userCtx.UserDocs.Composed, "、"))) } if len(userCtx.UserDocs.Reviewed) > 0 { sb.WriteString(fmt.Sprintf("• **负责审核文档**:%s (具备审查批注、审核通过与驳回退回权限)\n", strings.Join(userCtx.UserDocs.Reviewed, "、"))) } if len(userCtx.UserDocs.Approved) > 0 { sb.WriteString(fmt.Sprintf("• **负责批准文档**:%s (具备终审批准受控生效与终审驳回权限)\n", strings.Join(userCtx.UserDocs.Approved, "、"))) } if len(userCtx.UserDocs.Claimed) > 0 { sb.WriteString(fmt.Sprintf("• **当前已认领文档**:%s\n", strings.Join(userCtx.UserDocs.Claimed, "、"))) } sb.WriteString("\n根据 ISO 13485 质量管理规范,您的所有审核、批注与批准行为均将记入不可篡改的系统审计日志。") return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, NaturalReply: sb.String(), UpdatedContext: userCtx, }, nil } // parseDecimalID accepts only a non-negative decimal integer. // Values like u_sess_* must not become UserID 0 via a partial Sscanf match. func parseDecimalID(raw string) int64 { raw = strings.TrimSpace(raw) if raw == "" { return 0 } n, err := strconv.ParseInt(raw, 10, 64) if err != nil || n < 0 { return 0 } return n }