From e2efe129bfc2e4873e4b00fa429070146f000354 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 18:07:59 +0000 Subject: [PATCH] Generate real docx artifacts and guard against pseudo-binary writes Why: write_artifact/fs_write_file accept string content only, so models physically cannot emit ZIP containers; every ".docx" artifact written to date was plain text masquerading as Word (the meeting-notes docx preview exploded in JSZip). The system advertised docx as a first-class delivery format without a serialization path behind it. Evidence: new internal/tools/docx.go serializes markdown to a minimal OOXML package (PK-magic zip, word/document.xml, pStyle HeadingN) that ingest.parseDocx round-trips; write_artifact gains format=docx (name ending .docx auto-upgrades) and rejects xlsx/pptx/doc/xls string writes with actionable guidance; fs_write_file gains the same guard; runmgr.WriteArtifact now extracts index text via ingest instead of stuffing binary bytes into the knowledge index; mock #doc demo emits a real docx when the user names docx/word. go vet clean; go test ./... 18 packages pass, including new docx_test.go (magic, roundtrip, guards). Impact: write_artifact contract gains an optional format param (backwards compatible); binary-Office extensions now fail fast instead of silently producing unopenable files. Frontend fallback for stock fake-docx artifacts ships separately (feature/preview-sniff-fallback). Refs: .task/feature-docx-generation.md --- internal/runmgr/runmgr.go | 37 +++++++- internal/runtime/mock.go | 16 +++- internal/tools/docx.go | 130 ++++++++++++++++++++++++++++ internal/tools/docx_test.go | 163 ++++++++++++++++++++++++++++++++++++ internal/tools/fs.go | 5 ++ internal/tools/registry.go | 34 +++++++- 6 files changed, 376 insertions(+), 9 deletions(-) create mode 100644 internal/tools/docx.go create mode 100644 internal/tools/docx_test.go diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 01c7d7b..6f0b96f 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -4,6 +4,7 @@ package runmgr import ( + "bytes" "context" "errors" "fmt" @@ -13,6 +14,7 @@ import ( "agent-system-backend/internal/bus" "agent-system-backend/internal/cardsvc" + "agent-system-backend/internal/ingest" "agent-system-backend/internal/profiles" "agent-system-backend/internal/runtime" "agent-system-backend/internal/settings" @@ -284,16 +286,43 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar return art, err } // 工件文本进检索索引(管线分块+全文+可选向量;未注入回落整文档单块)。 + // 二进制工件(如序列化产出的 docx)不能 string() 直灌索引——先经 ingest + // 抽取纯文本,抽不出内容(warning/空块)则跳过,保持索引无二进制乱码。 if kind == "document" && len(content) < 200000 { - if c.mgr.indexer != nil { - _ = c.mgr.indexer.IndexDocument(c.run.Owner, c.run.ProjectID, "artifact", name, art.ID, string(content)) - } else { - _ = c.mgr.st.AddKnowledge(core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: string(content)}) + text := indexTextOf(name, mime, content) + if text != "" { + if c.mgr.indexer != nil { + _ = c.mgr.indexer.IndexDocument(c.run.Owner, c.run.ProjectID, "artifact", name, art.ID, text) + } else { + _ = c.mgr.st.AddKnowledge(core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: text}) + } } } return art, nil } +// indexTextOf 决定工件进检索索引的文本:可打印内容原样返回;否则按扩展名走 +// ingest 解析(docx/pdf 等),解析失败或为空返回空串(跳过索引)。 +func indexTextOf(name, mime string, content []byte) string { + if isMostlyTextual(content) { + return string(content) + } + doc := ingest.Parse(name, mime, content) + if len(doc.Warnings) > 0 || len(doc.Blocks) == 0 { + return "" + } + return doc.PlainText() +} + +// isMostlyTextual 以 NUL 字节为二进制信号(UTF-8 文本不会出现 NUL)。 +func isMostlyTextual(content []byte) bool { + n := len(content) + if n > 512 { + n = 512 + } + return !bytes.ContainsRune(content[:n], 0) +} + func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { if q.TopK <= 0 { q.TopK = int(c.eff.GetNumber("retrieval.topK", 5)) diff --git a/internal/runtime/mock.go b/internal/runtime/mock.go index 517d632..26b4e03 100644 --- a/internal/runtime/mock.go +++ b/internal/runtime/mock.go @@ -6,6 +6,7 @@ import ( "time" "agent-system-backend/internal/squadadapter" + "agent-system-backend/internal/tools" core "agent-system-core" ) @@ -54,7 +55,7 @@ func (m *MockRunner) Execute(rc RunCtx) error { Question: "演示:采用哪种输出格式?", Kind: core.HITLKindChoice, Choices: []core.HITLChoice{ {ID: "md", Label: "Markdown 文档", Description: "可版本管理的轻量文档", Impact: "写入 artifacts,可在右栏查看与继续编辑", Recommended: true}, - {ID: "docx", Label: "Word 文档", Description: "正式交付格式", Impact: "生成 docx 工件,后续修订走版本对比"}, + {ID: "docx", Label: "Word 文档", Description: "正式交付格式", Impact: "生成真实 docx 工件(后端 OOXML 序列化),前端可预览、Word 可打开"}, {ID: "both", Label: "两种都要", Description: "同时产出", Impact: "生成两个工件"}, }, AllowFreeInput: true, FreeInputHint: "也可以直接输入你的要求(输入 / 开头则转新命令)", @@ -144,7 +145,18 @@ func (m *MockRunner) Execute(rc RunCtx) error { rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "准备生成文档……"}) parts = append(parts, core.Part{Type: core.PartThinking, Text: "准备生成文档……", Process: true}) content := "# 演示文档\n\n这是 mock runner 生成的示例工件。\n\n## 要点\n\n- 由 #doc 指令触发\n- 写入 artifacts 并可在右栏查看\n" - art, err := rc.WriteArtifact("demo-报告.md", "document", "text/markdown", []byte(content)) + var art core.Artifact + var err error + // 用户点名 Word/docx 时走真实 OOXML 序列化(与 write_artifact 同一路径), + // 产出 PK 魔数的合法 docx——前端预览与下载均可打开;不再用文本冒充。 + if s := strings.ToLower(user); strings.Contains(s, "docx") || strings.Contains(s, "word") { + if docx, derr := tools.MarkdownToDocx(content); derr == nil { + art, err = rc.WriteArtifact("demo-报告.docx", "document", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", docx) + } + } else { + art, err = rc.WriteArtifact("demo-报告.md", "document", "text/markdown", []byte(content)) + } if err == nil { parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}}) } diff --git a/internal/tools/docx.go b/internal/tools/docx.go new file mode 100644 index 0000000..00930aa --- /dev/null +++ b/internal/tools/docx.go @@ -0,0 +1,130 @@ +// 最小 OOXML(docx)序列化器:把 markdown 文本转成 Word 可打开的真实 docx。 +// 背景:write_artifact/fs_write_file 只收字符串参数,模型物理上无法经 JSON 字符串 +// 产出 ZIP 容器——历史上 ".docx" 工件全是文本冒充(前端预览 JSZip 报 +// "end of central directory")。本文件提供唯一合法的 docx 产出路径。 +// +// v1 范围:标题(Heading1-3)、段落、列表(文字圆点前缀,不引 numbering.xml)、 +// 表格(简单边框)、代码块(等宽字体);行内仅处理 **加粗**。设计约束: +// 产出包必须能被 internal/ingest 的 parseDocx 往返解析(pStyle 用 HeadingN 约定)。 +package tools + +import ( + "archive/zip" + "bytes" + "encoding/xml" + "fmt" + "strings" + + core "agent-system-core" + + "agent-system-backend/internal/ingest" +) + +const mimeDocx = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + +// 二进制 Office 扩展:这些格式没有(也不计划有)字符串直写路径——字符串字节落盘 +// 得到的是打不开的伪文件。.docx 走本文件序列化,其余直接拒绝。 +var binaryOfficeExt = map[string]bool{ + ".docx": true, ".doc": true, ".xlsx": true, ".xls": true, ".xlsm": true, ".pptx": true, ".ppt": true, +} + +// MarkdownToDocx 把 markdown 文本序列化为合法 docx(ZIP 容器)字节。 +func MarkdownToDocx(markdown string) ([]byte, error) { + doc := ingest.Parse("doc.md", "text/markdown", []byte(markdown)) + var body strings.Builder + for _, b := range doc.Blocks { + switch b.Type { + case core.BlockHeading: + lvl := b.Level + if lvl < 1 || lvl > 3 { + lvl = 3 + } + body.WriteString(ooxmlPara(b.Text, fmt.Sprintf(``, lvl))) + case core.BlockPara, core.BlockCode: + body.WriteString(ooxmlPara(b.Text, "")) + case core.BlockList: + for _, item := range b.Items { + body.WriteString(ooxmlPara("• "+item, "")) + } + } + } + document := `` + + `` + + body.String() + + `` + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, part := range []struct{ name, content string }{ + {"[Content_Types].xml", contentTypesXML}, + {"_rels/.rels", rootRelsXML}, + {"word/document.xml", document}, + } { + f, err := zw.Create(part.name) + if err != nil { + return nil, err + } + if _, err := f.Write([]byte(part.content)); err != nil { + return nil, err + } + } + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +const contentTypesXML = `` + + `` + + `` + + `` + + `` + + `` + +const rootRelsXML = `` + + `` + + `` + + `` + +// ooxmlPara 输出一个段落:props 为 pPr 内容(可为空),文本按行拆 run(行间 w:br), +// 行内 **加粗** 转 run,其余字符经 XML 转义。 +func ooxmlPara(text, props string) string { + if strings.TrimSpace(text) == "" { + return "" + } + var sb strings.Builder + sb.WriteString("") + if props != "" { + sb.WriteString("" + props + "") + } + for i, line := range strings.Split(text, "\n") { + if i > 0 { + sb.WriteString("") + } + sb.WriteString(ooxmlRuns(line)) + } + sb.WriteString("") + return sb.String() +} + +// ooxmlRuns 把一行文本按 **对** 切分为普通/加粗 run;落单的 ** 保持字面。 +func ooxmlRuns(line string) string { + var sb strings.Builder + seg := strings.Split(line, "**") + for i, s := range seg { + if s == "" { + continue + } + sb.WriteString("") + if i%2 == 1 { + sb.WriteString("") + } + sb.WriteString(``) + xml.EscapeText(&sb, []byte(s)) + sb.WriteString("") + } + return sb.String() +} + +// 表格与代码样式(等宽 rFonts)暂不输出:ingest.parseMarkdown 尚不解析 md 表格 +// (不产 BlockTable),序列化端先不做走不到的分支;随 ingest 表格解析一并补。 diff --git a/internal/tools/docx_test.go b/internal/tools/docx_test.go new file mode 100644 index 0000000..14b4945 --- /dev/null +++ b/internal/tools/docx_test.go @@ -0,0 +1,163 @@ +// docx 序列化与伪二进制护栏测试:PK 魔数 / ingest 往返 / XML 转义 / write_artifact +// format 分派与拒绝路径 / fs_write_file 护栏。 +package tools + +import ( + "bytes" + "strings" + "testing" + + "agent-system-backend/internal/ingest" +) + +const sampleMD = `# 会议纪要 + +## 决议 + +1. 注册路径走 FDA 510(k) +2. 样机 B 版冻结 + +## 待办 + +- 法规组输出对比表 +- 结构组更新 BOM + +正文含 **加粗** 与 <标签> & "引号" 特殊字符。 +` + +func TestMarkdownToDocxProducesZip(t *testing.T) { + b, err := MarkdownToDocx(sampleMD) + if err != nil { + t.Fatal(err) + } + // ZIP 魔数:真 docx 的第一判据(历史上伪 docx 在这里就露馅)。 + if !bytes.HasPrefix(b, []byte("PK\x03\x04")) { + t.Fatalf("output is not a zip container: head=% x", b[:4]) + } + if len(b) < 512 { + t.Fatalf("suspiciously small docx: %d bytes", len(b)) + } +} + +func TestMarkdownToDocxRoundtrip(t *testing.T) { + b, err := MarkdownToDocx(sampleMD) + if err != nil { + t.Fatal(err) + } + doc := ingest.Parse("会议纪要.docx", mimeDocx, b) + if len(doc.Warnings) > 0 { + t.Fatalf("roundtrip warnings: %v", doc.Warnings) + } + var headings, paras int + sawBullet := false + for _, blk := range doc.Blocks { + switch blk.Type { + case "heading": + headings++ + if blk.Text == "会议纪要" && blk.Level != 1 { + t.Errorf("h1 level = %d", blk.Level) + } + case "para": + paras++ + // 列表项序列化为 "• " 前缀段落(docx 侧无 numbering.xml,语义近似往返)。 + if strings.HasPrefix(blk.Text, "• ") { + sawBullet = true + } + // XML 转义往返:<>& 与中文须原样还原;** 在序列化侧转成加粗 run, + // 还原为纯文本后星号消失。 + if strings.Contains(blk.Text, `<标签>`) && !strings.Contains(blk.Text, `"引号"`) { + t.Errorf("escape roundtrip lost content: %q", blk.Text) + } + if strings.Contains(blk.Text, "**") { + t.Errorf("literal ** leaked into text run: %q", blk.Text) + } + } + } + if headings < 2 { + t.Errorf("headings = %d, want >=2", headings) + } + if !sawBullet { + t.Errorf("list items missing bullet paragraphs") + } + if paras < 3 { + t.Errorf("paras = %d, want >=3", paras) + } +} + +func TestWriteArtifactFormatDocx(t *testing.T) { + r := NewRegistry() + if err := r.RegisterBuiltin(); err != nil { + t.Fatal(err) + } + rc := &fakeFSCtx{owner: "o1", conv: "c1"} + // name 以 .docx 结尾 → 自动序列化。 + res := call(t, r, rc, "write_artifact", `{"name":"纪要.docx","content":"# 标题\n\n正文"}`) + if res.Status != "ok" { + t.Fatalf("docx by name failed: %s", res.Output) + } + if rc.written[0] != "纪要.docx" { + t.Fatalf("written name = %s", rc.written[0]) + } + // format=docx 显式指定(名字不带后缀时补 .docx)。 + res = call(t, r, rc, "write_artifact", `{"name":"报告","format":"docx","content":"# 报告"}`) + if res.Status != "ok" { + t.Fatalf("format=docx failed: %s", res.Output) + } + if rc.written[1] != "报告.docx" { + t.Fatalf("format=docx name = %s", rc.written[1]) + } +} + +func TestWriteArtifactGuards(t *testing.T) { + r := NewRegistry() + if err := r.RegisterBuiltin(); err != nil { + t.Fatal(err) + } + rc := &fakeFSCtx{owner: "o1", conv: "c1"} + // 伪二进制拒绝:xlsx/pptx/doc 无序列化路径。 + for _, name := range []string{"台账.xlsx", "路演.pptx", "旧.doc", "表.xls"} { + res := call(t, r, rc, "write_artifact", `{"name":"`+name+`","content":"x"}`) + if res.Status != "error" || !strings.Contains(res.Output, "不支持直接写入二进制 Office 格式") { + t.Errorf("%s should be rejected, got %s %s", name, res.Status, res.Output) + } + } + // data + docx 冲突。 + res := call(t, r, rc, "write_artifact", `{"name":"a.docx","kind":"data","content":"{}"}`) + if res.Status != "error" || !strings.Contains(res.Output, "冲突") { + t.Errorf("data+docx should conflict, got %s %s", res.Status, res.Output) + } + // 未知 format 拒绝。 + res = call(t, r, rc, "write_artifact", `{"name":"a.md","format":"pdf","content":"x"}`) + if res.Status != "error" || !strings.Contains(res.Output, "仅支持 md 或 docx") { + t.Errorf("unknown format should be rejected, got %s %s", res.Status, res.Output) + } +} + +func TestFSWriteGuardsBinaryOffice(t *testing.T) { + r := newFSRegistry(t) + rc := &fakeFSCtx{root: t.TempDir(), owner: "o1", conv: "c1", fsToolsOn: true} + res := call(t, r, rc, "fs_write_file", `{"path":"docs/台账.xlsx","content":"a,b"}`) + if res.Status != "error" || !strings.Contains(res.Output, "不支持直接写入二进制 Office 格式") { + t.Fatalf("fs xlsx should be rejected, got %s %s", res.Status, res.Output) + } + // .md 不受影响。 + res = call(t, r, rc, "fs_write_file", `{"path":"docs/a.md","content":"x"}`) + if res.Status != "ok" { + t.Fatalf("fs md should pass, got %s %s", res.Status, res.Output) + } +} + +func TestArtifactParamsSchemaExposesFormat(t *testing.T) { + r := NewRegistry() + if err := r.RegisterBuiltin(); err != nil { + t.Fatal(err) + } + for _, d := range r.List() { + if d.Name != "write_artifact" { + continue + } + if !strings.Contains(string(d.Parameters), `"format"`) { + t.Fatalf("schema missing format param: %s", d.Parameters) + } + } +} diff --git a/internal/tools/fs.go b/internal/tools/fs.go index 4f4b57d..d0e06b5 100644 --- a/internal/tools/fs.go +++ b/internal/tools/fs.go @@ -113,6 +113,11 @@ func execFSWrite(rc ToolExecCtx, args json.RawMessage) (string, error) { if a.Path == "" { return "", fmt.Errorf("fs_write_file: path required") } + // 护栏:字符串直写产不出合法二进制 Office 文件(写出即伪文件,预览必炸); + // Word 交付件引导走 write_artifact(name 以 .docx 结尾时自动序列化)。 + if ext := strings.ToLower(filepath.Ext(a.Path)); binaryOfficeExt[ext] { + return "", fmt.Errorf("fs_write_file: 不支持直接写入二进制 Office 格式 %q——content 是文本,写出的是打不开的伪文件;请改用 write_artifact 且 name 以 .docx 结尾(自动序列化为真实 Word),或改存 .md 文档", ext) + } if len(a.Content) > fsMaxWrite { return "", fmt.Errorf("fs_write_file: 内容超过 2MB 上限") } diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 00422e6..ffa89fd 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -5,6 +5,7 @@ package tools import ( "encoding/json" "fmt" + "path/filepath" "sort" "strings" "sync" @@ -150,13 +151,14 @@ func (r *Registry) RegisterBuiltin() error { return err } if err := r.Register(core.ToolDesc{ - Name: "write_artifact", Description: "把生成内容写入工件(文档/数据),返回工件 ID 与名称", Source: core.SourceBuiltin, - Parameters: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"},"kind":{"type":"string","enum":["document","data"]},"content":{"type":"string"}},"required":["name","content"]}`), + Name: "write_artifact", Description: "把生成内容写入工件(文档/数据)。content 一律写 markdown/纯文本;需要 Word 交付件时用 name=*.docx 或 format=docx,系统会把内容序列化为真实 docx。不支持直接写入其他二进制 Office 格式(xlsx/pptx/doc 等)", Source: core.SourceBuiltin, + Parameters: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"},"kind":{"type":"string","enum":["document","data"]},"content":{"type":"string"},"format":{"type":"string","enum":["md","docx"],"description":"输出格式;name 以 .docx 结尾时默认 docx,否则默认 md"}},"required":["name","content"]}`), }, func(rc ToolExecCtx, args json.RawMessage) (string, error) { var a struct { Name string `json:"name"` Kind string `json:"kind"` Content string `json:"content"` + Format string `json:"format"` } if err := json.Unmarshal(args, &a); err != nil { return "", err @@ -168,11 +170,37 @@ func (r *Registry) RegisterBuiltin() error { if kind == "" { kind = "document" } + ext := strings.ToLower(filepath.Ext(a.Name)) + wantDocx := a.Format == "docx" || (a.Format == "" && ext == ".docx") + // 护栏:字符串直写产不出合法二进制 Office 文件(历史上模型把 markdown + // 存成 .xlsx/.pptx 造成伪工件)。docx 有序列化路径放行,其余明示拒绝。 + if binaryOfficeExt[ext] && !wantDocx { + return "", fmt.Errorf("write_artifact: 不支持直接写入二进制 Office 格式 %q——content 是文本,写出的是打不开的伪文件;请改用 name 以 .docx 结尾(自动序列化为真实 Word),或改存 .md 文档", ext) + } + if a.Format != "" && a.Format != "md" && a.Format != "docx" { + return "", fmt.Errorf("write_artifact: format 仅支持 md 或 docx,得到 %q", a.Format) + } + content := []byte(a.Content) mime := "text/markdown" if kind == "data" { mime = "application/json" } - art, err := rc.WriteArtifact(a.Name, kind, mime, []byte(a.Content)) + name := a.Name + if wantDocx { + if kind == "data" { + return "", fmt.Errorf("write_artifact: format=docx 与 kind=data 冲突——docx 是文档格式") + } + if ext != ".docx" { + name += ".docx" + } + var err error + content, err = MarkdownToDocx(a.Content) + if err != nil { + return "", fmt.Errorf("write_artifact: docx 序列化失败: %w", err) + } + mime = mimeDocx + } + art, err := rc.WriteArtifact(name, kind, mime, content) if err != nil { return "", err }