Files
ragflow/internal/parser/parser/docx_postprocess_test.go
Jack 0403d19b5a fix: honor parser params and image VLM system_prompt in Go ingestion (#17334)
## Summary
Fix the Go ingestion pipeline so that several parser setup switches and
the
image VLM prompt are actually honored end-to-end (previously the DSL
fields
existed but the Go code never read them).

- **DOCX** (`docx_parser.go`, `docx_postprocess.go`): read `remove_toc`
and
  `remove_header_footer`; apply to both JSON and markdown output paths
  (outline-based TOC removal with a text-heuristic fallback, plus
  header/footer section filtering).
- **HTML** (`html_parser.go`, `html_postprocess.go`, `text_toc.go`):
read
`remove_header_footer` (pre-parse strip of `<header>`/`<footer>` and
ARIA
`banner`/`contentinfo`) and `remove_toc` (post-parse
`remove_contents_table`
  heuristic).
- **Markdown** (`markdown_parser.go`): read `flatten_media_to_text` and
force
  media blocks to text when enabled.
- **Image VLM** (`media_dispatch.go`): read `system_prompt` instead of
`prompt`
  so the user-configured image VLM prompt is no longer silently dropped
  (`prompt` remains the video family key).

All flags are wired through `ConfigureFromSetup`, which the dispatch
layer
already invokes for every family, so the behavior is live rather than
dead code.

## Test plan
- New unit tests: `docx_postprocess_test.go`, `html_parser_test.go`,
`text_toc_test.go`, `markdown_parser_test.go`, `media_dispatch_test.go`.
- `bash build.sh --test ./internal/parser/parser/...
./internal/ingestion/component/...`

## Notes
- The `File` component is excluded from this migration scope.
- Relates to the Python→Go parity diff (Parser 1.8–1.11, 1.15).
2026-07-24 14:42:26 +08:00

189 lines
6.4 KiB
Go

package parser
import (
"archive/zip"
"bytes"
"encoding/json"
"reflect"
"testing"
)
// helper: build a minimal docx ZIP in-memory with given header/footer XML.
func buildDocxZIP(t *testing.T, headerText, footerText string) []byte {
t.Helper()
buf := new(bytes.Buffer)
zw := zip.NewWriter(buf)
// Minimal required parts for a valid docx: [Content_Types].xml + word/document.xml.
mustWrite := func(name, body string) {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(body))
}
mustWrite("[Content_Types].xml", `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/></Types>`)
mustWrite("word/document.xml", `<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Body text</w:t></w:r></w:p></w:body></w:document>`)
if headerText != "" {
mustWrite("word/header1.xml", `<?xml version="1.0"?><w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:t>`+headerText+`</w:t></w:r></w:p></w:hdr>`)
}
if footerText != "" {
mustWrite("word/footer1.xml", `<?xml version="1.0"?><w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:t>`+footerText+`</w:t></w:r></w:p></w:ftr>`)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestExtractDOCXHeaderFooterTexts(t *testing.T) {
data := buildDocxZIP(t, "Company Confidential", "Page 1")
texts := extractDOCXHeaderFooterTexts(data)
if !texts["Company Confidential"] {
t.Error("missing header text 'Company Confidential'")
}
if !texts["Page 1"] {
t.Error("missing footer text 'Page 1'")
}
}
func TestExtractDOCXHeaderFooterTexts_NoHeaders(t *testing.T) {
data := buildDocxZIP(t, "", "")
texts := extractDOCXHeaderFooterTexts(data)
if len(texts) != 0 {
t.Errorf("expected empty map, got %v", texts)
}
}
func TestExtractDOCXHeaderFooterTexts_Normalization(t *testing.T) {
// Whitespace runs should be collapsed and trimmed.
data := buildDocxZIP(t, " Multiple Spaces ", "")
texts := extractDOCXHeaderFooterTexts(data)
if !texts["Multiple Spaces"] {
t.Errorf("expected normalized 'Multiple Spaces', got %v", texts)
}
}
// TestRemoveDOCXHeaderFooterSections filters JSON items whose
// normalized text exactly matches a header/footer text.
func TestRemoveDOCXHeaderFooterSections(t *testing.T) {
items := []map[string]any{
{"text": "Company Confidential"},
{"text": "Real content"},
{"text": "Page 1"},
{"text": "More content"},
}
hfTexts := map[string]bool{"Company Confidential": true, "Page 1": true}
got := removeDOCXHeaderFooterSections(items, hfTexts)
want := []map[string]any{
{"text": "Real content"},
{"text": "More content"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
}
// TestRemoveDOCXHeaderFooterSections_EmptyHF keeps all items when no
// header/footer texts are provided.
func TestRemoveDOCXHeaderFooterSections_EmptyHF(t *testing.T) {
items := []map[string]any{{"text": "A"}, {"text": "B"}}
got := removeDOCXHeaderFooterSections(items, nil)
if !reflect.DeepEqual(got, items) {
t.Errorf("expected unchanged, got %+v", got)
}
}
// TestExtractDOCXOutlines verifies heading elements are extracted
// from the office_oxide IR as (title, level) outlines.
func TestExtractDOCXOutlines(t *testing.T) {
irJSON := `{"sections":[{"title":"","elements":[
{"type":"heading","level":1,"content":[{"type":"text","text":"第一章 概述"}]},
{"type":"paragraph","content":[{"type":"text","text":"正文"}]},
{"type":"heading","level":2,"content":[{"type":"text","text":"1.1 背景"}]}
]}]}`
outlines := extractDOCXOutlines(irJSON)
want := []docxOutline{{Title: "第一章 概述", Level: 0}, {Title: "1.1 背景", Level: 1}}
if !reflect.DeepEqual(outlines, want) {
t.Errorf("got %+v, want %+v", outlines, want)
}
}
// TestRemoveTOCWord_NoOutlines delegates to removeContentsTable when
// no outlines are available (mirrors Python utils.py:263-264).
func TestRemoveTOCWord_NoOutlines(t *testing.T) {
items := []map[string]any{
{"text": "前言"},
{"text": "目录"},
{"text": "第一章 概述"},
{"text": "正文"},
}
got := removeTOCWord(items, nil, false)
want := []map[string]any{
{"text": "前言"},
{"text": "正文"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
}
// TestRemoveTOCWord_WithOutlines deletes the TOC heading and
// following entries that match outline-title prefixes or the
// "dots + page number" regex (mirrors Python utils.py:115-144).
func TestRemoveTOCWord_WithOutlines(t *testing.T) {
items := []map[string]any{
{"text": "前言"},
{"text": "目录"},
{"text": "第一章 概述"},
{"text": "第一章 概述 .......... 1"},
{"text": "第二章 方法 .......... 5"},
{"text": "正文开始"},
}
outlines := []docxOutline{
{Title: "第一章 概述", Level: 0},
{Title: "第二章 方法", Level: 0},
}
got := removeTOCWord(items, outlines, false)
// "目录" heading + "第一章 概述" (outline prefix match) +
// "第一章 概述 .......... 1" (prefix match) +
// "第二章 方法 .......... 5" (prefix match + dot+page regex) removed.
// "前言" and "正文开始" survive.
want := []map[string]any{
{"text": "前言"},
{"text": "正文开始"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
}
// TestRemoveTOCWord_NoTOCHeading keeps items unchanged when no TOC
// heading is found (only removeContentsTable fallback runs).
func TestRemoveTOCWord_NoTOCHeading(t *testing.T) {
items := []map[string]any{
{"text": "第一章 概述"},
{"text": "正文"},
}
outlines := []docxOutline{{Title: "第一章 概述", Level: 0}}
got := removeTOCWord(items, outlines, false)
// No "目录/Contents" heading → outline-prefix loop skipped;
// removeContentsTable fallback finds no TOC heading either.
want := []map[string]any{
{"text": "第一章 概述"},
{"text": "正文"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
}
func TestDocxOutline_JSONRoundtrip(t *testing.T) {
o := docxOutline{Title: "Heading 1", Level: 2}
b, _ := json.Marshal(o)
var got docxOutline
_ = json.Unmarshal(b, &got)
if !reflect.DeepEqual(got, o) {
t.Errorf("roundtrip got %+v, want %+v", got, o)
}
}