Go 实现 /api/v1/analysis 契约、Demo/Live Worker、DeerFlow SSE;含设计文档与冒烟脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
624 lines
16 KiB
Go
624 lines
16 KiB
Go
package worker
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/deerflow"
|
||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
|
||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
|
||
)
|
||
|
||
// LiveChainRunner 真模型按链条顺序执行(被 LiveWorker 异步调用)。
|
||
type LiveChainRunner struct {
|
||
DF *deerflow.Client
|
||
Repo *repo.JobRepo
|
||
Coord string
|
||
|
||
mu sync.Mutex
|
||
lastFlush time.Time
|
||
}
|
||
|
||
func (r *LiveChainRunner) Run(job *domain.AnalysisJob) {
|
||
jobID := job.ID
|
||
defer func() {
|
||
if rec := recover(); rec != nil {
|
||
log.Printf("真模型会商 panic jobId=%s: %v", jobID, rec)
|
||
r.fail(job, fmt.Sprint(rec))
|
||
}
|
||
}()
|
||
|
||
agents := job.Agents
|
||
if len(agents) < 2 {
|
||
r.fail(job, "有效席位不足 2 个,无法启动真模型会商")
|
||
return
|
||
}
|
||
var agentNames []string
|
||
for _, a := range agents {
|
||
id := asStr(a["id"])
|
||
if id != "" {
|
||
agentNames = append(agentNames, id)
|
||
}
|
||
}
|
||
if len(agentNames) < 2 {
|
||
r.fail(job, "席位 agentId 无效(需要 DeerFlow 的 hyphen-case id)")
|
||
return
|
||
}
|
||
|
||
plan := ensurePlan(job)
|
||
r.pullModelFromDB(job)
|
||
if err := r.DF.AssertModelAvailable(job.Model); err != nil {
|
||
r.fail(job, err.Error())
|
||
return
|
||
}
|
||
job.Status = domain.StatusRunning
|
||
job.Phase = "running"
|
||
r.flush(job)
|
||
|
||
if r.cancelled(jobID) {
|
||
r.markCancelled(job)
|
||
return
|
||
}
|
||
|
||
threadIDs := loadThreadIDs(job)
|
||
if len(threadIDs) == 0 {
|
||
putProgress(job, 2, "正在初始化 DeerFlow 席位线程…")
|
||
r.flush(job)
|
||
r.pullModelFromDB(job)
|
||
tids, err := r.DF.InitMultiAgent(agentNames, r.Coord, job.Model)
|
||
if err != nil {
|
||
r.fail(job, err.Error())
|
||
return
|
||
}
|
||
threadIDs = tids
|
||
store := map[string]interface{}{}
|
||
for k, v := range tids {
|
||
store[k] = v
|
||
}
|
||
job.ThreadIDs = store
|
||
putProgress(job, 8, "席位线程已就绪,开始链条研讨")
|
||
r.flush(job)
|
||
} else {
|
||
pct := toInt(job.Progress["percent"], 8)
|
||
if pct < 8 {
|
||
pct = 8
|
||
}
|
||
putProgress(job, pct, "从检查点继续会商")
|
||
r.flush(job)
|
||
}
|
||
|
||
seed := asStr(plan["seedMessage"])
|
||
if seed == "" {
|
||
seed = BuildSeed(job.Progress)
|
||
plan["seedMessage"] = seed
|
||
}
|
||
if inj := consumePendingInject(plan); inj != "" {
|
||
seed = seed + "\n\n" + inj
|
||
plan["seedMessage"] = seed
|
||
}
|
||
|
||
total := len(agentNames)
|
||
start := toInt(plan["seatIndex"], 0)
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
seatDeliveries := loadDeliveries(job)
|
||
lastLeader := ""
|
||
|
||
for i := start; i < total; i++ {
|
||
if r.cancelled(jobID) {
|
||
r.markCancelled(job)
|
||
return
|
||
}
|
||
r.pullManualFromDB(job, plan)
|
||
r.pullInjectFromDB(job, plan)
|
||
r.pullModelFromDB(job)
|
||
model := job.Model
|
||
if mid := consumePendingInject(plan); mid != "" {
|
||
seed = seed + "\n\n" + mid
|
||
plan["seedMessage"] = seed
|
||
}
|
||
plan["seatIndex"] = i
|
||
job.Plan = plan
|
||
|
||
seat := agents[i]
|
||
seatID := asStr(seat["id"])
|
||
seatName := firstNonEmpty(asStr(seat["name"]), seatID)
|
||
job.ActiveAgentID = r.Coord
|
||
job.Cycle = i + 1
|
||
applyAgentSpeaking(agents, seatID, false)
|
||
job.Agents = agents
|
||
|
||
leaderBubbleID := openBubble(job, "leader", "总控协调", r.Coord, "链条派活中")
|
||
r.flush(job)
|
||
leaderRes, err := r.DF.RunStream("leader", threadIDs, r.Coord, "总控协调",
|
||
BuildLeaderTask(seed, i, total, seatID, seatName, i == 0),
|
||
model, false,
|
||
r.deltaHandler(job, leaderBubbleID),
|
||
func() bool { return r.cancelled(jobID) },
|
||
)
|
||
if err != nil {
|
||
finishBubble(job, leaderBubbleID, err.Error(), "error")
|
||
r.fail(job, buildModelHint("总控派活失败", model, err.Error()))
|
||
return
|
||
}
|
||
if r.cancelled(jobID) || leaderRes.Aborted {
|
||
finishBubble(job, leaderBubbleID, leaderRes.Content, "stopped")
|
||
r.markCancelled(job)
|
||
return
|
||
}
|
||
if leaderRes.Clarification {
|
||
finishBubble(job, leaderBubbleID, leaderRes.Content, "clarification")
|
||
detail := leaderRes.ClarificationDetail
|
||
if detail == "" {
|
||
detail = leaderRes.Content
|
||
}
|
||
r.pauseForInput(job, plan, i, "clarification", detail)
|
||
return
|
||
}
|
||
leaderBad := leaderRes.InvalidDelivery || strings.TrimSpace(leaderRes.Content) == "" || looksLikeModelFailure(leaderRes.Content)
|
||
if leaderBad {
|
||
finishBubble(job, leaderBubbleID, leaderRes.Content, "error")
|
||
r.fail(job, buildModelHint("总控派活失败", model, leaderRes.Content))
|
||
return
|
||
}
|
||
finishBubble(job, leaderBubbleID, leaderRes.Content, "done")
|
||
lastLeader = leaderRes.Content
|
||
r.flush(job)
|
||
|
||
task := PickChainTask(leaderRes.Dispatched, leaderRes.Content, seatID, seatName)
|
||
job.ActiveAgentID = seatID
|
||
applyAgentSpeaking(agents, seatID, true)
|
||
job.Agents = agents
|
||
seatBubbleID := openBubble(job, "special", seatName, seatID, "席位输出中…")
|
||
r.flush(job)
|
||
seatRes, err := r.DF.RunStream("special", threadIDs, seatID, seatName, task, model, false,
|
||
r.deltaHandler(job, seatBubbleID),
|
||
func() bool { return r.cancelled(jobID) },
|
||
)
|
||
if err != nil {
|
||
finishBubble(job, seatBubbleID, err.Error(), "error")
|
||
r.fail(job, buildModelHint("席位「"+seatName+"」输出失败", model, err.Error()))
|
||
return
|
||
}
|
||
if r.cancelled(jobID) || seatRes.Aborted {
|
||
finishBubble(job, seatBubbleID, seatRes.Content, "stopped")
|
||
r.markCancelled(job)
|
||
return
|
||
}
|
||
bad := seatRes.InvalidDelivery || strings.TrimSpace(seatRes.Content) == "" || looksLikeModelFailure(seatRes.Content)
|
||
status := "done"
|
||
if bad {
|
||
status = "error"
|
||
}
|
||
finishBubble(job, seatBubbleID, seatRes.Content, status)
|
||
if bad {
|
||
r.fail(job, buildModelHint("席位「"+seatName+"」输出失败", model, seatRes.Content))
|
||
return
|
||
}
|
||
seatDeliveries = append(seatDeliveries, map[string]interface{}{
|
||
"sender": seatName,
|
||
"agentId": seatID,
|
||
"content": seatRes.Content,
|
||
})
|
||
saveDeliveries(job, seatDeliveries)
|
||
pct := (i + 1) * 85 / total
|
||
if pct > 90 {
|
||
pct = 90
|
||
}
|
||
job.ConsensusPercentage = pct
|
||
putProgress(job, pct, fmt.Sprintf("已完成 %d/%d 席", i+1, total))
|
||
plan["seatIndex"] = i + 1
|
||
job.Plan = plan
|
||
r.flush(job)
|
||
|
||
r.pullManualFromDB(job, plan)
|
||
if readManual(plan) && i+1 < total {
|
||
r.pauseForInput(job, plan, i+1, "manual_continue",
|
||
fmt.Sprintf("人工干预:第 %d/%d 席已完成,请确认后继续下一席。", i+1, total))
|
||
return
|
||
}
|
||
}
|
||
|
||
if r.cancelled(jobID) {
|
||
r.markCancelled(job)
|
||
return
|
||
}
|
||
job.ActiveAgentID = r.Coord
|
||
r.pullModelFromDB(job)
|
||
closeModel := job.Model
|
||
closeID := openBubble(job, "leader", "总控协调", r.Coord, "全案盖印")
|
||
r.flush(job)
|
||
closeRes, err := r.DF.RunStream("leader", threadIDs, r.Coord, "总控协调", Closeout, closeModel, true,
|
||
r.deltaHandler(job, closeID),
|
||
func() bool { return r.cancelled(jobID) },
|
||
)
|
||
if err != nil {
|
||
finishBubble(job, closeID, err.Error(), "error")
|
||
r.fail(job, buildModelHint("全案收口失败", closeModel, err.Error()))
|
||
return
|
||
}
|
||
if r.cancelled(jobID) || closeRes.Aborted {
|
||
finishBubble(job, closeID, closeRes.Content, "stopped")
|
||
r.markCancelled(job)
|
||
return
|
||
}
|
||
if closeRes.InvalidDelivery || looksLikeModelFailure(closeRes.Content) ||
|
||
(strings.TrimSpace(closeRes.Content) == "" && strings.TrimSpace(lastLeader) == "") {
|
||
finishBubble(job, closeID, closeRes.Content, "error")
|
||
r.fail(job, buildModelHint("全案收口失败", closeModel, closeRes.Content))
|
||
return
|
||
}
|
||
if len(seatDeliveries) < total {
|
||
finishBubble(job, closeID, closeRes.Content, "error")
|
||
r.fail(job, fmt.Sprintf("会商未完整交付(成功 %d/%d 席),请检查模型后重新开始。", len(seatDeliveries), total))
|
||
return
|
||
}
|
||
if dialoguesHaveError(job) {
|
||
finishBubble(job, closeID, closeRes.Content, "error")
|
||
r.fail(job, "会商过程中已有失败发言,不能进入收口/下一阶段。请换可用模型后重新开始。")
|
||
return
|
||
}
|
||
summary := closeRes.Content
|
||
if strings.TrimSpace(summary) == "" {
|
||
summary = lastLeader
|
||
}
|
||
finishBubble(job, closeID, summary, "done")
|
||
for _, a := range agents {
|
||
a["status"] = "done"
|
||
}
|
||
job.Agents = agents
|
||
job.ActiveAgentID = ""
|
||
job.Status = domain.StatusDone
|
||
job.Phase = "done"
|
||
job.ConsensusPercentage = 100
|
||
putProgress(job, 100, "completed")
|
||
job.Result = map[string]interface{}{
|
||
"summary": summary,
|
||
"consensusPercentage": 100,
|
||
"seatDeliveries": seatDeliveries,
|
||
}
|
||
plan["awaitReason"] = nil
|
||
job.Plan = plan
|
||
r.flush(job)
|
||
log.Printf("真模型会商完成 jobId=%s", jobID)
|
||
}
|
||
|
||
func (r *LiveChainRunner) pauseForInput(job *domain.AnalysisJob, plan map[string]interface{}, next int, reason, detail string) {
|
||
plan["seatIndex"] = next
|
||
plan["awaitReason"] = reason
|
||
job.Plan = plan
|
||
job.Status = domain.StatusAwaitingInput
|
||
job.Phase = reason
|
||
job.PendingClarification = detail
|
||
msg := "等待用户澄清"
|
||
if reason == "manual_continue" {
|
||
msg = "等待人工确认继续"
|
||
}
|
||
putProgress(job, job.ConsensusPercentage, msg)
|
||
r.flush(job)
|
||
log.Printf("作业暂停等待输入 jobId=%s reason=%s", job.ID, reason)
|
||
}
|
||
|
||
func (r *LiveChainRunner) deltaHandler(job *domain.AnalysisJob, bubbleID string) deerflow.TextDeltaHandler {
|
||
return func(delta string) error {
|
||
appendBubble(job, bubbleID, delta)
|
||
r.throttleFlush(job)
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func (r *LiveChainRunner) throttleFlush(job *domain.AnalysisJob) {
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
if time.Since(r.lastFlush) >= 150*time.Millisecond {
|
||
r.flushLocked(job)
|
||
}
|
||
}
|
||
|
||
func (r *LiveChainRunner) flush(job *domain.AnalysisJob) {
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
r.flushLocked(job)
|
||
}
|
||
|
||
func (r *LiveChainRunner) flushLocked(job *domain.AnalysisJob) {
|
||
plan := ensurePlan(job)
|
||
r.pullManualFromDB(job, plan)
|
||
r.pullModelFromDB(job)
|
||
injectConsumed := plan["_injectConsumed"] == true
|
||
if !injectConsumed {
|
||
if strings.TrimSpace(asStr(plan["pendingInject"])) == "" {
|
||
r.pullInjectFromDB(job, plan)
|
||
}
|
||
}
|
||
delete(plan, "_injectConsumed")
|
||
job.Plan = plan
|
||
_ = r.Repo.UpdateProgress(job)
|
||
r.lastFlush = time.Now()
|
||
}
|
||
|
||
func (r *LiveChainRunner) cancelled(jobID string) bool {
|
||
ok, err := r.Repo.IsCancelFlag(jobID)
|
||
return err == nil && ok
|
||
}
|
||
|
||
func (r *LiveChainRunner) markCancelled(job *domain.AnalysisJob) {
|
||
job.Status = domain.StatusCancelled
|
||
job.Phase = "cancelled"
|
||
job.Error = "cancelled"
|
||
putProgress(job, job.ConsensusPercentage, "cancelled")
|
||
r.flush(job)
|
||
}
|
||
|
||
func (r *LiveChainRunner) fail(job *domain.AnalysisJob, message string) {
|
||
job.Status = domain.StatusError
|
||
job.Phase = "error"
|
||
job.Error = message
|
||
job.ConsensusPercentage = 0
|
||
if job.Result != nil {
|
||
delete(job.Result, "summary")
|
||
job.Result["consensusPercentage"] = 0
|
||
job.Result["failed"] = true
|
||
}
|
||
putProgress(job, 0, "error")
|
||
r.flush(job)
|
||
}
|
||
|
||
func (r *LiveChainRunner) pullModelFromDB(job *domain.AnalysisJob) {
|
||
fresh, err := r.Repo.FindByID(job.ID)
|
||
if err != nil || fresh == nil {
|
||
return
|
||
}
|
||
job.Model = fresh.Model
|
||
}
|
||
|
||
func (r *LiveChainRunner) pullManualFromDB(job *domain.AnalysisJob, plan map[string]interface{}) {
|
||
fresh, err := r.Repo.FindByID(job.ID)
|
||
if err != nil || fresh == nil || fresh.Plan == nil {
|
||
return
|
||
}
|
||
if v, ok := fresh.Plan["manualIntervention"]; ok {
|
||
plan["manualIntervention"] = v
|
||
}
|
||
}
|
||
|
||
func (r *LiveChainRunner) pullInjectFromDB(job *domain.AnalysisJob, plan map[string]interface{}) {
|
||
fresh, err := r.Repo.FindByID(job.ID)
|
||
if err != nil || fresh == nil || fresh.Plan == nil {
|
||
return
|
||
}
|
||
dbText := strings.TrimSpace(asStr(fresh.Plan["pendingInject"]))
|
||
if dbText == "" {
|
||
return
|
||
}
|
||
local := strings.TrimSpace(asStr(plan["pendingInject"]))
|
||
if local == "" {
|
||
plan["pendingInject"] = dbText
|
||
} else if !strings.Contains(local, dbText) {
|
||
plan["pendingInject"] = local + "\n\n" + dbText
|
||
}
|
||
}
|
||
|
||
func consumePendingInject(plan map[string]interface{}) string {
|
||
s := strings.TrimSpace(asStr(plan["pendingInject"]))
|
||
plan["pendingInject"] = nil
|
||
plan["_injectConsumed"] = true
|
||
return s
|
||
}
|
||
|
||
func ensurePlan(job *domain.AnalysisJob) map[string]interface{} {
|
||
p := job.EnsurePlan()
|
||
if _, ok := p["seatIndex"]; !ok {
|
||
p["seatIndex"] = 0
|
||
}
|
||
if _, ok := p["manualIntervention"]; !ok {
|
||
p["manualIntervention"] = true
|
||
}
|
||
return p
|
||
}
|
||
|
||
func putProgress(job *domain.AnalysisJob, percent int, message string) {
|
||
p := job.EnsureProgress()
|
||
p["percent"] = percent
|
||
p["message"] = message
|
||
}
|
||
|
||
func loadThreadIDs(job *domain.AnalysisJob) map[string]string {
|
||
out := map[string]string{}
|
||
for k, v := range job.ThreadIDs {
|
||
if v != nil {
|
||
out[k] = fmt.Sprint(v)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func loadDeliveries(job *domain.AnalysisJob) []map[string]interface{} {
|
||
if job.Result == nil {
|
||
return nil
|
||
}
|
||
raw, ok := job.Result["seatDeliveries"]
|
||
if !ok {
|
||
return nil
|
||
}
|
||
switch t := raw.(type) {
|
||
case []map[string]interface{}:
|
||
return t
|
||
case []interface{}:
|
||
var out []map[string]interface{}
|
||
for _, o := range t {
|
||
if m, ok := o.(map[string]interface{}); ok {
|
||
out = append(out, m)
|
||
}
|
||
}
|
||
return out
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func saveDeliveries(job *domain.AnalysisJob, list []map[string]interface{}) {
|
||
if job.Result == nil {
|
||
job.Result = map[string]interface{}{}
|
||
}
|
||
job.Result["seatDeliveries"] = list
|
||
}
|
||
|
||
func readManual(plan map[string]interface{}) bool {
|
||
if plan == nil {
|
||
return true
|
||
}
|
||
v, ok := plan["manualIntervention"]
|
||
if !ok || v == nil {
|
||
return true
|
||
}
|
||
switch t := v.(type) {
|
||
case bool:
|
||
return t
|
||
default:
|
||
return !strings.EqualFold(strings.TrimSpace(fmt.Sprint(t)), "false")
|
||
}
|
||
}
|
||
|
||
func openBubble(job *domain.AnalysisJob, role, sender, agentID, phase string) string {
|
||
list := job.EnsureDialogues()
|
||
id := fmt.Sprintf("dlg_%s_%d", job.ID, len(list)+1)
|
||
job.Dialogues = append(list, map[string]interface{}{
|
||
"id": id, "role": role, "sender": sender, "agentId": agentID,
|
||
"content": "", "status": "streaming", "phaseLabel": phase,
|
||
})
|
||
return id
|
||
}
|
||
|
||
func appendBubble(job *domain.AnalysisJob, bubbleID, delta string) {
|
||
if delta == "" {
|
||
return
|
||
}
|
||
for _, b := range job.Dialogues {
|
||
if asStr(b["id"]) == bubbleID {
|
||
b["content"] = asStr(b["content"]) + delta
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func finishBubble(job *domain.AnalysisJob, bubbleID, finalContent, status string) {
|
||
for _, b := range job.Dialogues {
|
||
if asStr(b["id"]) != bubbleID {
|
||
continue
|
||
}
|
||
if strings.TrimSpace(finalContent) != "" {
|
||
prev := asStr(b["content"])
|
||
b["content"] = mergeKeepThinking(prev, finalContent)
|
||
}
|
||
if status == "" {
|
||
status = "done"
|
||
}
|
||
b["status"] = status
|
||
b["phaseLabel"] = ""
|
||
return
|
||
}
|
||
}
|
||
|
||
func mergeKeepThinking(streamed, finalContent string) string {
|
||
if strings.Contains(finalContent, "<think>") || strings.Contains(finalContent, "</think>") {
|
||
return finalContent
|
||
}
|
||
open := strings.Index(streamed, "<think>")
|
||
if open < 0 {
|
||
return finalContent
|
||
}
|
||
close := strings.LastIndex(streamed, "</think>")
|
||
if close >= open {
|
||
return streamed[:close+len("</think>")] + "\n" + finalContent
|
||
}
|
||
return streamed + "</think>\n" + finalContent
|
||
}
|
||
|
||
func applyAgentSpeaking(agents []map[string]interface{}, speakingID string, seatTurn bool) {
|
||
for _, a := range agents {
|
||
id := asStr(a["id"])
|
||
if seatTurn && id == speakingID {
|
||
a["status"] = "speaking"
|
||
} else {
|
||
a["status"] = "listening"
|
||
}
|
||
}
|
||
}
|
||
|
||
func dialoguesHaveError(job *domain.AnalysisJob) bool {
|
||
for _, b := range job.Dialogues {
|
||
if strings.EqualFold(asStr(b["status"]), "error") {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func looksLikeModelFailure(content string) bool {
|
||
t := strings.TrimSpace(content)
|
||
if t == "" {
|
||
return false
|
||
}
|
||
lower := strings.ToLower(t)
|
||
needles := []string{"model_not_found", "invalid model", "does not exist", "no such model",
|
||
"litellm", "aperror", "apierror", "authenticationerror", "rate_limit", "context_length"}
|
||
for _, n := range needles {
|
||
if strings.Contains(lower, n) {
|
||
return true
|
||
}
|
||
}
|
||
cn := []string{"大模型本次调用未能成功完成", "请稍后再试或更换模型", "大模型因鉴权失败",
|
||
"大模型服务暂时繁忙", "大模型响应暂时中断", "大模型当前响应异常", "已被熔断保护",
|
||
"已拒绝自动切换", "模型不存在", "无可用模型", "模型不可用", "鉴权失败"}
|
||
for _, n := range cn {
|
||
if strings.Contains(t, n) {
|
||
return true
|
||
}
|
||
}
|
||
if utf8.RuneCountInString(t) < 400 && len(t) >= 5 && t[0] == '[' && t[4] == ']' {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func buildModelHint(head, model, detail string) string {
|
||
sb := head
|
||
if strings.TrimSpace(model) != "" {
|
||
sb += "(当前模型:" + strings.TrimSpace(model) + ")"
|
||
}
|
||
sb += "。请换可用模型后重新开始会商。"
|
||
d := strings.TrimSpace(detail)
|
||
if d != "" {
|
||
if utf8.RuneCountInString(d) > 180 {
|
||
rs := []rune(d)
|
||
d = string(rs[:180]) + "…"
|
||
}
|
||
sb += " 详情:" + d
|
||
}
|
||
return sb
|
||
}
|
||
|
||
func toInt(v interface{}, def int) int {
|
||
switch t := v.(type) {
|
||
case int:
|
||
return t
|
||
case int64:
|
||
return int(t)
|
||
case float64:
|
||
return int(t)
|
||
case string:
|
||
var n int
|
||
_, err := fmt.Sscanf(t, "%d", &n)
|
||
if err == nil {
|
||
return n
|
||
}
|
||
}
|
||
return def
|
||
}
|