Files
sundynix-agentix/sundynix-dispatcher/internal/eino/report.go
T
Blizzard de36ed4cb3 fix(dispatcher): Map 节点错误传播 —— 并行子项失败不再静默(T4.E)
- writeSection 返回 (body, error):仅真·LLM 调用失败返 err;预算触顶/模型未配置
  是主动降级(可见降级正文,err=nil)不计失败
- writeSections 返回 ([]section, failed):失败项 Body 带可见「撰写失败」标记 + 汇总失败数
- mapNode:全部子项失败 → 置 b.fatalErr(任务判 failed 而非静默 done-空);
  部分失败 → trace span + 流式 ⚠️ 告警
- report handleReport:部分章节失败时流式提示,不再当全成功
- 3 单测:全失败/部分精确计数(=1 非 all-or-nothing)/mapNode 置 fatalErr

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 09:56:26 +08:00

332 lines
12 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 eino
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/sundynix/sundynix-dispatcher/internal/dsl"
"github.com/sundynix/sundynix-dispatcher/internal/harness"
"github.com/sundynix/sundynix-dispatcher/internal/llm"
"github.com/sundynix/sundynix-shared/contract"
)
// 报告生成的并发与超时参数。
const (
reportFanout = 4 // 章节并行撰写的最大并发
reportRenderWait = 12 * time.Second // 渲染 docx 的等待上限
reportLLMTimeout = 60 * time.Second // 单次 LLM 调用(规划/撰写一章)的超时上限
)
// llmCtx 给单次报告 LLM 调用套超时,避免个别请求挂死拖垮整篇(曾遇连接累积卡死)。
// 超时即 cancel → 底层 http 请求中断 → Chat 返回错误 → 调用方走兜底,不无限等待。
func llmCtx(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, reportLLMTimeout)
}
// reportOutline 是规划阶段产出的大纲。
type reportOutline struct {
Title string `json:"title"`
Sections []string `json:"sections"`
}
// reportSection 是一章成稿(标题 + 正文)。
type reportSection struct {
Heading string `json:"heading"`
Body string `json:"body"`
}
// handleReport 执行报告生成的专用多步编排:
//
// 规划大纲 → 各章节并行(RAG 检索 + LLM 撰写) → 汇聚 → 渲染 Word(.docx) → 回流进度与正文
//
// 全程把人可读的 Markdown 进度与正文经 sundynix.streams.<id> 流回客户端;
// 最终调 mcp-go 的 report_render 落盘 docx,客户端凭 task_id 下载。
func (o *Orchestrator) handleReport(ctx context.Context, t *contract.Task, tr *execTracer) error {
defer func() { _ = o.sink.CompleteStream(t.ID) }()
topic, _ := t.Meta[contract.MetaTopic].(string)
kb, _ := t.Meta[contract.MetaKB].(string)
if topic == "" {
topic = dsl.Compile(t.Graph).Query // 兜底:从 DSL 取用户输入
}
if topic == "" {
topic = "未命名报告"
}
log.Printf("[report] task %s 生成报告: topic=%q kb=%q", t.ID, topic, kb)
tr.info("task", "system", "报告任务受理", fmt.Sprintf("主题:%s%s", topic, kbSuffix(kb)))
o.emit(t.ID, "> 正在规划大纲…\n\n")
endPlan := tr.span("plan", "plan", "规划大纲")
outline := o.planOutline(ctx, topic)
endPlan(fmt.Sprintf("%d 章:%s", len(outline.Sections), strings.Join(outline.Sections, " / ")), nil)
o.emit(t.ID, fmt.Sprintf("**报告大纲**%d 章)\n", len(outline.Sections)))
for i, s := range outline.Sections {
o.emit(t.ID, fmt.Sprintf("%d. %s\n", i+1, s))
}
if kb != "" {
o.emit(t.ID, fmt.Sprintf("\n> 正在并行检索知识库 %q 资料并撰写各章…\n\n", kb))
} else {
o.emit(t.ID, "\n> 正在并行撰写各章…\n\n")
}
sections, failed := o.writeSections(ctx, topic, kb, outline.Sections, tr)
if failed > 0 {
note := fmt.Sprintf("⚠️ %d/%d 章撰写失败(下方已标注),其余照常成稿。", failed, len(sections))
tr.info("plan", "system", "部分章节失败", note)
o.emit(t.ID, "\n> "+note+"\n\n")
}
// 把完整报告正文流式呈现给客户端。
o.emit(t.ID, "\n---\n\n# "+firstNonEmpty(outline.Title, topic)+"\n\n")
for _, s := range sections {
o.emit(t.ID, "## "+s.Heading+"\n\n"+s.Body+"\n\n")
}
// 只持久化报告源数据(标题+章节),不在生成阶段渲染;导出时再按需出 Word/PDF/Markdown。
endStore := tr.span("store", "render", "保存报告源")
if o.storeReport(ctx, t.ID, firstNonEmpty(outline.Title, topic), sections) {
endStore("已保存,可按需导出 Word/PDF/Markdown", nil)
o.emit(t.ID, "---\n✅ 报告正文已生成,可在上方导出 **Word / PDF / Markdown**。\n")
log.Printf("[report] task %s 完成,源已存", t.ID)
} else {
endStore("源保存失败", fmt.Errorf("store unavailable"))
o.emit(t.ID, "---\n⚠️ 报告源保存失败(导出可能不可用),以上为报告正文。\n")
}
o.breaker.Report(true)
return nil
}
func kbSuffix(kb string) string {
if kb == "" {
return "(不挂知识库)"
}
return ",知识库 " + kb
}
// planOutline 让模型规划 3–5 章大纲;模型不可用/解析失败则用通用兜底大纲。
func (o *Orchestrator) planOutline(ctx context.Context, topic string) reportOutline {
fallback := reportOutline{Title: topic, Sections: []string{"背景与现状", "核心分析", "结论与建议"}}
if !o.pool.Ready() {
return fallback
}
sys := "你是资深报告撰稿人,擅长搭建清晰的报告结构。"
user := fmt.Sprintf("请为主题《%s》规划一份报告大纲。"+
"只输出 JSON{\"title\":\"报告标题\",\"sections\":[\"章节标题\", ...]}3 到 5 章,不要任何多余文字。", topic)
cctx, cancel := llmCtx(ctx)
defer cancel()
txt, err := o.pool.Chat(cctx, []llm.ChatMessage{{Role: "system", Content: sys}, {Role: "user", Content: user}})
if err != nil {
log.Printf("[report] 规划大纲失败,用兜底大纲: %v", err)
return fallback
}
var out reportOutline
if json.Unmarshal([]byte(stripFence(txt)), &out) != nil || len(out.Sections) == 0 {
log.Printf("[report] 大纲 JSON 解析失败,用兜底大纲。原文: %s", truncate(txt, 200))
return fallback
}
if out.Title == "" {
out.Title = topic
}
return out
}
// planItems 为 map 节点把主题拆成一组并行子项(splitBy 为拆分依据提示)。
// 模型不可用/解析失败则用通用兜底分项。
func (o *Orchestrator) planItems(ctx context.Context, topic, splitBy string) []string {
fallback := []string{"背景与现状", "核心分析", "结论与建议"}
if !o.pool.Ready() {
return fallback
}
hint := splitBy
if hint == "" {
hint = "合理的章节"
}
user := fmt.Sprintf("请把主题《%s》拆分为一组「%s」,用于并行撰写。"+
"只输出 JSON 数组:[\"子项1\",\"子项2\", ...]3 到 6 项,不要任何多余文字。", topic, hint)
cctx, cancel := llmCtx(ctx)
defer cancel()
txt, err := o.pool.Chat(cctx, []llm.ChatMessage{
{Role: "system", Content: "你擅长把一个任务拆解为可并行处理的若干子项。"},
{Role: "user", Content: user},
})
if err != nil {
log.Printf("[map] 拆分子项失败,用兜底: %v", err)
return fallback
}
var items []string
if json.Unmarshal([]byte(stripFence(txt)), &items) != nil || len(items) == 0 {
log.Printf("[map] 子项 JSON 解析失败,用兜底。原文: %s", truncate(txt, 200))
return fallback
}
return items
}
// writeSections 各章节并行撰写(有界并发),结果按原顺序返回;第二返回值为失败项数。
// 失败项的 Body 会带明确的失败标记(可见,不静默),失败计数供上游汇总/判定整体成败。
func (o *Orchestrator) writeSections(ctx context.Context, topic, kb string, headings []string, tr *execTracer) ([]reportSection, int) {
out := make([]reportSection, len(headings))
sem := make(chan struct{}, reportFanout)
var wg sync.WaitGroup
var failed atomic.Int64
for i, h := range headings {
wg.Add(1)
go func(i int, h string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
node := fmt.Sprintf("section:%d", i)
end := tr.span(node, "section", fmt.Sprintf("第%d章 %s", i+1, h))
body, err := o.writeSection(ctx, topic, kb, h, tr, node)
if err != nil {
failed.Add(1)
end("撰写失败:"+err.Error(), err) // trace 记为该 section 失败(非静默)
} else {
end(fmt.Sprintf("成稿 %d 字", len([]rune(body))), nil)
}
out[i] = reportSection{Heading: h, Body: body}
}(i, h)
}
wg.Wait()
return out, int(failed.Load())
}
// writeSection 撰写一章:先 RAG 检索参考资料(若挂了知识库),再让模型成稿。
// 返回 (正文, 失败错误):仅「真·LLM 调用失败」返回非 nil err;预算触顶 / 模型未配置是
// 主动降级(出可见的降级正文、err=nil),不计入失败。
func (o *Orchestrator) writeSection(ctx context.Context, topic, kb, heading string, tr *execTracer, node string) (string, error) {
refs := o.retrieve(ctx, kb, topic+" "+heading)
if refs != "" {
tr.info(node, "section", "检索参考资料", truncate(strings.ReplaceAll(refs, "\n", " "), 120))
}
if !o.pool.Ready() {
if refs != "" {
return "(模型未配置,以下为检索到的参考资料)\n" + refs, nil
}
return "(模型未配置,无法撰写本章。)", nil
}
sys := "你是专业报告撰稿人,语言严谨、条理清晰,使用中文书面语。"
var ub strings.Builder
fmt.Fprintf(&ub, "报告主题:%s\n本章标题:%s\n", topic, heading)
if refs != "" {
ub.WriteString("可参考的资料(来自知识库检索,请甄别采用,不要照搬):\n")
ub.WriteString(refs)
ub.WriteString("\n")
}
ub.WriteString("请就「本章标题」撰写 200–400 字正文。只输出正文,不要重复标题、不要再列提纲。")
// 成本护栏:计入输入;若已触顶则跳过本章(报告优雅降级出部分稿,不整体失败)。
if bud := harness.BudgetFrom(ctx); bud != nil {
bud.AddPrompt(sys + ub.String())
if bud.Exceeded() {
tr.info(node, "system", "token 预算", "已达预算上限,跳过本章")
return "(已达 token 预算上限,本章自动跳过。)", nil
}
}
cctx, cancel := llmCtx(ctx)
defer cancel()
txt, err := o.pool.Chat(cctx, []llm.ChatMessage{{Role: "system", Content: sys}, {Role: "user", Content: ub.String()}})
if err != nil {
log.Printf("[report] 撰写「%s」失败: %v", heading, err)
return "(本章撰写失败:" + err.Error() + "", err
}
if bud := harness.BudgetFrom(ctx); bud != nil {
bud.AddComplete(txt) // 成本护栏:计入输出
}
return strings.TrimSpace(txt), nil
}
// retrieve 经 Eino Retriever 组件(包 mcp-go kb_search)检索知识库,整理为可读参考资料。
func (o *Orchestrator) retrieve(ctx context.Context, kb, query string) string {
docs, err := o.newRetriever(kb).Retrieve(ctx, query)
if err != nil || len(docs) == 0 {
return ""
}
var b strings.Builder
for i, d := range docs {
fmt.Fprintf(&b, "%d. %s\n", i+1, strings.TrimSpace(d.Content))
}
return b.String()
}
// storeReport 经 mcp-go report_store 把报告源数据(title+sections)落盘,供导出时按需渲染。
func (o *Orchestrator) storeReport(ctx context.Context, taskID, title string, secs []reportSection) bool {
if o.tools == nil {
return false
}
arr := make([]map[string]any, len(secs))
for i, s := range secs {
arr[i] = map[string]any{"heading": s.Heading, "body": s.Body}
}
cctx, cancel := context.WithTimeout(ctx, reportRenderWait)
defer cancel()
res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("report_store"), &contract.ToolCall{
Tool: "report_store", TaskID: taskID,
Args: map[string]any{"title": title, "task_id": taskID, "sections": arr},
})
return err == nil && res != nil && res.OK
}
// renderReport 经 mcp-go report_render 工具渲染 docx 并落盘,返回文件路径(失败返回空)。
func (o *Orchestrator) renderReport(ctx context.Context, taskID, title string, secs []reportSection) string {
if o.tools == nil {
return ""
}
arr := make([]map[string]any, len(secs))
for i, s := range secs {
arr[i] = map[string]any{"heading": s.Heading, "body": s.Body}
}
cctx, cancel := context.WithTimeout(ctx, reportRenderWait)
defer cancel()
res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("report_render"), &contract.ToolCall{
Tool: "report_render", TaskID: taskID,
Args: map[string]any{"title": title, "task_id": taskID, "sections": arr},
})
if err != nil || res == nil || !res.OK {
log.Printf("[report] report_render 失败: %v", err)
return ""
}
return res.Content
}
// emit 把一段文本作为 Token 流回客户端(报告进度与正文都走这里)。
func (o *Orchestrator) emit(taskID, s string) {
if err := o.sink.PublishToken(taskID, []byte(s)); err != nil {
log.Printf("[report] emit token failed: %v", err)
}
}
// ---- 小工具 ----
func firstNonEmpty(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
}
return b
}
// stripFence 去掉模型可能包裹的 ```json … ``` 代码围栏。
func stripFence(s string) string {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "```") {
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[i+1:]
}
s = strings.TrimSuffix(strings.TrimSpace(s), "```")
}
return strings.TrimSpace(s)
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}