diff --git a/internal/coordinator/context.go b/internal/coordinator/context.go new file mode 100644 index 00000000..9cfc6c7a --- /dev/null +++ b/internal/coordinator/context.go @@ -0,0 +1,168 @@ +package coordinator + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/intent" + "chat-agent-system/internal/squad" +) + +// UserInfo 用户在系统与项目内的身份 +type UserInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Role string `json:"role"` // 系统角色 (如 engineer, pm, director) + ProjectRole string `json:"project_role"` // 项目内角色 (如 编制人 composer, 审核人 reviewer, 批准人 approver) +} + +// ProjectInfo 项目与工作流当前基准 +type ProjectInfo struct { + ID string `json:"id"` + Name string `json:"name"` + CustomerName string `json:"customer_name"` + DeviceType string `json:"device_type"` + IntendedUse string `json:"intended_use"` + TargetMarket string `json:"target_market"` + CurrentWorkflow string `json:"current_workflow"` + CurrentStage bizdev.MaturityStage `json:"current_stage"` + StageRules []string `json:"stage_rules,omitempty"` +} + +// UserDocsInfo 用户相关文档清单与认领状态 +type UserDocsInfo struct { + Composed []string `json:"composed"` // 负责编制的文档 + Reviewed []string `json:"reviewed"` // 负责审核的文档 + Approved []string `json:"approved"` // 负责批准的文档 + Claimed []string `json:"claimed"` // 已认领/已打开查阅的文档 +} + +// PendingItems 待用户处理或决策的待办项 +type PendingItems struct { + ConfirmModifications []string `json:"confirm_modifications,omitempty"` + PendingReviews []string `json:"pending_reviews,omitempty"` + PendingApprovals []string `json:"pending_approvals,omitempty"` + MyDraftsInProgress []string `json:"my_drafts_in_progress,omitempty"` + UnreadSupplements []string `json:"unread_supplements,omitempty"` +} + +// UserProjectContext 用户项目级全量统一上下文 (对话系统与执行系统共同消费) +type UserProjectContext struct { + SnapshotID string `json:"snapshot_id"` + AssembledAt time.Time `json:"assembled_at"` + User UserInfo `json:"user"` + Project ProjectInfo `json:"project"` + UserDocs UserDocsInfo `json:"user_docs"` + Pending PendingItems `json:"pending"` + BlackboardFacts map[string]string `json:"blackboard_facts"` + LatestAdvisorBrief *squad.AdvisorBriefing `json:"latest_advisor_brief,omitempty"` + AvailableActions []string `json:"available_actions"` + RecentEvents []string `json:"recent_events,omitempty"` + WorkDir string `json:"work_dir,omitempty"` +} + +// ContextAssembler 上下文组装服务 +type ContextAssembler struct{} + +func NewContextAssembler() *ContextAssembler { + return &ContextAssembler{} +} + +// ComputeAvailableActions 根据当前成熟度阶段与角色计算允许执行的操作集合 +func ComputeAvailableActions(stage bizdev.MaturityStage, pRole string) []string { + actions := []string{"QUERY_PROGRESS", "SUPPLEMENT_INFO", "VIEW_DOC"} + + switch stage { + case bizdev.StageV01_Triage: + actions = append(actions, "TRIGGER_TASK:intake", "ADVANCE_STAGE:V0.5_feasibility") + case bizdev.StageV05_Feasibility: + actions = append(actions, + "TRIGGER_TASK:research", + "TRIGGER_TASK:feasibility_reg", + "TRIGGER_TASK:prelim_design", + "ADVANCE_STAGE:V1.0_quoting", + ) + case bizdev.StageV10_Quoting: + actions = append(actions, "TRIGGER_TASK:cdo_quote", "LOCK_COMMERCIAL") + default: + actions = append(actions, "ADVANCE_STAGE:V0.1_triage") + } + + return actions +} + +// ToSnapshot 转换为 L1 意图识别器所需的轻量上下文快照 +func (ctx *UserProjectContext) ToSnapshot() *intent.ContextSnapshot { + if ctx == nil { + return nil + } + return &intent.ContextSnapshot{ + SnapshotID: ctx.SnapshotID, + ProjectName: ctx.Project.Name, + CurrentStage: string(ctx.Project.CurrentStage), + AvailableActions: ctx.AvailableActions, + } +} + +// AssembleFromWorkspace 基于现有工作区快速组装 UserProjectContext +func (a *ContextAssembler) AssembleFromWorkspace(workDir string, user UserInfo) (*UserProjectContext, error) { + snapID := fmt.Sprintf("ctx_%d", time.Now().UnixNano()) + + ctx := &UserProjectContext{ + SnapshotID: snapID, + AssembledAt: time.Now(), + User: user, + WorkDir: workDir, + BlackboardFacts: make(map[string]string), + AvailableActions: []string{"QUERY_PROGRESS", "SUPPLEMENT_INFO", "VIEW_DOC"}, + } + + // 1. 尝试读取 workspace.json + wsMetaPath := filepath.Join(workDir, "workspace.json") + if data, err := os.ReadFile(wsMetaPath); err == nil { + var wsMeta squad.WorkspaceMetadata + if json.Unmarshal(data, &wsMeta) == nil { + ctx.Project.Name = wsMeta.ProjectName + ctx.Project.CustomerName = wsMeta.CustomerName + ctx.Project.DeviceType = wsMeta.DeviceType + ctx.Project.IntendedUse = wsMeta.IntendedUse + ctx.Project.TargetMarket = wsMeta.TargetMarket + ctx.Project.CurrentWorkflow = wsMeta.TaskKey + } + } + + // 2. 尝试读取 blackboard.json + bbPath := filepath.Join(workDir, "blackboard.json") + if data, err := os.ReadFile(bbPath); err == nil { + var bb squad.BlackboardState + if json.Unmarshal(data, &bb) == nil { + for k, v := range bb.Fields { + ctx.BlackboardFacts[k] = v + } + for _, g := range bb.Gaps { + if g.Blocking && !g.Resolved { + ctx.Pending.ConfirmModifications = append(ctx.Pending.ConfirmModifications, g.Item) + } + } + } + } + + // 3. 尝试提取阶段与文档 + if ctx.Project.CurrentStage == "" { + ctx.Project.CurrentStage = bizdev.StageV01_Triage + } + ctx.AvailableActions = ComputeAvailableActions(ctx.Project.CurrentStage, user.ProjectRole) + + // 4. 若有 output.md,尝试提取最新的参谋简报 + outPath := filepath.Join(workDir, "output.md") + if outBytes, err := os.ReadFile(outPath); err == nil && len(outBytes) > 0 { + ctx.LatestAdvisorBrief = squad.ExtractAdvisorBriefing(ctx.Project.CurrentWorkflow, string(outBytes), ctx.BlackboardFacts) + ctx.UserDocs.Composed = append(ctx.UserDocs.Composed, ctx.Project.CurrentWorkflow) + } + + return ctx, nil +} diff --git a/internal/coordinator/context_test.go b/internal/coordinator/context_test.go new file mode 100644 index 00000000..846ec72b --- /dev/null +++ b/internal/coordinator/context_test.go @@ -0,0 +1,105 @@ +package coordinator_test + +import ( + "os" + "path/filepath" + "testing" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/coordinator" + "chat-agent-system/internal/intent" +) + +func TestComputeAvailableActions(t *testing.T) { + actionsV01 := coordinator.ComputeAvailableActions(bizdev.StageV01_Triage, "composer") + foundAdvance := false + for _, a := range actionsV01 { + if a == "ADVANCE_STAGE:V0.5_feasibility" { + foundAdvance = true + } + } + if !foundAdvance { + t.Errorf("expected V0.1 actions to contain advance to V0.5: %v", actionsV01) + } + + actionsV05 := coordinator.ComputeAvailableActions(bizdev.StageV05_Feasibility, "composer") + foundAdvanceQuote := false + for _, a := range actionsV05 { + if a == "ADVANCE_STAGE:V1.0_quoting" { + foundAdvanceQuote = true + } + } + if !foundAdvanceQuote { + t.Errorf("expected V0.5 actions to contain advance to V1.0: %v", actionsV05) + } +} + +func TestAssembleFromWorkspace(t *testing.T) { + tmpDir := t.TempDir() + + wsJSON := `{ + "project_name": "智能听诊器系统", + "customer_name": "水木清研", + "device_type": "电子听诊器", + "task_key": "intake" + }` + _ = os.WriteFile(filepath.Join(tmpDir, "workspace.json"), []byte(wsJSON), 0o644) + + bbJSON := `{ + "fields": { + "project_name": "智能听诊器系统", + "target_market": "国内" + }, + "gaps": [ + {"item": "缺少蓝牙传输规格", "blocking": true, "resolved": false} + ] + }` + _ = os.WriteFile(filepath.Join(tmpDir, "blackboard.json"), []byte(bbJSON), 0o644) + + outputMD := "# 用户初始需求收集表\n\n## 结论\n初步整理完成。\n\n## 待确认清单\n- 蓝牙传输距离与功耗要求\n" + _ = os.WriteFile(filepath.Join(tmpDir, "output.md"), []byte(outputMD), 0o644) + + user := coordinator.UserInfo{ + ID: "u_1001", + Name: "张工", + Role: "engineer", + ProjectRole: "composer", + } + + assembler := coordinator.NewContextAssembler() + ctx, err := assembler.AssembleFromWorkspace(tmpDir, user) + if err != nil { + t.Fatalf("assemble context failed: %v", err) + } + + if ctx.Project.Name != "智能听诊器系统" { + t.Errorf("expected project name '智能听诊器系统', got '%s'", ctx.Project.Name) + } + if ctx.BlackboardFacts["target_market"] != "国内" { + t.Errorf("expected target_market '国内', got '%s'", ctx.BlackboardFacts["target_market"]) + } + if len(ctx.Pending.ConfirmModifications) == 0 { + t.Errorf("expected blocking gap in pending confirm modifications") + } + if ctx.LatestAdvisorBrief == nil { + t.Fatalf("expected latest advisor brief to be assembled") + } + if ctx.LatestAdvisorBrief.MaturityLevel != "V0.1" { + t.Errorf("expected V0.1 maturity, got %s", ctx.LatestAdvisorBrief.MaturityLevel) + } +} + +func TestIntentContractTypes(t *testing.T) { + cand := intent.CandidateIntent{ + Intent: intent.IntentQueryProgress, + Confidence: 0.95, + FilledSlots: map[string]string{ + intent.SlotQueryTopic: "progress", + }, + Reasoning: "用户明确询问当前立项走到哪一步了", + } + + if cand.Intent != intent.IntentQueryProgress || cand.Confidence < 0.9 { + t.Errorf("candidate intent fields mismatch: %+v", cand) + } +} diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go new file mode 100644 index 00000000..172bae5a --- /dev/null +++ b/internal/coordinator/engine.go @@ -0,0 +1,373 @@ +package coordinator + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/intent" + "chat-agent-system/internal/squad" +) + +// CoordinationStatus 协调裁决状态 +type CoordinationStatus string + +const ( + StatusAccepted CoordinationStatus = "ACCEPTED" + StatusRejected CoordinationStatus = "REJECTED" + StatusClarificationRequired CoordinationStatus = "CLARIFICATION_REQUIRED" +) + +// CoordinationResult 协调层给对话层的回执 +type CoordinationResult struct { + Status CoordinationStatus `json:"status"` + Intent intent.IntentType `json:"intent"` + RejectReason string `json:"reject_reason,omitempty"` + ClarificationPrompt string `json:"clarification_prompt,omitempty"` + NaturalReply string `json:"natural_reply"` + AdvisorBrief *squad.AdvisorBriefing `json:"advisor_brief,omitempty"` + ArtifactPath string `json:"artifact_path,omitempty"` + UpdatedContext *UserProjectContext `json:"updated_context"` +} + +// CoordinatorEngine L2 协调引擎 +type CoordinatorEngine struct { + bizdevSvc bizdev.BizdevService + assembler *ContextAssembler +} + +func NewCoordinatorEngine(bizdevSvc bizdev.BizdevService) *CoordinatorEngine { + return &CoordinatorEngine{ + bizdevSvc: bizdevSvc, + assembler: NewContextAssembler(), + } +} + +// ProcessIntent 校验候选意图合法性并驱动调度与响应 +func (e *CoordinatorEngine) ProcessIntent( + ctx context.Context, + recRes *intent.IntentRecognitionResult, + userCtx *UserProjectContext, +) (*CoordinationResult, error) { + if userCtx == nil { + userCtx = &UserProjectContext{ + User: UserInfo{ID: "guest", Name: "用户", Role: "engineer", ProjectRole: "composer"}, + BlackboardFacts: make(map[string]string), + } + } + + top := recRes.TopIntent + if top == nil { + top = &intent.CandidateIntent{ + Intent: intent.IntentCasualChat, + Confidence: 1.0, + } + } + + // 1. 若置信度极低或显式缺少核心槽位,转为用户澄清 + if recRes.RequiresClarifying && top.ClarificationQuestion != "" { + return &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: top.Intent, + ClarificationPrompt: top.ClarificationQuestion, + NaturalReply: top.ClarificationQuestion, + UpdatedContext: userCtx, + }, nil + } + + // 2. 根据候选意图执行分支 + switch top.Intent { + case intent.IntentQueryProgress: + return e.handleQueryProgress(top, userCtx) + + case intent.IntentSupplementInfo: + return e.handleSupplementInfo(top, userCtx) + + case intent.IntentAdvanceStage: + return e.handleAdvanceStage(ctx, top, userCtx) + + case intent.IntentTriggerTask: + return e.handleTriggerTask(ctx, top, userCtx) + + case intent.IntentCasualChat: + return e.handleCasualChat(userCtx) + + default: + return &CoordinationResult{ + Status: StatusAccepted, + Intent: top.Intent, + NaturalReply: "我已收到您的信息。请问需要我为您查询项目进展,还是协助您推进到下一阶段?", + UpdatedContext: userCtx, + }, nil + } +} + +func (e *CoordinatorEngine) handleQueryProgress(cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + brief := userCtx.LatestAdvisorBrief + topic := cand.FilledSlots[intent.SlotQueryTopic] + + if brief == nil { + reply := fmt.Sprintf("您好,项目「%s」当前处于初始准备期。我建议先行为您启动【V0.1 闪电摸底】,快速沉淀第一版立项画像并为您准备沟通话术。您希望现在开始吗?", + userCtx.Project.Name) + return &CoordinationResult{ + Status: StatusAccepted, + Intent: cand.Intent, + NaturalReply: reply, + UpdatedContext: userCtx, + }, nil + } + + 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)) + } + } + } + + return &CoordinationResult{ + Status: StatusAccepted, + Intent: cand.Intent, + NaturalReply: sb.String(), + AdvisorBrief: brief, + UpdatedContext: userCtx, + }, 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.BlackboardFacts[factKey] = factVal + summaryKey = fmt.Sprintf("「%s」已更新为「%s」", factKey, factVal) + // 同步到工作区 project 字段 + if factKey == "target_market" { + userCtx.Project.TargetMarket = factVal + } + } else { + summaryKey = "已补充新的背景信息" + } + + // 若存在真实工作区,写回 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) + } + } + + reply := fmt.Sprintf("已收到并为您记录最新信息:%s。\n这些事实已沉淀至项目黑板,后续论证与核价将直接吸收这些设定。您接下来想:\n[1] 查阅当前最新的项目进展\n[2] 推进进入下一阶段方案论证", + summaryKey) + if rawDoc != "" && summaryKey == "已补充新的背景信息" { + reply = fmt.Sprintf("已为您收录补充材料,并已关联至项目事实黑板。\n在后续的文档修订和阶段演进中,智能体小队将自动引用这部分事实。") + } + + return &CoordinationResult{ + Status: StatusAccepted, + Intent: cand.Intent, + NaturalReply: reply, + AdvisorBrief: userCtx.LatestAdvisorBrief, + UpdatedContext: userCtx, + }, nil +} + +func (e *CoordinatorEngine) handleAdvanceStage(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + targetStageStr := cand.FilledSlots[intent.SlotTargetStage] + targetStage := bizdev.MaturityStage(targetStageStr) + if targetStage == "" { + targetStage = bizdev.StageV05_Feasibility + } + + // 权限与状态机合法性检查 + if userCtx.Project.CurrentStage == targetStage { + return &CoordinationResult{ + Status: StatusRejected, + Intent: cand.Intent, + RejectReason: "ALREADY_IN_STAGE", + NaturalReply: fmt.Sprintf("项目目前已经在【%s】阶段中,无需重复推进。您可以吩咐我进行详细论证或查询当前进展。", targetStage), + UpdatedContext: userCtx, + }, nil + } + + if e.bizdevSvc == nil { + userCtx.Project.CurrentStage = targetStage + userCtx.AvailableActions = ComputeAvailableActions(targetStage, userCtx.User.ProjectRole) + return &CoordinationResult{ + Status: StatusAccepted, + Intent: cand.Intent, + NaturalReply: fmt.Sprintf("已为您模拟推进到阶段【%s】。", targetStage), + UpdatedContext: userCtx, + }, nil + } + + // 调用底层 L3 调度器执行阶段跃迁 + stageRes, err := e.bizdevSvc.ExecuteStage(ctx, bizdev.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, + RejectReason: err.Error(), + NaturalReply: fmt.Sprintf("阶段推进失败: %v", err), + UpdatedContext: userCtx, + }, err + } + + userCtx.Project.CurrentStage = targetStage + userCtx.LatestAdvisorBrief = stageRes.AdvisorBrief + userCtx.AvailableActions = ComputeAvailableActions(targetStage, userCtx.User.ProjectRole) + if stageRes.WorkDir != "" { + userCtx.WorkDir = stageRes.WorkDir + } + + artifactPath := "" + if stageRes.PrimaryArtifact != nil { + artifactPath = filepath.Join(stageRes.PrimaryArtifact.WorkDir, "output.md") + } + + reply := fmt.Sprintf("已为您成功推进并完成了【%s】!\n智能体小队已协同完成相应论证与文档沉淀。\n\n💡 最新进展诊断:%s\n建议您可以审阅正式交付文档(%s),或按最新参谋建议开展后续商务洽谈。", + stageRes.StageTitle, + stageRes.AdvisorBrief.MaturityHeadline, + artifactPath, + ) + + return &CoordinationResult{ + Status: StatusAccepted, + Intent: cand.Intent, + NaturalReply: reply, + AdvisorBrief: stageRes.AdvisorBrief, + ArtifactPath: artifactPath, + UpdatedContext: userCtx, + }, nil +} + +func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + taskKey := cand.FilledSlots[intent.SlotTargetTask] + if taskKey == "" { + taskKey = "intake" + } + + if e.bizdevSvc == nil { + return &CoordinationResult{ + Status: StatusAccepted, + Intent: cand.Intent, + NaturalReply: fmt.Sprintf("已收到生成【%s】的指令。", taskKey), + UpdatedContext: userCtx, + }, nil + } + + taskRes, err := e.bizdevSvc.ExecuteTask(ctx, bizdev.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, + }) + if err != nil { + return &CoordinationResult{ + Status: StatusRejected, + Intent: cand.Intent, + RejectReason: err.Error(), + NaturalReply: fmt.Sprintf("任务执行失败: %v", err), + UpdatedContext: userCtx, + }, err + } + + userCtx.LatestAdvisorBrief = taskRes.AdvisorBrief + outPath := filepath.Join(taskRes.WorkDir, "output.md") + + reply := fmt.Sprintf("已为您生成【%s】!\n交付物正文已保存至:%s\n耗时: %v", + taskRes.ArtifactTitle, outPath, taskRes.Duration) + + return &CoordinationResult{ + Status: StatusAccepted, + Intent: cand.Intent, + NaturalReply: reply, + AdvisorBrief: taskRes.AdvisorBrief, + ArtifactPath: outPath, + UpdatedContext: userCtx, + }, nil +} + +func (e *CoordinatorEngine) handleCasualChat(userCtx *UserProjectContext) (*CoordinationResult, error) { + projName := userCtx.Project.Name + if projName == "" { + projName = "当前医疗器械研发项目" + } + + reply := fmt.Sprintf("您好!我是您的医疗器械立项与商务对接贴身参谋。\n\n针对「%s」,我可以协助您:\n• 随时查询项目成熟度、进展与预见性风险;\n• 补充或调整客户沟通事实与技术指标;\n• 自动起草面向客户负责人的微信/邮件沟通话术;\n• 驱动小队推进方案论证与最终 CDO 报价核算。\n\n请问您现在想从哪里开始?", + projName) + + return &CoordinationResult{ + Status: StatusAccepted, + Intent: intent.IntentCasualChat, + NaturalReply: reply, + AdvisorBrief: userCtx.LatestAdvisorBrief, + UpdatedContext: userCtx, + }, nil +} diff --git a/internal/coordinator/engine_test.go b/internal/coordinator/engine_test.go new file mode 100644 index 00000000..bd7082a8 --- /dev/null +++ b/internal/coordinator/engine_test.go @@ -0,0 +1,117 @@ +package coordinator_test + +import ( + "context" + "testing" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/coordinator" + "chat-agent-system/internal/intent" + "chat-agent-system/internal/squad" +) + +func TestCoordinatorEngine_QueryProgress(t *testing.T) { + engine := coordinator.NewCoordinatorEngine(nil) + + userCtx := &coordinator.UserProjectContext{ + Project: coordinator.ProjectInfo{ + Name: "便携式掌上超声项目", + }, + LatestAdvisorBrief: &squad.AdvisorBriefing{ + MaturityScore: 70, + MaturityLevel: "V0.1", + MaturityHeadline: "已具备基本产品定义,亟待补充探头频率与目标市场", + PreemptiveRisks: []string{"欧盟CE MDR认证周期较长"}, + NextBestActions: []string{"推进进入V0.5可行性论证"}, + CommunicationKit: "【给客户的建议沟通要点】...", + }, + } + + recRes := &intent.IntentRecognitionResult{ + TopIntent: &intent.CandidateIntent{ + Intent: intent.IntentQueryProgress, + FilledSlots: map[string]string{ + intent.SlotQueryTopic: "risk", + }, + }, + } + + res, err := engine.ProcessIntent(context.Background(), recRes, userCtx) + if err != nil { + t.Fatalf("process intent failed: %v", err) + } + + if res.Status != coordinator.StatusAccepted { + t.Errorf("expected status ACCEPTED, got %s", res.Status) + } + if res.AdvisorBrief == nil { + t.Errorf("expected advisor brief in result") + } +} + +func TestCoordinatorEngine_SupplementInfo(t *testing.T) { + engine := coordinator.NewCoordinatorEngine(nil) + + userCtx := &coordinator.UserProjectContext{ + Project: coordinator.ProjectInfo{ + Name: "便携式掌上超声项目", + }, + BlackboardFacts: make(map[string]string), + } + + recRes := &intent.IntentRecognitionResult{ + TopIntent: &intent.CandidateIntent{ + Intent: intent.IntentSupplementInfo, + FilledSlots: map[string]string{ + intent.SlotFactKey: "target_market", + intent.SlotFactValue: "国内及欧盟CE", + }, + }, + } + + res, err := engine.ProcessIntent(context.Background(), recRes, userCtx) + if err != nil { + t.Fatalf("process intent failed: %v", err) + } + + if res.Status != coordinator.StatusAccepted { + t.Errorf("expected status ACCEPTED, got %s", res.Status) + } + if userCtx.BlackboardFacts["target_market"] != "国内及欧盟CE" { + t.Errorf("expected blackboard target_market updated, got %s", userCtx.BlackboardFacts["target_market"]) + } +} + +func TestCoordinatorEngine_AdvanceStage(t *testing.T) { + engine := coordinator.NewCoordinatorEngine(nil) + userCtx := &coordinator.UserProjectContext{ + Project: coordinator.ProjectInfo{ + Name: "超声测试项目", + CurrentStage: bizdev.StageV01_Triage, + }, + User: coordinator.UserInfo{ + ProjectRole: "composer", + }, + } + + recRes := &intent.IntentRecognitionResult{ + TopIntent: &intent.CandidateIntent{ + Intent: intent.IntentAdvanceStage, + FilledSlots: map[string]string{ + intent.SlotTargetStage: string(bizdev.StageV05_Feasibility), + }, + }, + } + + res, err := engine.ProcessIntent(context.Background(), recRes, userCtx) + if err != nil { + t.Fatalf("process intent failed: %v", err) + } + + if res.Status != coordinator.StatusAccepted { + t.Errorf("expected status ACCEPTED, got %s", res.Status) + } + if userCtx.Project.CurrentStage != bizdev.StageV05_Feasibility { + t.Errorf("expected stage V0.5_feasibility, got %s", userCtx.Project.CurrentStage) + } +}