Files
eightbf-analysis-go/internal/worker/live_runner.go
T
gozerosandCursor 0c45b86d67 chore: 初始化 eightbf-analysis-go(对齐 Java 会商作业服务)
Go 实现 /api/v1/analysis 契约、Demo/Live Worker、DeerFlow SSE;含设计文档与冒烟脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 00:13:46 +08:00

624 lines
16 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 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
}