Files
Agent-System-Backend/internal/docgen/docgen_test.go
T
weichen 370ae1a280 Generate styled Word deliverables and pin artifact cards in chat
Why: docx deliverables were plain text saved with a .docx name (no
headings/tables/styles), and the artifact_ref part was marked Process
so the deliverable vanished into the collapsed process section — the
user saw only model-written text like "📄 xxx.docx 工件 ID …" instead
of an openable, formatted document delivered in the conversation.

Evidence:
- new internal/docgen: Markdown -> styled OOXML (Title/Heading1-4,
  numbered+bullet lists with numbering.xml, GFM tables with shaded
  header, quotes, code blocks, bold/italic/strike, real hyperlinks);
  zero third-party deps; tests cover zip structure, XML well-formedness,
  numbering refs, hyperlink rels, escaping, plain-text fallback
- ingest round-trip test proves generated docx parses back to blocks
- new write_docx builtin tool (markdown in -> styled .docx artifact);
  write_artifact prompt now routes Word docs to it; task.doc-minutes
  output contract requires write_docx delivery
- llm.go: artifact refs collected as deliverables and appended as
  content parts AFTER the final text (pinned tail, not process); mock
  #dogfood path generates a real styled docx; runmgr skips string
  indexing for binary office mimes
- go test ./... 19 packages ok (incl. new e2e asserting <w:tbl> and
  title text inside the artifact served by the API)

Impact: model-written documents open in Word/WPS with real layout and
preview with formatting (mammoth maps Heading1-4 to h1-h4); every tool
artifact now lands as a visible in-chat card; no behavior change for
text artifacts.

Refs: task feature/deliverable-docx-r1
2026-10-01 02:48:49 +00:00

243 lines
6.0 KiB
Go

