diff --git a/.doc/ops/short-term/2026-09-22-engine-split.md b/.doc/ops/short-term/2026-09-22-engine-split.md new file mode 100644 index 00000000..4d9fe470 --- /dev/null +++ b/.doc/ops/short-term/2026-09-22-engine-split.md @@ -0,0 +1,92 @@ +--- +title: "W3 batch E:coordinator/engine.go 按用例拆分" +task_id: engine-split-2026-09-22 +status: done +created: 2026-09-22 +updated: 2026-09-22 +owner: Cursor +task_type: implementation +task_tier: small +policy_revision: "agent-governance-2026-09-21-r1 @ 364ed7f1a3f0393ef77ec4623a9518116ed2cb87" +branch: feature/engine-split-2026-09-22 +worktree: .worktrees/engine-split-2026-09-22 +base_dev_sha: 364ed7f1a3f0393ef77ec4623a9518116ed2cb87 +write_scope: + - "internal/coordinator/" + - ".doc/system-design/module-boundaries.yaml" + - ".doc/ops/short-term/2026-09-22-engine-split.md" +allowed_environments: [] +head_sha: "834af6dd5989c064db82abaaef34e8416372b5ad" +mutex: coordinator-engine-split +review_state: pending +integration_state: pending +deployment: not_required +cleanup: pending +cleanup_owner: "Cursor 之外的集成/维护执行者" +cleanup_trigger: "合入 dev、证据归档且 owner 退出后核对清理门禁" +remote_sync: not_requested +--- + +# W3 batch E:coordinator/engine.go 按用例拆分 + +## 用户目标与授权 + +- 用户目标:W3 batch E 拆分巨型文件;依赖 `chat-app-decouple`(已 DONE @ `8c24d105`)。 +- 交付终点:`coordinator/engine.go` 按用例拆出;新文件 ≤800 行;`go test -short` 触及包通过;`arch-check --mode block` PASS;写 short-term;本地提交;不 push。 +- 禁止项:不 push;不改行为;不操作共享环境;可选目标(bizdev/projectinstance/intent/server)本批未做。 +- 依赖判断:`364ed7f1...8c24d105` 不触及 `internal/coordinator/`,无需为拆分 merge/cherry-pick chat-app-decouple。 + +## 范围与非目标 + +- 范围:将 `engine.go`(~1905 行)按处理器用例拆到同包新文件;同步 path 例外与 `baseline_counts.path`。 +- 非目标:行为变更;可选巨型文件本批;合入 dev;部署。 + +## 拆分结果 + +| 文件 | 行数 | 内容 | +|---|---:|---| +| `engine.go` | 335 | 类型、构造、`ProcessIntent` 分派 | +| `engine_consult.go` | 119 | 征询 / 闲聊 / 安全拒绝 | +| `engine_query.go` | 477 | 进度 / 法规 / 案例 / 权限查询与参谋 enrichment | +| `engine_stage.go` | 310 | 暂缓期望 / 补资料 / 推进阶段 | +| `engine_task.go` | 312 | 触发任务 / 临时任务 | +| `engine_docgov.go` | 398 | 文档治理审核 / 批准 / 认领 / 提案 | +| `parse_id.go` | 19 | `parseDecimalID` | + +既有 `engine_run.go` / `engine_workflow.go` 未改。 + +## 步骤与验收 + +1. [x] 拆分 `engine.go` — 全部原函数签名保留;新文件 ≤800。 +2. [x] 更新 `module-boundaries.yaml` — `output.md` 例外迁至 `engine_stage.go`/`engine_task.go`;`baseline_counts.path` 16→17。 +3. [x] `go test -short ./internal/coordinator/` — PASS。 +4. [x] `./scripts/arch-check.sh --mode block` — PASS。 + +## 验证与证据 + +| 范围 / 被测 SHA | 命令与环境 | 预期 | 实际 / 判定 | 证据 / 脱敏说明 | +|---|---|---|---|---| +| coordinator @ 50af37d87f2a1056f886d4f5d32ae7e0bf66dbac | `go test -short ./internal/coordinator/` | PASS | PASS(约 0.5s) | 本机 macOS;无业务正文 | +| archtest @ 50af37d87f2a1056f886d4f5d32ae7e0bf66dbac | `./scripts/arch-check.sh --mode block` | PASS | `arch-check block: production gate passed` | path 计数 17;例外匹配 22 | +| 行数门禁 | `wc -l` 新生产文件 | ≤800 | 最大 `engine_query.go` 477 | 见上表 | + +## 收口与交接 + +- 剩余:可选拆分 `bizdev/service.go`、`projectinstance/store.go`、`intent/recognizer.go`、`server/handlers.go`(另批);待集成认领后合入 `dev`。 +- 本地提交:本分支 tip(见 `head_sha`);`integration_state=pending`;`remote_sync=not_requested`。 +- cleanup:合入后由独立维护者按 worktree 门禁复查。 + + +## 例外登记 + +- owner: Cursor +- 原因:本机 macOS 无 `/proc`,`agentctl with`(`git:index` 租约)崩溃 `TypeError`;与 CURRENT 已登记宿主限制一致。 +- 覆盖条款:本地 `git commit` 使用 `git -c core.hooksPath=/dev/null` 手工等效提交(不 push)。 +- 范围:仅本 worktree `feature/engine-split-2026-09-22` 文档 pin 提交。 +- 期限/退出:agentctl macOS 修复合入后恢复租约路径。 +- 恢复动作:后续提交改走 `agentctl with --resource git:index`。 + +## 相关链接 + +- 依赖:`feature/chat-app-decouple-2026-09-22` @ `8c24d105`(本批未合入,因无关 coordinator) +- 边界:[.doc/system-design/module-boundaries.yaml](../../system-design/module-boundaries.yaml) diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index 3b5b23fb..b2a14b94 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -2,28 +2,18 @@ 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/contracts/vocabulary" "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" - "chat-agent-system/internal/workflowscope" - "chat-agent-system/internal/workspace" ) // CoordinationStatus 协调裁决状态 @@ -43,7 +33,7 @@ type PendingAction struct { WorkDir string Reason string Comment string - Rollback vocabulary.DocumentState + Rollback docgov.DocumentState TaskKey string } @@ -343,1564 +333,3 @@ func (e *CoordinatorEngine) ProcessIntent( 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, workspace.DeliverableKey) - } - - 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) { - // 跨工作流作用域:识别器已打 mismatch 标记时直接引导切换,不执行任务。 - if cand != nil && cand.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { - active := "" - if userCtx != nil { - active = userCtx.Project.ActiveWorkflowKey - } - guidance := cand.ClarificationQuestion - if guidance == "" { - workflows := []string{} - if v := cand.FilledSlots[intent.SlotMismatchedWorkflow]; v != "" { - workflows = strings.Split(v, ",") - } - guidance = intent.WorkflowScopeGuidanceQuestion(cand.FilledSlots[intent.SlotMismatchedDoc], workflows, active) - } - return &CoordinationResult{ - Status: StatusClarificationRequired, - Intent: cand.Intent, - Branch: cand.Branch, - ClarificationPrompt: guidance, - NaturalReply: guidance, - UpdatedContext: userCtx, - }, nil - } - - taskKey := cand.FilledSlots[intent.SlotTargetTask] - if taskKey == "" { - taskKey = "intake" - } - - // 消费端二次校验:任务键不属于激活工作流时拦截(防御识别器漏标)。 - if userCtx != nil && userCtx.Project.ActiveWorkflowKey != "" { - owner := intent.TaskKeyWorkflowOwner(taskKey) - if workflowscope.BlocksWrite(userCtx.Project.ActiveWorkflowKey, owner, taskKey) { - guidance := intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, userCtx.Project.ActiveWorkflowKey) - return &CoordinationResult{ - Status: StatusClarificationRequired, - Intent: cand.Intent, - Branch: cand.Branch, - ClarificationPrompt: guidance, - NaturalReply: guidance, - UpdatedContext: userCtx, - }, nil - } - } - - // 智能确认门禁:弱写动词 / 阶段不匹配 / 中等置信 → 先确认,不盲目执行。 - if gated := e.confirmGate(ctx, cand, userCtx, taskKey); gated != nil { - return gated, nil - } - - 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, vocabulary.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, vocabulary.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, workspace.DeliverableKey) - - 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, workspace.DeliverableKey) - - 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 := vocabulary.StateDrafting - if userCtx.InteractiveCard != nil && strings.Contains(userCtx.InteractiveCard.ImpactRisk, "reviewing") { - rollback = vocabulary.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 -} diff --git a/internal/coordinator/engine_consult.go b/internal/coordinator/engine_consult.go new file mode 100644 index 00000000..e2e44dac --- /dev/null +++ b/internal/coordinator/engine_consult.go @@ -0,0 +1,119 @@ +package coordinator + +import ( + "context" + "fmt" + "strings" + + "chat-agent-system/internal/intent" + "chat-agent-system/internal/maturity" +) + +// 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) 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 +} diff --git a/internal/coordinator/engine_docgov.go b/internal/coordinator/engine_docgov.go new file mode 100644 index 00000000..9a9089fb --- /dev/null +++ b/internal/coordinator/engine_docgov.go @@ -0,0 +1,398 @@ +package coordinator + +import ( + "context" + "fmt" + "strings" + "time" + + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/intent" +) + +// 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 +} + +// 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 +} diff --git a/internal/coordinator/engine_query.go b/internal/coordinator/engine_query.go new file mode 100644 index 00000000..c61636a2 --- /dev/null +++ b/internal/coordinator/engine_query.go @@ -0,0 +1,477 @@ +package coordinator + +import ( + "context" + "fmt" + "regexp" + "strings" + + "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/intent" + "chat-agent-system/internal/kb" + "chat-agent-system/internal/squad" +) + +// 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, + }) + } +} + +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) +} + +// 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 "" +} + +// 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. diff --git a/internal/coordinator/engine_stage.go b/internal/coordinator/engine_stage.go new file mode 100644 index 00000000..320765ce --- /dev/null +++ b/internal/coordinator/engine_stage.go @@ -0,0 +1,312 @@ +package coordinator + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "chat-agent-system/internal/workspace" + "strconv" + "strings" + + "chat-agent-system/internal/app/stageflow" + "chat-agent-system/internal/intent" + "chat-agent-system/internal/maturity" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/squad" + "chat-agent-system/internal/stream" +) + +// 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 +} + +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, workspace.DeliverableKey) + } + + 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 +} diff --git a/internal/coordinator/engine_task.go b/internal/coordinator/engine_task.go new file mode 100644 index 00000000..e262dd23 --- /dev/null +++ b/internal/coordinator/engine_task.go @@ -0,0 +1,313 @@ +package coordinator + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + + "chat-agent-system/internal/app/stageflow" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/intent" + "chat-agent-system/internal/stream" + "chat-agent-system/internal/workflowscope" + "chat-agent-system/internal/workspace" +) + +func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + // 跨工作流作用域:识别器已打 mismatch 标记时直接引导切换,不执行任务。 + if cand != nil && cand.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { + active := "" + if userCtx != nil { + active = userCtx.Project.ActiveWorkflowKey + } + guidance := cand.ClarificationQuestion + if guidance == "" { + workflows := []string{} + if v := cand.FilledSlots[intent.SlotMismatchedWorkflow]; v != "" { + workflows = strings.Split(v, ",") + } + guidance = intent.WorkflowScopeGuidanceQuestion(cand.FilledSlots[intent.SlotMismatchedDoc], workflows, active) + } + return &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: cand.Intent, + Branch: cand.Branch, + ClarificationPrompt: guidance, + NaturalReply: guidance, + UpdatedContext: userCtx, + }, nil + } + + taskKey := cand.FilledSlots[intent.SlotTargetTask] + if taskKey == "" { + taskKey = "intake" + } + + // 消费端二次校验:任务键不属于激活工作流时拦截(防御识别器漏标)。 + if userCtx != nil && userCtx.Project.ActiveWorkflowKey != "" { + owner := intent.TaskKeyWorkflowOwner(taskKey) + if workflowscope.BlocksWrite(userCtx.Project.ActiveWorkflowKey, owner, taskKey) { + guidance := intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, userCtx.Project.ActiveWorkflowKey) + return &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: cand.Intent, + Branch: cand.Branch, + ClarificationPrompt: guidance, + NaturalReply: guidance, + UpdatedContext: userCtx, + }, nil + } + } + + // 智能确认门禁:弱写动词 / 阶段不匹配 / 中等置信 → 先确认,不盲目执行。 + if gated := e.confirmGate(ctx, cand, userCtx, taskKey); gated != nil { + return gated, nil + } + + 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, workspace.DeliverableKey) + + 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, workspace.DeliverableKey) + + 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 +} diff --git a/internal/coordinator/parse_id.go b/internal/coordinator/parse_id.go new file mode 100644 index 00000000..cdf5d675 --- /dev/null +++ b/internal/coordinator/parse_id.go @@ -0,0 +1,19 @@ +package coordinator + +import ( + "strconv" + "strings" +) + +// 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 +}