Files
Agent-System/internal/squad/service.go
T
agent-bot 2cd52da85d
CI / go-vet (pull_request) Successful in 12s
CI / short-tests-report (pull_request) Failing after 35s
CI / backup-restore-drill (pull_request) Successful in 36s
CI / web (pull_request) Successful in 33s
refactor(squad): typed ProgressSink port replaces direct stream dependency
KTD1 second step (behavior preserved):
- squad defines ProgressSink (EmitPhase/Detail/Deliverable/Summary/Briefing)
  with its own DetailEvent/ToolEvent types; zero internal/stream imports left
- bizdev assembly maps port to SSE streamer (squad_progress_adapter) and
  injects via ctx at the four squad entry points
- process_bridge tests rewritten as port-contract tests (captureSink)
- verified: build+vet clean, squad/bizdev/archtest green
2026-09-29 05:51:32 +00:00

588 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package squad
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"chat-agent-system/internal/agent"
"chat-agent-system/internal/quote"
"chat-agent-system/internal/workflowdef"
)
// TaskMeta 任务元信息
type TaskMeta struct {
Key string `json:"key"`
Title string `json:"title"`
ArtifactTitle string `json:"artifact_title"`
Description string `json:"description"`
}
// Attachment 附加产物文件 (如 Excel)
type Attachment struct {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Data []byte `json:"data"`
}
// SquadRunRequest 运行请求
type SquadRunRequest struct {
TaskKey string `json:"task_key"`
Title string `json:"title,omitempty"` // 自由任务/非标任务的人类可读标题(缺省回落 TaskArtifactTitle)
ProjectName string `json:"project_name"`
CustomerName string `json:"customer_name"`
DeviceType string `json:"device_type"`
IntendedUse string `json:"intended_use"`
TargetMarket string `json:"target_market"`
Fields map[string]string `json:"fields"`
Materials []MaterialItem `json:"materials"`
Backend string `json:"backend"`
Timeout time.Duration `json:"timeout"`
WorkDir string `json:"work_dir"`
CustomSteps []StepSpec `json:"custom_steps"`
AutoRepair bool `json:"auto_repair"`
KnowledgeHits []KnowledgeHit `json:"knowledge_hits"`
PriorDocs []PriorDocItem `json:"prior_docs"`
UserContext string `json:"user_context,omitempty"`
MemoryBankFiles map[string]string `json:"memory_bank_files,omitempty"`
DialoguePackage *DialoguePackage `json:"-"`
SessionID string `json:"session_id,omitempty"`
UserID int64 `json:"user_id,omitempty"`
ProjectID int64 `json:"project_id,omitempty"`
RunID string `json:"run_id,omitempty"`
StepNotify func(StepEvent) `json:"-"`
}
// SquadRunResult 运行结果
type SquadRunResult struct {
TaskKey string `json:"task_key"`
ArtifactTitle string `json:"artifact_title"`
MarkdownBody string `json:"markdown_body"`
ExtractedData map[string]string `json:"extracted_data"`
Attachments []Attachment `json:"attachments"`
WorkDir string `json:"work_dir"`
BackendUsed string `json:"backend_used"`
Duration time.Duration `json:"duration"`
Success bool `json:"success"`
ErrorReason string `json:"error_reason,omitempty"`
QualityReport *QualityReport `json:"quality_report,omitempty"`
Manifest *Manifest `json:"manifest,omitempty"`
AdvisorBrief *AdvisorBriefing `json:"advisor_brief,omitempty"`
}
// WorkspaceState 用于观察工作区当前完整状态
type WorkspaceState struct {
WorkDir string `json:"work_dir"`
Metadata WorkspaceMetadata `json:"metadata"`
Squad SquadConfig `json:"squad"`
Workflow WorkflowPlan `json:"workflow"`
Blackboard BlackboardState `json:"blackboard"`
DeliverableContent string `json:"deliverable_content"`
Manifest *Manifest `json:"manifest,omitempty"`
}
// ModifyWorkflowRequest 动态调整现有工作区任务
type ModifyWorkflowRequest struct {
WorkDir string `json:"work_dir"`
AddSteps []StepSpec `json:"add_steps,omitempty"`
RemoveStepIDs []string `json:"remove_step_ids,omitempty"`
UpdateSteps []StepSpec `json:"update_steps,omitempty"`
Backend string `json:"backend,omitempty"`
Timeout time.Duration `json:"timeout,omitempty"`
AutoRepair bool `json:"auto_repair,omitempty"`
StepNotify func(StepEvent) `json:"-"`
}
// SquadService 小队领域服务核心接口
type SquadService interface {
ListTasks() []TaskMeta
PlanWorkflow(ctx context.Context, req SquadRunRequest) (*WorkflowPlan, error)
ExecuteWorkflow(ctx context.Context, wsDir string, backend string, opts ExecuteOptions) (*SquadRunResult, error)
ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadRunResult, error)
InspectWorkspace(ctx context.Context, workDir string) (*WorkspaceState, error)
ResumeOrModifyWorkspace(ctx context.Context, req ModifyWorkflowRequest) (*SquadRunResult, error)
}
// Options 是 Squad 依赖注入的唯一配置面;不反向依赖主仓 config.Config,
// 为独立模块做准备(见多仓方案 KTD1)。
type Options struct {
// BackendName 为执行后端名(空 = 由调用方默认)。
BackendName string
// BackendTimeout 为单次执行超时;零值由调用方默认。
BackendTimeout time.Duration
// DataDir 为工作区根(任务目录挂在其下)。
DataDir string
// NewBackend 由调用方注入的后端工厂(Core 装配:凭证、环境、并发上限
// 留在产品侧)。nil 时返回错误,不静默降级 Stub。
NewBackend func(name string) agent.Backend
}
func (o Options) withDefaults() Options {
if o.BackendName == "" {
o.BackendName = "pi-agent"
}
if o.BackendTimeout <= 0 {
o.BackendTimeout = 10 * time.Minute
}
return o
}
// Service SquadService 实现
type Service struct {
opts Options
engine *WorkspaceEngine
driver *WorkflowDriver
gate *QualityGate
}
func NewService(opts Options) *Service {
engine := NewWorkspaceEngine()
return &Service{
opts: opts.withDefaults(),
engine: engine,
driver: NewWorkflowDriver(engine),
gate: NewQualityGate(),
}
}
var baseSupportedTasks = []TaskMeta{
{Key: "bd_intake", Title: "用户初始需求收集", ArtifactTitle: "用户初始需求收集表", Description: "梳理客户原始需求、提炼事实清单与缺口"},
{Key: "bd_research", Title: "调研", ArtifactTitle: "调研报告", Description: "市场竞品同维度对比、法规要点与技术方案初筛"},
{Key: "bd_feasibility_dev", Title: "设计开发可行性评估", ArtifactTitle: "设计开发可行性评估报告", Description: "工程化差距、技术路线与系统架构可行性推导"},
{Key: "bd_feasibility_reg", Title: "注册路径可行性评估", ArtifactTitle: "注册路径可行性评估报告", Description: "分类建议、临床评价策略与主要标准检测规划"},
{Key: "bd_user_requirements", Title: "用户需求表", ArtifactTitle: "用户需求表", Description: "用户角色画像与可追溯需求条目清单"},
{Key: "pm_system_reqs", Title: "系统需求规格书", ArtifactTitle: "系统需求规格书", Description: "智能产品经理深度提炼,输出系统需求规格书及工程追溯"},
{Key: "tm_system_verification", Title: "系统验证方案及计划", ArtifactTitle: "系统验证方案及计划", Description: "智能测试经理基于系统需求生成系统测试方案与测试用例"},
{Key: "bd_prelim_design", Title: "系统初步设计方案", ArtifactTitle: "系统初步设计方案", Description: "子系统架构边界、数据接口与Mermaid上下文流向"},
{Key: "bd_cdo_quote", Title: "CDO报价单", ArtifactTitle: "CDO报价单(含定价核算 Excel)", Description: "基于公司定价模型的工时拆解、成本核算与法定说明"},
}
// SupportedTasks 标准小队任务白名单(商务九键 + rdev 六十键)。
var SupportedTasks []TaskMeta
func init() {
SupportedTasks = append(append([]TaskMeta{}, baseSupportedTasks...), rdevSupportedTasks()...)
}
func (s *Service) ListTasks() []TaskMeta {
return SupportedTasks
}
func taskKnown(taskKey string) bool {
canon := workflowdef.CanonicalTaskKey(taskKey)
for _, t := range SupportedTasks {
if t.Key == canon {
return true
}
}
return false
}
func TaskArtifactTitle(taskKey string) string {
canon := workflowdef.CanonicalTaskKey(taskKey)
for _, t := range SupportedTasks {
if t.Key == canon {
return t.ArtifactTitle
}
}
return taskKey
}
// taskDisplayTitle 返回任务展示标题(自由任务 Title 优先,回落标准任务产物名)
func taskDisplayTitle(req SquadRunRequest) string {
if req.Title != "" {
return req.Title
}
return TaskArtifactTitle(req.TaskKey)
}
// PlanWorkflow 动态规划生成任务工作流
func (s *Service) PlanWorkflow(ctx context.Context, req SquadRunRequest) (*WorkflowPlan, error) {
planner := NewAdaptivePlanner(nil)
res, err := planner.Plan(PlannerRequest{
TaskKey: req.TaskKey,
Title: taskDisplayTitle(req),
ProjectName: req.ProjectName,
CustomerName: req.CustomerName,
DeviceType: req.DeviceType,
IntendedUse: req.IntendedUse,
TargetMarket: req.TargetMarket,
Materials: req.Materials,
KnowledgeHits: req.KnowledgeHits,
Fields: req.Fields,
CustomSteps: req.CustomSteps,
})
if err != nil {
return nil, err
}
return &res.Workflow, nil
}
// ExecuteTask 便捷门面:一步构建工作区、调度动态工作流、进行CDO核算与终审质量门禁
func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadRunResult, error) {
start := time.Now()
workDir := req.WorkDir
if workDir == "" {
ts := time.Now().UnixNano()
workDir = filepath.Join(s.opts.DataDir, "workspaces", "bizdev", fmt.Sprintf("%s_%d", req.TaskKey, ts))
}
backendName := req.Backend
if backendName == "" {
backendName = s.opts.BackendName
}
// 1. 初始化工作区目录体系
wsReq := WorkspacePrepareRequest{
WorkDir: workDir,
SessionID: FlexSessionID(req.SessionID),
ProjectID: req.ProjectID,
TaskKey: req.TaskKey,
StageTitle: taskDisplayTitle(req),
ProjectName: req.ProjectName,
CustomerName: req.CustomerName,
DeviceType: req.DeviceType,
IntendedUse: req.IntendedUse,
TargetMarket: req.TargetMarket,
Backend: backendName,
Materials: req.Materials,
KnowledgeHits: req.KnowledgeHits,
PriorDocs: req.PriorDocs,
WorkflowSteps: req.CustomSteps,
InitialFields: req.Fields,
UserContext: req.UserContext,
MemoryBankFiles: req.MemoryBankFiles,
DialoguePackage: req.DialoguePackage,
}
_, err := s.engine.PrepareWorkspace(wsReq)
if err != nil {
return nil, fmt.Errorf("prepare workspace: %w", err)
}
// 2. 若为 CDO 报价任务,先执行 Excel 报价核算引擎并回填
var attachments []Attachment
if workflowdef.SameTaskKey(req.TaskKey, "cdo_quote") {
att, err := s.handleCDOQuotePreparation(workDir, req)
if err == nil && att != nil {
attachments = append(attachments, *att)
}
}
// 3. 执行动态工作流调度
sink := ProgressFromContext(ctx)
if sink != nil {
sink.EmitPhase("squad_assembly", fmt.Sprintf("正在组建并启动【%s】智能体小队(后端: %s)...", TaskArtifactTitle(req.TaskKey), backendName), "leader", 0, 0, 5)
}
if s.opts.NewBackend == nil {
return nil, fmt.Errorf("squad: NewBackend factory not injected")
}
backend := s.opts.NewBackend(backendName)
timeout := req.Timeout
if timeout <= 0 {
timeout = s.opts.BackendTimeout
}
execOpts := ExecuteOptions{
Timeout: timeout,
StepNotify: req.StepNotify,
}
execRes, err := s.driver.Execute(ctx, workDir, backend, execOpts)
if err != nil {
if sink != nil {
sink.EmitPhase("task_failed", fmt.Sprintf("任务执行异常: %v", err), "leader", 0, 0, 0)
}
return &SquadRunResult{
TaskKey: req.TaskKey,
ArtifactTitle: TaskArtifactTitle(req.TaskKey),
WorkDir: workDir,
BackendUsed: backendName,
Duration: time.Since(start),
Success: false,
ErrorReason: err.Error(),
}, err
}
// 4. 自动化质量门禁与修复 (QualityGate)
if sink != nil {
sink.EmitPhase("quality_gate", "审核主管正在核验交付物规范并执行质量门禁把关...", "leader", 0, 0, 85)
}
maxRetries := repairBudgetForSquadTask(req)
report, err := s.gate.EnsureQualityOrRepair(ctx, workDir, req.TaskKey, s.driver, backend, maxRetries, execOpts)
if err != nil {
// 记录门禁错误但容错返回当前正文
report = &QualityReport{Passed: false, Violations: []string{err.Error()}}
if sink != nil {
sink.EmitPhase("quality_gate", fmt.Sprintf("质量门禁审查告警: %v", err), "leader", 0, 0, 90)
}
} else if sink != nil {
if report.Passed {
sink.EmitPhase("quality_gate", fmt.Sprintf("质量门禁审核通过(评分: %.1f / 100)", report.Score), "leader", 0, 0, 95)
} else {
violations := strings.Join(report.Violations, ";")
if violations == "" {
violations = "未满足质量门禁要求"
}
sink.EmitPhase("quality_gate", fmt.Sprintf("质量门禁未通过(评分: %.1f / 100):%s", report.Score, violations), "leader", 0, 0, 95)
}
}
manifest, _ := s.gate.GenerateManifest(workDir, req.TaskKey, *report)
// 5. 读取最终交付正文与黑板抽取数据
finalOutPath := filepath.Join(workDir, "output.md")
finalBytes, _ := os.ReadFile(finalOutPath)
if backendName == "stub" {
finalBytes, _ = os.ReadFile(filepath.Join(workDir, "template.md"))
_ = os.WriteFile(finalOutPath, finalBytes, 0o644)
// stub 终稿以 template 为准:QualityReport 对齐最终交付物(Success 仍只表示执行完成)
if reReport, reErr := s.gate.Validate(workDir, req.TaskKey, string(finalBytes)); reErr == nil {
report = reReport
manifest, _ = s.gate.GenerateManifest(workDir, req.TaskKey, *report)
}
}
bb, _ := s.driver.LoadBlackboard(workDir)
var extractedData map[string]string
if bb != nil {
extractedData = bb.Fields
}
advisorBrief := ExtractAdvisorBriefing(req.TaskKey, string(finalBytes), extractedData)
if bb != nil && len(bb.Assumptions) > 0 {
for _, a := range bb.Assumptions {
stmt := fmt.Sprintf("[%s] %s (推导依据: %s)", a.Topic, a.Statement, a.Rationale)
advisorBrief.BaselineAssumptions = append(advisorBrief.BaselineAssumptions, stmt)
}
// 若交付正文中尚未包含显式基准假设附录,自动挂载
if !strings.Contains(string(finalBytes), "基准假设与待确认清单") && !strings.Contains(string(finalBytes), "Baseline Assumptions") {
var appendix strings.Builder
appendix.WriteString("\n\n---\n\n## 附录:智能体小队基准假设与待确认清单 (Baseline Assumptions & Checklist)\n\n")
appendix.WriteString("> 提示:针对项目初期未明确的信息,智能体小队已依据医疗器械行业先验预设基准假设并完成初步推演成文;用户可随时确认或修正:\n\n")
for idx, a := range bb.Assumptions {
appendix.WriteString(fmt.Sprintf("%d. **[%s]** %s\n - *推导依据*:%s\n", idx+1, a.Topic, a.Statement, a.Rationale))
}
if len(bb.Gaps) > 0 {
appendix.WriteString("\n### 待补充与确认缺口 (Pending Gaps)\n")
for idx, g := range bb.Gaps {
appendix.WriteString(fmt.Sprintf("- [%d] %s (阻塞性: %v, 责任方: %s)\n", idx+1, g.Item, g.Blocking, g.Owner))
}
}
finalBytes = append(finalBytes, []byte(appendix.String())...)
_ = os.WriteFile(finalOutPath, finalBytes, 0o644)
}
}
if sink != nil {
score := 0.0
if report != nil {
score = report.Score
}
sink.EmitDeliverable(req.TaskKey, TaskArtifactTitle(req.TaskKey), finalOutPath, len(strings.Fields(string(finalBytes))), score)
if advisorBrief != nil {
sink.EmitPhase("advisor_briefing", "贴身业务参谋正在提炼关键决策摘要与风险雷达...", "coordinator", 0, 0, 98)
sink.EmitSummary(TaskArtifactTitle(req.TaskKey)+"已生成", advisorBrief.MaturityHeadline, advisorBrief.MaturityScore, advisorBrief.MaturityLevel)
sink.EmitBriefing(advisorBrief)
}
}
return &SquadRunResult{
TaskKey: req.TaskKey,
ArtifactTitle: TaskArtifactTitle(req.TaskKey),
MarkdownBody: string(finalBytes),
ExtractedData: extractedData,
Attachments: attachments,
WorkDir: workDir,
BackendUsed: backendName,
Duration: time.Since(start),
Success: execRes.Success,
QualityReport: report,
Manifest: manifest,
AdvisorBrief: advisorBrief,
}, nil
}
// repairBudgetForSquadTask 仅尊重请求方 AutoRepair,不因 TaskKey 非空而改写 CLI/桩/非受控语义。
// 受控文档任务的修复预算由 bizdev/HTTP 在调用前强制 AutoRepair=true。
func repairBudgetForSquadTask(req SquadRunRequest) int {
if req.AutoRepair {
return DefaultMaxRepairRounds
}
return 0
}
// ExecuteWorkflow 执行既有工作区工作流
func (s *Service) ExecuteWorkflow(ctx context.Context, wsDir string, backendName string, opts ExecuteOptions) (*SquadRunResult, error) {
start := time.Now()
if backendName == "" {
backendName = s.opts.BackendName
}
if s.opts.NewBackend == nil {
return nil, fmt.Errorf("squad: NewBackend factory not injected")
}
backend := s.opts.NewBackend(backendName)
execRes, err := s.driver.Execute(ctx, wsDir, backend, opts)
if err != nil {
return nil, err
}
wsMeta, _ := s.loadWorkspaceMeta(wsDir)
finalBytes, _ := os.ReadFile(filepath.Join(wsDir, "output.md"))
bb, _ := s.driver.LoadBlackboard(wsDir)
var extractedData map[string]string
if bb != nil {
extractedData = bb.Fields
}
advisorBrief := ExtractAdvisorBriefing(wsMeta.TaskKey, string(finalBytes), extractedData)
if bb != nil && len(bb.Assumptions) > 0 {
for _, a := range bb.Assumptions {
stmt := fmt.Sprintf("[%s] %s (推导依据: %s)", a.Topic, a.Statement, a.Rationale)
advisorBrief.BaselineAssumptions = append(advisorBrief.BaselineAssumptions, stmt)
}
}
return &SquadRunResult{
TaskKey: wsMeta.TaskKey,
ArtifactTitle: TaskArtifactTitle(wsMeta.TaskKey),
MarkdownBody: string(finalBytes),
ExtractedData: extractedData,
WorkDir: wsDir,
BackendUsed: backendName,
Duration: time.Since(start),
Success: execRes.Success,
AdvisorBrief: advisorBrief,
}, nil
}
// InspectWorkspace 查看指定工作区的当前执行状态、黑板数据与各专员产出
func (s *Service) InspectWorkspace(ctx context.Context, workDir string) (*WorkspaceState, error) {
meta, err := s.loadWorkspaceMeta(workDir)
if err != nil {
return nil, fmt.Errorf("load workspace meta: %w", err)
}
squadCfg, err := s.driver.loadSquadConfig(workDir)
if err != nil {
return nil, fmt.Errorf("load squad config: %w", err)
}
wf, err := s.driver.LoadWorkflow(workDir)
if err != nil {
return nil, fmt.Errorf("load workflow: %w", err)
}
bb, err := s.driver.LoadBlackboard(workDir)
if err != nil {
return nil, fmt.Errorf("load blackboard: %w", err)
}
outPath := filepath.Join(workDir, "output.md")
data, _ := os.ReadFile(outPath)
return &WorkspaceState{
WorkDir: workDir,
Metadata: meta,
Squad: squadCfg,
Workflow: *wf,
Blackboard: *bb,
DeliverableContent: string(data),
}, nil
}
// ResumeOrModifyWorkspace 在现有工作区基础上,动态添加/重跑/修改任务并继续执行
func (s *Service) ResumeOrModifyWorkspace(ctx context.Context, req ModifyWorkflowRequest) (*SquadRunResult, error) {
for _, add := range req.AddSteps {
if err := s.driver.AddStep(req.WorkDir, add); err != nil {
return nil, fmt.Errorf("add step %s: %w", add.ID, err)
}
}
for _, removeID := range req.RemoveStepIDs {
if err := s.driver.RemoveStep(req.WorkDir, removeID); err != nil {
return nil, fmt.Errorf("remove step %s: %w", removeID, err)
}
}
for _, update := range req.UpdateSteps {
if err := s.driver.UpdateStep(req.WorkDir, update); err != nil {
return nil, fmt.Errorf("update step %s: %w", update.ID, err)
}
}
backendName := req.Backend
if backendName == "" {
backendName = s.opts.BackendName
}
timeout := req.Timeout
if timeout <= 0 {
timeout = s.opts.BackendTimeout
}
opts := ExecuteOptions{
Timeout: timeout,
StepNotify: req.StepNotify,
}
return s.ExecuteWorkflow(ctx, req.WorkDir, backendName, opts)
}
func (s *Service) handleCDOQuotePreparation(workDir string, req SquadRunRequest) (*Attachment, error) {
xlsxPath := filepath.Join(workDir, "CDO报价核算-V01.xlsx")
artifactXlsxPath := filepath.Join(workDir, "artifacts", "CDO报价核算-V01.xlsx")
in := quote.Input{
CustomerName: req.CustomerName,
ProductName: req.ProjectName,
Hours: map[string]float64{
"结构工程师": 320,
"硬件工程师": 320,
"软件工程师": 480,
"项目经理": 160,
},
}
summary, err := quote.CalculateAndFill(in, "templates/xlsx/cdo_quote.xlsx", xlsxPath)
if err != nil {
return nil, err
}
data, err := os.ReadFile(xlsxPath)
if err != nil {
return nil, err
}
_ = os.WriteFile(artifactXlsxPath, data, 0o644)
// 回填 template.md 中的报价数据
tmplPath := filepath.Join(workDir, "template.md")
if tmplBytes, err := os.ReadFile(tmplPath); err == nil {
filled := BackfillCDOQuoteMarkdown(string(tmplBytes), summary)
_ = os.WriteFile(tmplPath, []byte(filled), 0o644)
_ = os.WriteFile(filepath.Join(workDir, "output.md"), []byte(filled), 0o644)
}
return &Attachment{
Filename: "CDO报价核算-V01.xlsx",
ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
Data: data,
}, nil
}
func (s *Service) loadWorkspaceMeta(wsDir string) (WorkspaceMetadata, error) {
p := filepath.Join(wsDir, "workspace.json")
var m WorkspaceMetadata
data, err := os.ReadFile(p)
if err != nil {
return m, err
}
err = json.Unmarshal(data, &m)
return m, err
}