package docgen
import (
"archive/zip"
"bytes"
"encoding/xml"
"io"
"strings"
"testing"
)
// 样例正文:会议纪要形态(标题/表格/列表/任务项/引用/链接/删除线/分隔线)。
// 行内代码用 \x60(反引号)拼接——raw string 里不能直接写字面反引号。
const sampleMinutes = "# 研发周会纪要\n" + `
## 一、基本信息
| 项目 | 内容 |
|---|---|
| 时间 | 2026-09-30 14:00 |
| 地点 | 3F 会议室 |
| 主持人 | 陈威 |
## 二、讨论要点
- **架构**:docx 生成走后端转换,见 [方案](https://example.com/plan)
- 排版要求:标题层级、表格、列表
- 二级要点:交付物卡片
- [x] 已完成:预览装配
- [ ] 待办:样式打磨
> 会议结论:按周五节点交付。
## 三、决议
1. 采用 write_docx 工具
2. 对话内出交付物卡片
` + "\x60write_docx\x60 与 ~~write_artifact~~ 的分工见上文。" + `
---
后续:周报合并。
`
func buildSample(t *testing.T) []byte {
t.Helper()
b, err := Build("", sampleMinutes)
if err != nil {
t.Fatalf("Build: %v", err)
}
return b
}
func TestBuildZipStructure(t *testing.T) {
b := buildSample(t)
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
if err != nil {
t.Fatalf("zip 打开失败(产物不是合法 docx 容器): %v", err)
}
want := map[string]bool{
"[Content_Types].xml": false, "word/document.xml": false,
"word/styles.xml": false, "word/numbering.xml": false,
"word/_rels/document.xml.rels": false, "_rels/.rels": false,
"docProps/core.xml": false,
}
for _, f := range zr.File {
if _, ok := want[f.Name]; ok {
want[f.Name] = true
}
}
for name, seen := range want {
if !seen {
t.Errorf("docx 缺少部件 %s", name)
}
}
}
func TestBuildDocumentXMLWellFormed(t *testing.T) {
b := buildSample(t)
zr, _ := zip.NewReader(bytes.NewReader(b), int64(len(b)))
var doc string
for _, f := range zr.File {
if f.Name == "word/document.xml" {
rc, _ := f.Open()
doc = string(readAll(rc))
rc.Close()
}
}
if doc == "" {
t.Fatal("word/document.xml 为空")
}
dec := xml.NewDecoder(strings.NewReader(doc))
for {
_, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("document.xml 不是良构 XML: %v", err)
}
}
for _, marker := range []string{
`<w:pStyle w:val="Title"/>`,
`<w:pStyle w:val="Heading1"/>`,
`<w:pStyle w:val="Heading2"/>`,
`<w:tbl>`,
`<w:tblHeader/>`,
`<w:numPr>`,
`<w:hyperlink r:id="rIdH1">`,
`<w:strike/>`,
} {
if !strings.Contains(doc, marker) {
t.Errorf("document.xml 缺少排版标记 %s", marker)
}
}
// 标题文本与转义:一级标题进 Title 段(title 取首个标题)。
if !strings.Contains(doc, "研发周会纪要") {
t.Errorf("document.xml 缺少标题文本")
}
}
func TestBuildNumberingRefsResolve(t *testing.T) {
b := buildSample(t)
zr, _ := zip.NewReader(bytes.NewReader(b), int64(len(b)))
read := func(name string) string {
for _, f := range zr.File {
if f.Name == name {
rc, _ := f.Open()
s, _ := io.ReadAll(rc)
rc.Close()
return string(s)
}
}
t.Fatalf("缺少 %s", name)
return ""
}
doc, numbering := read("word/document.xml"), read("word/numbering.xml")
// 收集 document 里引用的 numId,逐一确认 numbering.xml 有对应 w:num。
refs := map[string]bool{}
for _, seg := range strings.Split(doc, "<w:numId w:val=\"")[1:] {
id := seg[:strings.IndexByte(seg, '"')]
refs[id] = true
}
if len(refs) == 0 {
t.Fatal("列表未引用任何 numId")
}
for id := range refs {
if !strings.Contains(numbering, `<w:num w:numId="`+id+`">`) {
t.Errorf("numId %s 在 numbering.xml 无定义", id)
}
}
// 有序/无序列表分属不同编号实例(同文档两类列表都出现时 numId 应不同)。
if len(refs) < 2 {
t.Errorf("样例含无序+有序两类列表,numId 应至少 2 个,得 %d", len(refs))
}
}
func TestBuildHyperlinkRels(t *testing.T) {
b := buildSample(t)
zr, _ := zip.NewReader(bytes.NewReader(b), int64(len(b)))
var rels string
for _, f := range zr.File {
if f.Name == "word/_rels/document.xml.rels" {
rc, _ := f.Open()
rels = string(readAll(rc))
rc.Close()
}
}
if !strings.Contains(rels, `Id="rIdH1"`) || !strings.Contains(rels, "https://example.com/plan") {
t.Errorf("超链接 relationship 缺失: %s", rels)
}
}
func TestBuildEscapesXML(t *testing.T) {
b, err := Build("标题<a>&'", "包含 <标签> & \"引号\" 的正文")
if err != nil {
t.Fatalf("Build: %v", err)
}
zr, _ := zip.NewReader(bytes.NewReader(b), int64(len(b)))
for _, f := range zr.File {
if f.Name != "word/document.xml" {
continue
}
rc, _ := f.Open()
doc := readAll(rc)
rc.Close()
if !strings.Contains(string(doc), "&lt;标签&gt;") || !strings.Contains(string(doc), "&amp;") {
t.Errorf("正文 XML 转义缺失")
}
dec := xml.NewDecoder(bytes.NewReader(doc))
for {
_, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("转义后 XML 不良构: %v", err)
}
}
}
}
func TestBuildPlainTextFallback(t *testing.T) {
// 无任何 Markdown 结构的纯文本:按段落输出,不 panic、产物良构。
b, err := Build("", "第一行\n第二行\n\n第二段")
if err != nil {
t.Fatalf("Build: %v", err)
}
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
if err != nil {
t.Fatalf("zip: %v", err)
}
var doc string
for _, f := range zr.File {
if f.Name == "word/document.xml" {
rc, _ := f.Open()
doc = string(readAll(rc))
rc.Close()
}
}
if !strings.Contains(doc, "第二段") {
t.Errorf("纯文本段落丢失")
}
// 段内换行应转 w:br,且不残留裸换行进 w:t。
if !strings.Contains(doc, "<w:br/>") {
t.Errorf("段内换行未转 w:br")
}
}
func TestSuggestName(t *testing.T) {
if got := SuggestName("季度/报告:2026"); got != "季度报告2026.docx" {
t.Errorf("SuggestName = %q", got)
}
if got := SuggestName(" "); got != "文档.docx" {
t.Errorf("SuggestName 空 = %q", got)
}
}
func readAll(r io.Reader) []byte {
b, _ := io.ReadAll(r)
return b
}