feat(report): 报告生成端到端 — 规划→分章并行检索撰写→渲染真实 Word
- shared: 新增 intent=report 任务约定 + ReportPath(跨进程共享落盘目录,零配置对齐) - dispatcher: handleReport 专用编排(DeepSeek 规划大纲 → 各章并行 RAG 检索+撰写 → 汇聚 → report_render),Pool.Chat 非流式聚合;进度与正文经 Token 流实时回流 - mcp-go: 用标准库 archive/zip + OOXML 拼出真实可打开的 .docx(零额外依赖), report_render 工具落盘到共享目录;附 docx 有效性测试 - gateway: POST /reports 触发;GET /reports/:id/download 下发 Word - desktop: 新增「报告」页(主题→实时编排进度→下载 Word),左导航置为就绪 实测:DeepSeek 生成 5 章报告 → 渲染 5KB docx → file 识别为 Microsoft Word 2007+ → textutil 提取标题/各章正文完整。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
sharedbus "github.com/sundynix/sundynix-shared/bus"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
|
||||
"github.com/sundynix/sundynix-mcp-go/internal/history"
|
||||
"github.com/sundynix/sundynix-mcp-go/internal/memory"
|
||||
"github.com/sundynix/sundynix-mcp-go/internal/office"
|
||||
"github.com/sundynix/sundynix-mcp-go/internal/rag"
|
||||
"github.com/sundynix/sundynix-mcp-go/internal/search"
|
||||
)
|
||||
@@ -37,7 +40,7 @@ func (g *Gateway) Serve(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = unsub() }()
|
||||
log.Printf("[mcp_go] tools ready on %s (queue=%s): wiki_search, kb_ingest, kb_search, kb_graph, memory_*, history_*, echo",
|
||||
log.Printf("[mcp_go] tools ready on %s (queue=%s): wiki_search, kb_ingest, kb_search, kb_graph, report_render, memory_*, history_*, echo",
|
||||
contract.SubjectToolsGoAll, contract.QueueToolsGo)
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
@@ -55,6 +58,8 @@ func (g *Gateway) dispatch(ctx context.Context, call *contract.ToolCall) *contra
|
||||
return g.kbSearch(ctx, call)
|
||||
case "kb_graph":
|
||||
return g.kbGraph(ctx, call)
|
||||
case "report_render":
|
||||
return g.reportRender(ctx, call)
|
||||
case "memory_get":
|
||||
return g.memoryGet(ctx, call)
|
||||
case "memory_upsert":
|
||||
@@ -174,6 +179,37 @@ func (g *Gateway) kbGraph(ctx context.Context, call *contract.ToolCall) *contrac
|
||||
return &contract.ToolResult{OK: true, Content: string(data)}
|
||||
}
|
||||
|
||||
// reportRender 把结构化报告(title + sections[{heading,body}])渲染为真实 .docx,
|
||||
// 落盘到 contract.ReportPath(task_id),返回绝对路径供 Gateway 提供下载。
|
||||
func (g *Gateway) reportRender(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
|
||||
title, _ := call.Args["title"].(string)
|
||||
id, _ := call.Args["task_id"].(string)
|
||||
if id == "" {
|
||||
id = call.TaskID
|
||||
}
|
||||
if id == "" {
|
||||
return &contract.ToolResult{OK: false, Error: "report_render: task_id 必填"}
|
||||
}
|
||||
// sections 经 NATS JSON 透传,统一 re-marshal 再解出强类型。
|
||||
var secs []office.Section
|
||||
if raw, err := json.Marshal(call.Args["sections"]); err == nil {
|
||||
_ = json.Unmarshal(raw, &secs)
|
||||
}
|
||||
data, err := office.NewRenderer().RenderReport(ctx, title, secs)
|
||||
if err != nil {
|
||||
return &contract.ToolResult{OK: false, Error: "report_render: " + err.Error()}
|
||||
}
|
||||
path := contract.ReportPath(id)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return &contract.ToolResult{OK: false, Error: "report_render: mkdir " + err.Error()}
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return &contract.ToolResult{OK: false, Error: "report_render: write " + err.Error()}
|
||||
}
|
||||
log.Printf("[mcp_go] report_render 已生成 %s (%d 字节, %d 章节)", path, len(data), len(secs))
|
||||
return &contract.ToolResult{OK: true, Content: path}
|
||||
}
|
||||
|
||||
// kbIngest 把文本入库(切块→embedding→Milvus+Bleve)。
|
||||
// 带 job_id 时逐阶段把进度发到 sundynix.streams.<job_id>,供 UI 实时入库监控。
|
||||
func (g *Gateway) kbIngest(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package office
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderReportValidDocx(t *testing.T) {
|
||||
data, err := NewRenderer().RenderReport(context.Background(), "测试报告 <X&Y>", []Section{
|
||||
{Heading: "第一章 背景", Body: "这是第一段。\n这是第二段,含特殊字符 < > &。"},
|
||||
{Heading: "第二章 分析", Body: "结论行。"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
t.Fatalf("not a valid zip: %v", err)
|
||||
}
|
||||
want := map[string]bool{"[Content_Types].xml": false, "_rels/.rels": false, "word/document.xml": false}
|
||||
var docXML string
|
||||
for _, f := range zr.File {
|
||||
if _, ok := want[f.Name]; ok {
|
||||
want[f.Name] = true
|
||||
}
|
||||
if f.Name == "word/document.xml" {
|
||||
rc, _ := f.Open()
|
||||
var b strings.Builder
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, e := rc.Read(buf)
|
||||
b.Write(buf[:n])
|
||||
if e != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
rc.Close()
|
||||
docXML = b.String()
|
||||
}
|
||||
}
|
||||
for name, found := range want {
|
||||
if !found {
|
||||
t.Errorf("missing part %s", name)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(docXML, "< > &") {
|
||||
t.Errorf("xml not escaped properly: %s", docXML)
|
||||
}
|
||||
if !strings.Contains(docXML, "第一章 背景") {
|
||||
t.Errorf("heading missing")
|
||||
}
|
||||
t.Logf("docx ok: %d bytes, %d parts", len(data), len(zr.File))
|
||||
}
|
||||
@@ -1,15 +1,169 @@
|
||||
// Package office 基于 UniOffice 提供 Word/文档渲染能力。
|
||||
// Package office 生成真实可用的 Word(.docx)文档。
|
||||
//
|
||||
// 这里不引第三方 Office 库(UniOffice 为商业授权、且会显著增重依赖),而是直接
|
||||
// 按 OOXML(WordprocessingML) 规范用标准库 archive/zip + 内联 XML 拼出最小但完整、
|
||||
// Word / Pages / WPS 均可正常打开的 .docx 包。零额外依赖,契合 clone 即跑的目标。
|
||||
package office
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Renderer 把结构化数据渲染为 docx/xlsx 等文档。
|
||||
// Doc 累积段落,最终序列化为一个 .docx 字节流。
|
||||
type Doc struct {
|
||||
body strings.Builder // word/document.xml 的 <w:body> 内部段落串
|
||||
}
|
||||
|
||||
// NewDoc 新建一个空文档。
|
||||
func NewDoc() *Doc { return &Doc{} }
|
||||
|
||||
// Title 加一行大标题(居中、加粗、约 18pt)。
|
||||
func (d *Doc) Title(text string) *Doc {
|
||||
d.para(text, paraOpts{bold: true, sizeHalfPt: 36, center: true, spaceAfter: 240})
|
||||
return d
|
||||
}
|
||||
|
||||
// Heading 加一行小节标题(加粗、约 14pt)。
|
||||
func (d *Doc) Heading(text string) *Doc {
|
||||
d.para(text, paraOpts{bold: true, sizeHalfPt: 28, spaceBefore: 240, spaceAfter: 120})
|
||||
return d
|
||||
}
|
||||
|
||||
// Para 加一个正文段落(约 11pt)。空串忽略。
|
||||
func (d *Doc) Para(text string) *Doc {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return d
|
||||
}
|
||||
d.para(text, paraOpts{sizeHalfPt: 22, spaceAfter: 120})
|
||||
return d
|
||||
}
|
||||
|
||||
// Body 把一段可能含多个换行的正文按行拆成多个段落。
|
||||
func (d *Doc) Body(text string) *Doc {
|
||||
for _, line := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
|
||||
d.Para(line)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
type paraOpts struct {
|
||||
bold bool
|
||||
center bool
|
||||
sizeHalfPt int // OOXML 字号单位为半磅(half-points),22 = 11pt
|
||||
spaceBefore int // 段前间距(twentieths of a point)
|
||||
spaceAfter int
|
||||
}
|
||||
|
||||
func (d *Doc) para(text string, o paraOpts) {
|
||||
d.body.WriteString("<w:p>")
|
||||
// 段落属性:间距 + 居中。
|
||||
d.body.WriteString("<w:pPr>")
|
||||
if o.spaceBefore > 0 || o.spaceAfter > 0 {
|
||||
fmt.Fprintf(&d.body, `<w:spacing w:before="%d" w:after="%d"/>`, o.spaceBefore, o.spaceAfter)
|
||||
}
|
||||
if o.center {
|
||||
d.body.WriteString(`<w:jc w:val="center"/>`)
|
||||
}
|
||||
d.body.WriteString("</w:pPr>")
|
||||
// 文本 run。
|
||||
d.body.WriteString("<w:r><w:rPr>")
|
||||
if o.bold {
|
||||
d.body.WriteString("<w:b/>")
|
||||
}
|
||||
if o.sizeHalfPt > 0 {
|
||||
fmt.Fprintf(&d.body, `<w:sz w:val="%d"/><w:szCs w:val="%d"/>`, o.sizeHalfPt, o.sizeHalfPt)
|
||||
}
|
||||
d.body.WriteString("</w:rPr>")
|
||||
fmt.Fprintf(&d.body, `<w:t xml:space="preserve">%s</w:t>`, escapeXML(text))
|
||||
d.body.WriteString("</w:r></w:p>")
|
||||
}
|
||||
|
||||
// Bytes 把累积的段落打包为合规 .docx(zip + 三个核心 XML 部件)。
|
||||
func (d *Doc) Bytes() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
parts := map[string]string{
|
||||
"[Content_Types].xml": contentTypesXML,
|
||||
"_rels/.rels": relsXML,
|
||||
"word/document.xml": documentXML(d.body.String()),
|
||||
}
|
||||
for name, content := range parts {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := w.Write([]byte(content)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// ---- Renderer:把结构化报告(标题 + 章节)渲染为 docx ----
|
||||
|
||||
// Section 是报告的一章:小节标题 + 正文。
|
||||
type Section struct {
|
||||
Heading string `json:"heading"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// Renderer 把结构化数据渲染为 docx。
|
||||
type Renderer struct{}
|
||||
|
||||
func NewRenderer() *Renderer { return &Renderer{} }
|
||||
|
||||
// RenderDocx 生成 Word 文档并返回字节流。
|
||||
func (r *Renderer) RenderDocx(ctx context.Context, payload map[string]any) ([]byte, error) {
|
||||
// TODO: 使用 unioffice/document 构建并序列化
|
||||
return nil, nil
|
||||
// RenderReport 渲染「大标题 + 多章节」结构的报告为 .docx 字节流。
|
||||
func (r *Renderer) RenderReport(_ context.Context, title string, sections []Section) ([]byte, error) {
|
||||
doc := NewDoc()
|
||||
if title != "" {
|
||||
doc.Title(title)
|
||||
}
|
||||
for _, s := range sections {
|
||||
if s.Heading != "" {
|
||||
doc.Heading(s.Heading)
|
||||
}
|
||||
doc.Body(s.Body)
|
||||
}
|
||||
return doc.Bytes()
|
||||
}
|
||||
|
||||
// ---- OOXML 模板 ----
|
||||
|
||||
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
</Types>`
|
||||
|
||||
const relsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
func documentXML(body string) string {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>` +
|
||||
body +
|
||||
`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>`
|
||||
}
|
||||
|
||||
func escapeXML(s string) string {
|
||||
r := strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
`"`, """,
|
||||
"'", "'",
|
||||
)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user