Files
Agent-System-Backend/internal/ingest/ingest_test.go
T
weichen 68c1a7272a
ci / vet-and-test (pull_request) Successful in 43s
Keep tracked-change and hyperlink text in DOCX and add an exec hard switch
Why: The DOCX parser only read w:r runs that were direct children of w:p, so
text inside w:ins, w:hyperlink and w:fldSimple was dropped with no warning,
violating the no-silent-degradation rule. Separately, runtime.exec is a
user-writable setting, so multi-user deployments could not reliably turn the
exec primitives off.
Evidence: SELF - red tests TestParseDocxTrackedChangesAndHyperlinks and
TestExecHardDisabledOverridesSettings failed before and pass after; backend
go test ./... (32 packages) passes; a mock-mode binary started with
--disable-exec-tools hides run_shell/run_python/register_artifact from
/api/tools even after the user sets runtime.exec=true.
Impact: DOCX paragraphs are decoded from the token stream at any depth;
deleted/moved-from text, field codes and mc:Fallback are excluded and
revised documents carry an explicit warning. New Options.DisableExecTools
(SurfaceVersion 2.5.0-experimental), CLI --disable-exec-tools, config key
disableExecTools and env AGENT_SYSTEM_DISABLE_EXEC_TOOLS; zero value keeps
current behavior.
Refs: Agent-System-RDM docs/proposals/2026-10-09-V22-review-and-implementation-plan.md (G0-7, B-5)
2026-10-09 10:36:02 +00:00

