Generate real docx artifacts and guard against pseudo-binary writes #27

Closed
weichen wants to merge 1 commits from feature/docx-generation into main
6 changed files with 376 additions and 9 deletions
+33 -4
View File
@@ -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))
+14 -2
View File
@@ -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}})
}
+130
View File
@@ -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(`<w:pStyle w:val="Heading%d"/>`, lvl)))
case core.BlockPara, core.BlockCode:
body.WriteString(ooxmlPara(b.Text, ""))
case core.BlockList:
for _, item := range b.Items {
body.WriteString(ooxmlPara("• "+item, ""))
}
}
}
document := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>` +
body.String() +
`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr></w:body></w:document>`
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 = `<?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 rootRelsXML = `<?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>`
// ooxmlPara 输出一个段落:props 为 pPr 内容(可为空),文本按行拆 run(行间 w:br),
// 行内 **加粗** 转 <w:b/> run,其余字符经 XML 转义。
func ooxmlPara(text, props string) string {
if strings.TrimSpace(text) == "" {
return ""
}
var sb strings.Builder
sb.WriteString("<w:p>")
if props != "" {
sb.WriteString("<w:pPr>" + props + "</w:pPr>")
}
for i, line := range strings.Split(text, "\n") {
if i > 0 {
sb.WriteString("<w:r><w:br/></w:r>")
}
sb.WriteString(ooxmlRuns(line))
}
sb.WriteString("</w:p>")
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("<w:r>")
if i%2 == 1 {
sb.WriteString("<w:rPr><w:b/></w:rPr>")
}
sb.WriteString(`<w:t xml:space="preserve">`)
xml.EscapeText(&sb, []byte(s))
sb.WriteString("</w:t></w:r>")
}
return sb.String()
}
// 表格与代码样式(等宽 rFonts)暂不输出:ingest.parseMarkdown 尚不解析 md 表格
// (不产 BlockTable),序列化端先不做走不到的分支;随 ingest 表格解析一并补。
+163
View File
@@ -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)
}
}
}
+5
View File
@@ -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 上限")
}
+31 -3
View File
@@ -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
}