feat(squad): 实现单步评估器StepEvaluator与工作流动态加步、跳步及基准假设推断

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-07 22:27:06 +08:00
co-authored by Cursor
parent 37664ad7c8
commit abd0893e12
5 changed files with 603 additions and 32 deletions
+29 -2
View File
@@ -40,8 +40,8 @@ func Load(path string) (*Config, error) {
CursorBridgeBin: "tools/cursor-bridge/index.mjs",
PiAgentBin: "/opt/homebrew/bin/pi",
PiAgentBridgeBin: "tools/pi-bridge/index.mjs",
PiAgentProvider: "anthropic",
PiAgentModel: "claude-3-7-sonnet",
PiAgentProvider: "cc-switch-kimi-for-coding",
PiAgentModel: "kimi-for-coding",
KbDbHost: "127.0.0.1",
KbDbPort: 55432,
KbDbUser: "regkb",
@@ -49,6 +49,13 @@ func Load(path string) (*Config, error) {
KbDbName: "regulations",
}
if path == "" {
if _, err := os.Stat("configs/.env.dev"); err == nil {
path = "configs/.env.dev"
} else if _, err := os.Stat(".env"); err == nil {
path = ".env"
}
}
f, err := os.Open(path)
if err == nil {
defer f.Close()
@@ -113,5 +120,25 @@ func Load(path string) (*Config, error) {
}
}
// 允许系统环境变量覆盖
if v := os.Getenv("ENV"); v != "" {
cfg.Env = v
}
if v := os.Getenv("DATA_DIR"); v != "" {
cfg.DataDir = v
}
if v := os.Getenv("AGENT_BACKEND"); v != "" {
cfg.AgentBackend = v
}
if v := os.Getenv("PI_AGENT_BIN"); v != "" {
cfg.PiAgentBin = v
}
if v := os.Getenv("PI_AGENT_PROVIDER"); v != "" {
cfg.PiAgentProvider = v
}
if v := os.Getenv("PI_AGENT_MODEL"); v != "" {
cfg.PiAgentModel = v
}
return cfg, nil
}
+152
View File
@@ -0,0 +1,152 @@
package squad
import (
"fmt"
"strings"
"time"
)
// StepEvaluator 负责单步执行产物评估与自适应反馈调整
type StepEvaluator struct{}
// NewStepEvaluator 创建单步评估器
func NewStepEvaluator() *StepEvaluator {
return &StepEvaluator{}
}
// EvaluateStepOutput 评估单步产物与黑板事实,推导基准假设与动态重排指令
func (e *StepEvaluator) EvaluateStepOutput(
wsDir string,
step StepSpec,
outputContent string,
bb *BlackboardState,
) (*EvaluationResult, error) {
now := time.Now()
res := &EvaluationResult{
StepID: step.ID,
Action: StepActionContinue,
Reason: "步骤按计划正常推进",
NewAssumptions: make([]AssumptionItem, 0),
DynamicStepsToAdd: make([]StepSpec, 0),
DynamicStepsToAlter: make([]StepSpec, 0),
StepsToSkip: make([]string, 0),
}
contentLower := strings.ToLower(outputContent)
// 1. 基于黑板缺口与产物推导“基准假设 (Baseline Assumptions)”
var gapTexts []string
if bb != nil {
for _, g := range bb.Gaps {
gapTexts = append(gapTexts, strings.ToLower(g.Item))
}
}
combinedLower := contentLower + " " + strings.Join(gapTexts, " ")
// 规则 1.1: 灭菌方式缺口推断
if strings.Contains(combinedLower, "未明确灭菌") || strings.Contains(combinedLower, "未提供灭菌") ||
strings.Contains(combinedLower, "未确定灭菌") || strings.Contains(combinedLower, "缺少灭菌方式") ||
strings.Contains(combinedLower, "未提供产品灭菌方式") {
if !hasAssumptionTopic(bb, "sterilization_method") {
res.NewAssumptions = append(res.NewAssumptions, AssumptionItem{
ID: fmt.Sprintf("assump_sterilization_%d", now.UnixNano()),
Topic: "sterilization_method",
Statement: "基准假设灭菌方式采用环氧乙烷(EO)灭菌,解析期设定为14天",
Rationale: "客户未明确灭菌方式,依据二类无源无菌高分子介入器械行业通用工程实践设定",
Source: "inferred",
})
}
}
// 规则 1.2: 货架有效期缺口推断
if strings.Contains(combinedLower, "未明确货架寿命") || strings.Contains(combinedLower, "有效期未定") ||
strings.Contains(combinedLower, "未提供有效期") {
if !hasAssumptionTopic(bb, "shelf_life") {
res.NewAssumptions = append(res.NewAssumptions, AssumptionItem{
ID: fmt.Sprintf("assump_shelflife_%d", now.UnixNano()),
Topic: "shelf_life",
Statement: "基准假设产品货架有效期设定为2年,需执行加速老化试验",
Rationale: "符合常规无菌医疗器械首次注册基准设计周期",
Source: "inferred",
})
}
}
// 规则 1.3: 注册目标市场缺口推断
if strings.Contains(combinedLower, "未明确目标市场") || strings.Contains(combinedLower, "市场未定") {
if !hasAssumptionTopic(bb, "target_market") {
res.NewAssumptions = append(res.NewAssumptions, AssumptionItem{
ID: fmt.Sprintf("assump_market_%d", now.UnixNano()),
Topic: "target_market",
Statement: "基准假设首发上市目标为中国境内NMPA二类医疗器械注册",
Rationale: "以国内优先注册为基准,后续拓展海外认证",
Source: "inferred",
})
}
}
// 2. 检查是否触发动态跳步 (Skip Step)
if strings.Contains(contentLower, "无需开展重复公开资料检索") ||
strings.Contains(contentLower, "无需重复开展市场竞品检索") ||
strings.Contains(contentLower, "竞品数据已全面锁定") ||
strings.Contains(contentLower, "跳过市场调研") {
if res.Action == StepActionContinue {
res.Action = StepActionSkipStep
}
res.StepsToSkip = append(res.StepsToSkip, "step_02_market", "step_02", "market")
res.Reason = "前序分析已充分锁定竞品数据,避免重复冗余检索"
}
// 3. 检查是否触发动态加步 (Add Step)
// 射频/微波 EMC 与热安全风险
if (strings.Contains(contentLower, "射频") || strings.Contains(contentLower, "微波") || strings.Contains(contentLower, "消融")) &&
(strings.Contains(contentLower, "电磁兼容") || strings.Contains(contentLower, "emc") || strings.Contains(contentLower, "热灼伤")) {
res.Action = StepActionAddStep
res.DynamicStepsToAdd = append(res.DynamicStepsToAdd, StepSpec{
ID: "step_rf_emc_safety",
Role: "engineer",
Title: "高频电磁兼容性(EMC)与热安全防护专项工程论证",
SubWorkspace: "tasks/dynamic_rf_emc",
Mode: "execute",
AllowDynamicAdjustment: false,
Accept: []string{
"核算高频消融电磁辐射泄漏安全边际与抗干扰设计",
"设计温度反馈闭环与防止意外热灼伤切断机制",
},
})
res.Reason = "识别到大功率射频高频风险,动态注入EMC与热防护专项工程论证工序"
}
// 动物源病毒灭活专题
if (strings.Contains(contentLower, "牛心包") || strings.Contains(contentLower, "动物源")) &&
(strings.Contains(contentLower, "病毒灭活") || strings.Contains(contentLower, "免疫原性")) {
res.Action = StepActionAddStep
res.DynamicStepsToAdd = append(res.DynamicStepsToAdd, StepSpec{
ID: "step_animal_virus_inactivation",
Role: "regulatory",
Title: "动物源性材料病毒灭活与免疫原性合规专题论证",
SubWorkspace: "tasks/dynamic_virus_inactivation",
Mode: "execute",
AllowDynamicAdjustment: false,
Accept: []string{
"核查动物源溯源合规证明与供体检疫要求",
"拟定基于ISO 22442的病毒灭活有效性验证方案",
},
})
res.Reason = "识别到动物源性材料生物风险,动态追加病毒灭活合规论证工序"
}
return res, nil
}
func hasAssumptionTopic(bb *BlackboardState, topic string) bool {
if bb == nil {
return false
}
for _, a := range bb.Assumptions {
if a.Topic == topic {
return true
}
}
return false
}
+239
View File
@@ -0,0 +1,239 @@
package squad
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"chat-agent-system/internal/agent"
)
func TestStepEvaluator_InferAssumptionsFromGaps(t *testing.T) {
evaluator := NewStepEvaluator()
step := StepSpec{
ID: "step_01_organize",
Role: "organizer",
AllowDynamicAdjustment: true,
}
outputContent := "材料整理完成。目前客户未明确灭菌方式与包装材料,仅注明为一次性无菌介入类医疗器械。"
bb := &BlackboardState{
Gaps: []GapItem{
{ID: "g1", Item: "未提供产品灭菌方式", Blocking: false},
},
Assumptions: []AssumptionItem{},
}
res, err := evaluator.EvaluateStepOutput("", step, outputContent, bb)
if err != nil {
t.Fatalf("EvaluateStepOutput failed: %v", err)
}
foundSterilization := false
for _, a := range res.NewAssumptions {
if a.Topic == "sterilization_method" {
foundSterilization = true
if !strings.Contains(a.Statement, "环氧乙烷") {
t.Errorf("assumption statement should infer EO sterilization, got %s", a.Statement)
}
if a.Source != "inferred" {
t.Errorf("assumption source should be inferred, got %s", a.Source)
}
break
}
}
if !foundSterilization {
t.Errorf("expected baseline assumption for sterilization_method to be inferred")
}
}
func TestStepEvaluator_SkipStepDetection(t *testing.T) {
evaluator := NewStepEvaluator()
step := StepSpec{
ID: "step_01_organize",
Role: "organizer",
AllowDynamicAdjustment: true,
}
outputContent := "输入材料中已完整包含国内外3款主要竞品型号、市场份额与终端招标定价,竞品数据已全面锁定,无需重复开展市场竞品检索。"
bb := &BlackboardState{}
res, err := evaluator.EvaluateStepOutput("", step, outputContent, bb)
if err != nil {
t.Fatalf("EvaluateStepOutput failed: %v", err)
}
if res.Action != StepActionSkipStep {
t.Errorf("expected action %s, got %s", StepActionSkipStep, res.Action)
}
hasMarketSkip := false
for _, s := range res.StepsToSkip {
if s == "step_02_market" || strings.Contains(s, "market") {
hasMarketSkip = true
break
}
}
if !hasMarketSkip {
t.Errorf("expected step_02_market to be in StepsToSkip, got %v", res.StepsToSkip)
}
}
func TestStepEvaluator_AddStepDetection(t *testing.T) {
evaluator := NewStepEvaluator()
step := StepSpec{
ID: "step_01_organize",
Role: "organizer",
AllowDynamicAdjustment: true,
}
outputContent := "梳理发现本项目采用微波射频双极消融技术,输出功率达150W,涉及高频电磁兼容性(EMC)与热灼伤防护特殊工程要求,需重点论证。"
bb := &BlackboardState{}
res, err := evaluator.EvaluateStepOutput("", step, outputContent, bb)
if err != nil {
t.Fatalf("EvaluateStepOutput failed: %v", err)
}
if res.Action != StepActionAddStep {
t.Errorf("expected action %s, got %s", StepActionAddStep, res.Action)
}
if len(res.DynamicStepsToAdd) == 0 {
t.Fatalf("expected at least 1 dynamic step to be added")
}
added := res.DynamicStepsToAdd[0]
if !strings.Contains(added.ID, "emc") && !strings.Contains(added.Title, "电磁兼容") {
t.Errorf("expected added step related to EMC/RF, got %s / %s", added.ID, added.Title)
}
}
func TestWorkflowDriver_DynamicAdjustmentIntegration(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "wf_eval_test_*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
engine := NewWorkspaceEngine()
driver := NewWorkflowDriver(engine)
evaluator := NewStepEvaluator()
driver.SetEvaluator(evaluator)
// 构建一个包含 3 步的工作流
steps := []StepSpec{
{
ID: "step_01",
Role: "organizer",
Title: "整理输入材料",
SubWorkspace: "tasks/01_organize",
Mode: "execute",
AllowDynamicAdjustment: true,
},
{
ID: "step_02",
Role: "market",
Title: "市场竞品调研",
SubWorkspace: "tasks/02_market",
Mode: "execute",
},
{
ID: "step_03",
Role: "writer",
Title: "整合撰写交付初稿",
SubWorkspace: "tasks/03_draft",
Mode: "execute",
},
}
req := WorkspacePrepareRequest{
WorkDir: tmpDir,
TaskKey: "intake",
StageTitle: "需求收集",
ProjectName: "射频消融介入系统",
WorkflowSteps: steps,
}
_, err = engine.PrepareWorkspace(req)
if err != nil {
t.Fatalf("prepare workspace failed: %v", err)
}
// 模拟让 step 1 产出触发跳过 step 2 并添加电磁兼容专题
step1Dir := filepath.Join(tmpDir, "tasks", "01_organize")
_ = os.MkdirAll(step1Dir, 0o755)
_ = os.WriteFile(filepath.Join(step1Dir, "output.md"), []byte(
"材料梳理完毕。竞品数据已全面锁定,无需重复开展市场竞品检索。同时发现输出功率达150W微波射频高频,需重点论证高频电磁兼容性(EMC)。另外,客户未明确灭菌方式,需做工程假设推演。",
), 0o644)
// 创建一个仅执行并模拟写入 output.md 的后端
backend := &testEchoBackend{}
res, err := driver.Execute(context.Background(), tmpDir, backend, ExecuteOptions{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("driver.Execute failed: %v", err)
}
if !res.Success {
t.Errorf("driver.Execute expected success")
}
// 检验工作流状态:step_02 应被置为 skipped,且动态追加了 emc 步骤
wf, err := driver.LoadWorkflow(tmpDir)
if err != nil {
t.Fatalf("load workflow failed: %v", err)
}
foundSkipped := false
foundEMCAdded := false
for _, s := range wf.Steps {
if s.ID == "step_02" && s.Status == "skipped" {
foundSkipped = true
}
if strings.Contains(s.ID, "emc") && s.Status == "completed" {
foundEMCAdded = true
}
}
if !foundSkipped {
t.Errorf("step_02 should have been marked as skipped")
}
if !foundEMCAdded {
t.Errorf("dynamic EMC step should have been added and executed")
}
// 检验黑板是否成功写入了 baseline assumption
bb, err := driver.LoadBlackboard(tmpDir)
if err != nil {
t.Fatalf("load blackboard failed: %v", err)
}
if len(bb.Assumptions) == 0 {
t.Errorf("expected at least 1 baseline assumption in blackboard")
} else {
t.Logf("inferred assumption: %s (%s)", bb.Assumptions[0].Topic, bb.Assumptions[0].Statement)
}
}
type testEchoBackend struct{}
func (b *testEchoBackend) Name() string { return "test-echo" }
func (b *testEchoBackend) Execute(ctx context.Context, req agent.Request) (*agent.Result, error) {
// 如果目标文件已存在(如手动预置的 step 1),不覆盖
target := "output.md"
if len(req.Targets) > 0 {
target = req.Targets[0]
}
p := filepath.Join(req.WorkDir, target)
if _, err := os.Stat(p); os.IsNotExist(err) {
_ = os.WriteFile(p, []byte("## 步骤执行交付产物\n\n已完成任务要求论证与数据沉淀。\n"), 0o644)
}
return &agent.Result{Success: true}, nil
}
+170 -27
View File
@@ -43,14 +43,20 @@ type WorkflowExecutionResult struct {
// WorkflowDriver 负责动态工作流调度与沙箱隔离执行
type WorkflowDriver struct {
engine *WorkspaceEngine
engine *WorkspaceEngine
evaluator *StepEvaluator
}
func NewWorkflowDriver(engine *WorkspaceEngine) *WorkflowDriver {
if engine == nil {
engine = NewWorkspaceEngine()
}
return &WorkflowDriver{engine: engine}
return &WorkflowDriver{engine: engine, evaluator: NewStepEvaluator()}
}
// SetEvaluator 配置自定义单步评估器
func (d *WorkflowDriver) SetEvaluator(ev *StepEvaluator) {
d.evaluator = ev
}
// LoadWorkflow 读取工作区 workflow.json
@@ -199,42 +205,60 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen
i := 0
for i < len(wf.Steps) {
step := wf.Steps[i]
if step.Status == "skipped" {
i++
continue
}
if step.Parallel {
var parallelGroup []StepSpec
for i < len(wf.Steps) && wf.Steps[i].Parallel {
parallelGroup = append(parallelGroup, wf.Steps[i])
if wf.Steps[i].Status != "skipped" {
parallelGroup = append(parallelGroup, wf.Steps[i])
}
i++
}
var wg sync.WaitGroup
var groupErr error
for _, s := range parallelGroup {
sCopy := s
wg.Add(1)
go func() {
defer wg.Done()
if err := d.runOneStep(runCtx, wsDir, sCopy, backend, opts.StepNotify, &mu, &producedFiles); err != nil {
mu.Lock()
if groupErr == nil {
groupErr = err
if len(parallelGroup) > 0 {
var wg sync.WaitGroup
var groupErr error
for _, s := range parallelGroup {
sCopy := s
wg.Add(1)
go func() {
defer wg.Done()
if err := d.runOneStep(runCtx, wsDir, sCopy, backend, opts.StepNotify, &mu, &producedFiles); err != nil {
mu.Lock()
if groupErr == nil {
groupErr = err
}
mu.Unlock()
}
}()
}
wg.Wait()
if groupErr != nil {
wf.Status = "failed"
_ = d.SaveWorkflow(wsDir, wf)
_ = d.updateWorkspaceStatus(wsDir, "failed")
return &WorkflowExecutionResult{
Success: false,
ErrorReason: groupErr.Error(),
Duration: time.Since(start),
}, groupErr
}
stepsRun += len(parallelGroup)
for idx := range wf.Steps {
for _, pg := range parallelGroup {
if wf.Steps[idx].ID == pg.ID {
wf.Steps[idx].Status = "completed"
}
mu.Unlock()
}
}()
}
wg.Wait()
if groupErr != nil {
wf.Status = "failed"
}
_ = d.SaveWorkflow(wsDir, wf)
_ = d.updateWorkspaceStatus(wsDir, "failed")
return &WorkflowExecutionResult{
Success: false,
ErrorReason: groupErr.Error(),
Duration: time.Since(start),
}, groupErr
}
stepsRun += len(parallelGroup)
} else {
stepIndex := i
i++
if err := d.runOneStep(runCtx, wsDir, step, backend, opts.StepNotify, &mu, &producedFiles); err != nil {
wf.Status = "failed"
@@ -247,6 +271,12 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen
}, err
}
stepsRun++
wf.Steps[stepIndex].Status = "completed"
_ = d.SaveWorkflow(wsDir, wf)
if step.AllowDynamicAdjustment && d.evaluator != nil {
d.applyDynamicAdjustment(wsDir, wf, step, &mu, opts.StepNotify)
}
}
}
@@ -422,6 +452,119 @@ func (d *WorkflowDriver) runOneStep(
return nil
}
func (d *WorkflowDriver) applyDynamicAdjustment(
wsDir string,
wf *WorkflowPlan,
step StepSpec,
mu *sync.Mutex,
notify func(StepEvent),
) {
deliverableName := "output.md"
if step.Mode == "plan" || step.Mode == "replan" {
deliverableName = "output_plan.md"
}
outPath := filepath.Join(wsDir, filepath.FromSlash(step.SubWorkspace), deliverableName)
outContent, _ := os.ReadFile(outPath)
bb, _ := d.LoadBlackboard(wsDir)
evalRes, err := d.evaluator.EvaluateStepOutput(wsDir, step, string(outContent), bb)
if err != nil || evalRes == nil {
return
}
mu.Lock()
defer mu.Unlock()
// 1. 合并新推断的基准假设
if len(evalRes.NewAssumptions) > 0 && bb != nil {
for _, na := range evalRes.NewAssumptions {
exists := false
for _, ea := range bb.Assumptions {
if ea.Topic == na.Topic {
exists = true
break
}
}
if !exists {
bb.Assumptions = append(bb.Assumptions, na)
}
}
_ = d.SaveBlackboard(wsDir, bb)
}
// 2. 处理动态跳步
if len(evalRes.StepsToSkip) > 0 {
for _, skipID := range evalRes.StepsToSkip {
target := strings.ToLower(skipID)
for idx := range wf.Steps {
curID := strings.ToLower(wf.Steps[idx].ID)
curRole := strings.ToLower(wf.Steps[idx].Role)
isMatch := (curID == target || curRole == target || strings.Contains(curID, target) || strings.Contains(target, curID))
if isMatch && (wf.Steps[idx].Status == "pending" || wf.Steps[idx].Status == "") {
wf.Steps[idx].Status = "skipped"
if notify != nil {
notify(StepEvent{
StepID: wf.Steps[idx].ID,
Role: wf.Steps[idx].Role,
Title: wf.Steps[idx].Title,
Status: "skipped",
})
}
}
}
}
_ = d.SaveWorkflow(wsDir, wf)
}
// 3. 处理动态加步
if len(evalRes.DynamicStepsToAdd) > 0 {
squadCfg, _ := d.loadSquadConfig(wsDir)
tmpl, _ := os.ReadFile(filepath.Join(wsDir, "template.md"))
for _, addStep := range evalRes.DynamicStepsToAdd {
alreadyExists := false
for _, s := range wf.Steps {
if s.ID == addStep.ID {
alreadyExists = true
break
}
}
if alreadyExists {
continue
}
if addStep.SubWorkspace == "" {
addStep.SubWorkspace = fmt.Sprintf("tasks/%02d_%s", len(wf.Steps)+1, addStep.Role)
}
if addStep.Status == "" {
addStep.Status = "pending"
}
if addStep.Mode == "" {
addStep.Mode = "execute"
}
// 构建子沙箱
_ = d.engine.buildSubWorkspaces(wsDir, WorkflowPlan{Steps: []StepSpec{addStep}}, squadCfg, string(tmpl))
// 插入在 writer 或 leader 之前,确保专家成果在成文前产生
insertIdx := len(wf.Steps)
for idx, s := range wf.Steps {
if s.Role == "writer" || s.Role == "leader" {
insertIdx = idx
break
}
}
newSteps := make([]StepSpec, 0, len(wf.Steps)+1)
newSteps = append(newSteps, wf.Steps[:insertIdx]...)
newSteps = append(newSteps, addStep)
newSteps = append(newSteps, wf.Steps[insertIdx:]...)
wf.Steps = newSteps
}
_ = d.SaveWorkflow(wsDir, wf)
}
}
func (d *WorkflowDriver) resolveFinalDeliverable(wsDir string, wf *WorkflowPlan) ([]byte, string) {
// 查找优先级:leader 的 output.md > writer 的 output.md > 最后一个步骤的 output.md
for i := len(wf.Steps) - 1; i >= 0; i-- {
+13 -3
View File
@@ -98,11 +98,21 @@ func (e *WorkspaceEngine) PrepareWorkspace(req WorkspacePrepareRequest) (*Worksp
var wf WorkflowPlan
tmplContent := req.TemplateContent
if len(req.CustomAgents) > 0 && len(req.WorkflowSteps) > 0 {
if len(req.WorkflowSteps) > 0 {
agents := req.CustomAgents
if len(agents) == 0 {
agents = DefaultSquadAgents(req.TaskKey)
}
squadCfg = SquadConfig{
SquadName: fmt.Sprintf("%s_squad", req.TaskKey),
Version: "1.0.0",
Agents: req.CustomAgents,
Agents: agents,
}
steps := req.WorkflowSteps
for idx := range steps {
if steps[idx].Status == "" {
steps[idx].Status = "pending"
}
}
wf = WorkflowPlan{
WorkflowID: fmt.Sprintf("wf_%s_%d", req.TaskKey, now.UnixNano()),
@@ -110,7 +120,7 @@ func (e *WorkspaceEngine) PrepareWorkspace(req WorkspacePrepareRequest) (*Worksp
Title: req.StageTitle,
Status: "pending",
CurrentStepIndex: 0,
Steps: req.WorkflowSteps,
Steps: steps,
}
} else {
planner := NewAdaptivePlanner(nil)