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
243 lines
6.0 KiB
Go
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), "<标签>") || !strings.Contains(string(doc), "&") {
|
|
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
|
|
}
|