185 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ingest
import (
"archive/zip"
"bytes"
"encoding/xml"
"strings"
"testing"
core "agent-system-core"
)
func TestParseMarkdown(t *testing.T) {
doc := Parse("笔记.md", "text/markdown", []byte("# 标题一\n\n段落甲。\n\n- 要点 1\n- 要点 2\n\n## 标题二\n\n段落乙。\n"))
if doc.Meta.Parser != "builtin:md" || doc.Meta.Confidence != 1.0 {
t.Fatalf("meta: %+v", doc.Meta)
}
var heads, paras, lists int
for _, b := range doc.Blocks {
switch b.Type {
case core.BlockHeading:
heads++
case core.BlockPara:
paras++
case core.BlockList:
lists++
if len(b.Items) != 2 {
t.Errorf("list items: %v", b.Items)
}
}
}
if heads != 2 || paras != 2 || lists != 1 {
t.Errorf("blocks: heads=%d paras=%d lists=%d", heads, paras, lists)
}
if !strings.Contains(doc.PlainText(), "# 标题一") {
t.Error("PlainText missing heading")
}
}
func TestParseUnknownBinary(t *testing.T) {
doc := Parse("blob.bin", "application/octet-stream", []byte{0x00, 0x01, 0x02, 0xFF})
if len(doc.Blocks) != 0 {
t.Error("binary should not produce blocks")
}
if len(doc.Warnings) == 0 {
t.Error("binary should carry explicit warning (fail visible, not silent)")
}
}
// 构造最小 docx(zip + word/document.xml)验证 OOXML 解析。
func TestParseDocx(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
docXML := `<?xml version="1.0"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>总则</w:t></w:r></w:p>
<w:p><w:r><w:t>本文件说明医疗器械文档要求。</w:t></w:r></w:p>
<w:tbl>
<w:tr><w:tc><w:p><w:r><w:t>项</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>值</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:p><w:r><w:t>版本</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>1.0</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:p><w:r><w:t>表格之后的段落。</w:t></w:r></w:p>
</w:body>
</w:document>`
if f, err := zw.Create("word/document.xml"); err == nil {
f.Write([]byte(docXML))
} else {
t.Fatal(err)
}
zw.Close()
doc := Parse("报告.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", buf.Bytes())
if doc.Meta.Parser != "builtin:docx" || len(doc.Warnings) != 0 {
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
}
var heading, para, table, afterTable bool
for _, b := range doc.Blocks {
switch b.Type {
case core.BlockHeading:
heading = b.Level == 1 && b.Text == "总则"
case core.BlockPara:
para = para || strings.Contains(b.Text, "医疗器械")
afterTable = afterTable || strings.Contains(b.Text, "表格之后的段落")
case core.BlockTable:
table = len(b.Rows) == 2 && b.Rows[1][1] == "1.0"
}
}
if !afterTable {
t.Error("paragraph after table missing (table must not reorder content)")
}
// 文档顺序:标题 → 段落 → 表格 → 表后段落(表格原位,不后置)。
var seq []core.DocBlockType
for _, b := range doc.Blocks {
seq = append(seq, b.Type)
}
want := []core.DocBlockType{core.BlockHeading, core.BlockPara, core.BlockTable, core.BlockPara}
if len(seq) != len(want) {
t.Fatalf("block count: got %v want %v", seq, want)
}
for i := range want {
if seq[i] != want[i] {
t.Errorf("block order: got %v want %v", seq, want)
break
}
}
if !heading || !para || !table {
t.Errorf("docx blocks wrong: heading=%v para=%v table=%v blocks=%+v", heading, para, table, doc.Blocks)
}
if !strings.Contains(doc.PlainText(), "医疗器械") {
t.Error("PlainText missing docx text")
}
}
// docxWith 把 body 片段包成最小 docx(zip 内仅 word/document.xml)。
func docxWith(t *testing.T, body string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
f, err := zw.Create("word/document.xml")
if err != nil {
t.Fatal(err)
}
f.Write([]byte(`<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>` + body + `</w:body></w:document>`))
zw.Close()
return buf.Bytes()
}
// 修订与超链接:插入/超链接/域结果内的文字必须保留;删除文字不进正文;
// 含修订时显式告警(SR-A5:禁止静默降级)。
func TestParseDocxTrackedChangesAndHyperlinks(t *testing.T) {
body := `<w:p><w:r><w:t>周期</w:t></w:r><w:ins w:id="1" w:author="a"><w:r><w:t>18</w:t></w:r></w:ins>` +
`<w:del w:id="2" w:author="a"><w:r><w:delText>12</w:delText></w:r></w:del><w:r><w:t>个月</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>见</w:t></w:r><w:hyperlink r:id="rId5"><w:r><w:t>附件A</w:t></w:r></w:hyperlink></w:p>` +
`<w:p><w:fldSimple w:instr="PAGE"><w:r><w:t>3</w:t></w:r></w:fldSimple><w:r><w:t>页</w:t></w:r></w:p>` +
`<w:tbl><w:tr><w:tc><w:p><w:ins w:id="3" w:author="a"><w:r><w:t>新增项</w:t></w:r></w:ins></w:p></w:tc></w:tr></w:tbl>`
doc := Parse("修订稿.docx", "", docxWith(t, body))
var paras []string
var cell string
for _, b := range doc.Blocks {
switch b.Type {
case core.BlockPara:
paras = append(paras, b.Text)
case core.BlockTable:
if len(b.Rows) > 0 && len(b.Rows[0]) > 0 {
cell = b.Rows[0][0]
}
}
}
want := []string{"周期18个月", "见附件A", "3页"}
if strings.Join(paras, "|") != strings.Join(want, "|") {
t.Errorf("paragraphs: got %q want %q", paras, want)
}
if cell != "新增项" {
t.Errorf("table cell inside w:ins: got %q", cell)
}
if strings.Contains(doc.PlainText(), "12个月") {
t.Errorf("deleted text leaked into body: %q", doc.PlainText())
}
warned := false
for _, w := range doc.Warnings {
warned = warned || strings.Contains(w, "修订")
}
if !warned {
t.Errorf("tracked changes must be reported in warnings, got %v", doc.Warnings)
}
}
// 无修订的普通文档不产生修订告警(避免噪音)。
func TestParseDocxNoRevisionNoWarning(t *testing.T) {
doc := Parse("干净.docx", "", docxWith(t, `<w:p><w:r><w:t>正文</w:t></w:r></w:p>`))
if len(doc.Warnings) != 0 {
t.Errorf("clean docx warnings: %v", doc.Warnings)
}
}
func TestParsePDFInvalid(t *testing.T) {
doc := Parse("scan.pdf", "application/pdf", []byte("not a pdf"))
if len(doc.Warnings) == 0 || doc.Meta.Confidence > 0.5 {
t.Errorf("invalid pdf should warn with low confidence: %+v", doc.Meta)
}
}
var _ = xml.Marshal