Files
ragflow/internal/parser/parser/docx_ir_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

254 lines
9.5 KiB
Go

package parser
import (
"encoding/json"
"reflect"
"testing"
)
// TestBuildDOCXJSONSections_FromJSON feeds an office_oxide IR JSON
// string directly into buildDOCXJSONSections and asserts the
// paragraph / heading / image / table / list branches. This pins the
// pure-Go IR → sections transform without requiring the office_oxide
// native library, so it runs under CGO_ENABLED=0.
func TestBuildDOCXJSONSections_FromJSON(t *testing.T) {
// "data":"aGVsbG8=" is base64 for "hello"; buildDOCXJSONSections
// re-encodes el.Data, so the item's "image" must equal "aGVsbG8=".
irJSON := `{"sections":[{"title":"","elements":[
{"type":"heading","level":1,"content":[{"type":"text","text":"Title"}]},
{"type":"paragraph","content":[{"type":"text","text":"Hello"}]},
{"type":"image","data":"aGVsbG8="},
{"type":"table","rows":[{"cells":[{"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]},
{"type":"list","items":[{"content":[{"type":"paragraph","content":[{"type":"text","text":"item1"}]}]}]}
]}]}`
got := buildDOCXJSONSections(irJSON)
want := []map[string]any{
{"text": "Title", "image": nil, "doc_type_kwd": "text", "ck_type": "heading"},
{"text": "Hello", "image": nil, "doc_type_kwd": "text"},
{"text": "", "image": "aGVsbG8=", "doc_type_kwd": "image"},
{"text": "<table><tr><td>cell</td></tr></table>", "image": nil, "doc_type_kwd": "table"},
{"text": "item1", "image": nil, "doc_type_kwd": "text"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildDOCXJSONSections mismatch:\n got: %+v\nwant: %+v", got, want)
}
}
// TestBuildDOCXJSONSections_EmptyTableSkipped verifies that a table
// with no rows renders as "<table></table>" and is dropped by the
// "if html == \"<table></table>\" { continue }" guard.
func TestBuildDOCXJSONSections_EmptyTableSkipped(t *testing.T) {
irJSON := `{"sections":[{"elements":[
{"type":"paragraph","content":[{"type":"text","text":"keep"}]},
{"type":"table","rows":[]}
]}]}`
got := buildDOCXJSONSections(irJSON)
if len(got) != 1 || got[0]["text"] != "keep" {
t.Fatalf("expected only the paragraph to survive, got %+v", got)
}
}
// TestJoinDOCXIRRuns pins that only text-type runs are concatenated;
// non-text runs (e.g. nested image runs) are skipped.
func TestJoinDOCXIRRuns(t *testing.T) {
runs := []docxIRRun{
{Type: "text", Text: "Hello"},
{Type: "image", Text: "ignored"},
{Type: "text", Text: " World"},
}
if got := joinDOCXIRRuns(runs); got != "Hello World" {
t.Fatalf("joinDOCXIRRuns = %q, want %q", got, "Hello World")
}
if got := joinDOCXIRRuns(nil); got != "" {
t.Fatalf("joinDOCXIRRuns(nil) = %q, want empty", got)
}
}
// TestExtractDOCXFiguresFromIR verifies the image-figure context
// extraction: an image block carries the immediately surrounding text
// as ContextAbove / ContextBelow / Marker. Pure-Go, runs under !cgo.
func TestExtractDOCXFiguresFromIR(t *testing.T) {
irJSON := `{"sections":[{"elements":[
{"type":"paragraph","content":[{"type":"text","text":"before"}]},
{"type":"image","data":"aGVsbG8="},
{"type":"paragraph","content":[{"type":"text","text":"after"}]}
]}]}`
figs := extractDOCXFiguresFromIR(irJSON)
if len(figs) != 1 {
t.Fatalf("expected 1 figure, got %d", len(figs))
}
fig := figs[0]
want := DOCXFigure{
Image: "aGVsbG8=",
ContextAbove: "before",
ContextBelow: "after",
Marker: "before",
}
if !reflect.DeepEqual(fig, want) {
t.Fatalf("figure mismatch:\n got: %+v\nwant: %+v", fig, want)
}
}
// TestExtractDOCXFiguresFromIR_NoImage returns nil when the IR has no
// image blocks.
func TestExtractDOCXFiguresFromIR_NoImage(t *testing.T) {
irJSON := `{"sections":[{"elements":[
{"type":"paragraph","content":[{"type":"text","text":"only text"}]}
]}]}`
if figs := extractDOCXFiguresFromIR(irJSON); figs != nil {
t.Fatalf("expected nil, got %+v", figs)
}
}
// TestExtractDOCXFiguresFromIR_BadJSON returns nil on unparseable IR,
// mirroring the json.Unmarshal error guard.
func TestExtractDOCXFiguresFromIR_BadJSON(t *testing.T) {
if figs := extractDOCXFiguresFromIR("{not json"); figs != nil {
t.Fatalf("expected nil for bad JSON, got %+v", figs)
}
}
// TestExtractDOCXFiguresFromIR_NonParagraphContext verifies that
// tables, lists, and text boxes adjacent to an image contribute their
// text to ContextAbove / ContextBelow. Previously the flatten loop
// called joinDOCXIRRuns(el.contentRuns()) for every non-image element,
// which returned "" for table/list/text_box (their Content is not text
// runs), silently dropping the surrounding VLM context.
func TestExtractDOCXFiguresFromIR_NonParagraphContext(t *testing.T) {
irJSON := `{"sections":[{"elements":[
{"type":"table","rows":[{"cells":[{"content":[{"type":"paragraph","content":[{"type":"text","text":"table cell"}]}]}]}]},
{"type":"image","data":"aGVsbG8="},
{"type":"list","items":[{"content":[{"type":"paragraph","content":[{"type":"text","text":"list item"}]}]}]},
{"type":"text_box","content":[{"type":"paragraph","content":[{"type":"text","text":"box text"}]}]}
]}]}`
figs := extractDOCXFiguresFromIR(irJSON)
if len(figs) != 1 {
t.Fatalf("expected 1 figure, got %d", len(figs))
}
fig := figs[0]
if fig.ContextAbove != "table cell" {
t.Errorf("ContextAbove = %q, want %q", fig.ContextAbove, "table cell")
}
// Below the image: list item then text_box, joined by newline.
if fig.ContextBelow != "list item\nbox text" {
t.Errorf("ContextBelow = %q, want %q", fig.ContextBelow, "list item\nbox text")
}
}
// TestCollectDOCXText_BoundedByMaxLen pins that the joined context
// never exceeds maxLen runes, even though newline separators are
// inserted between blocks (they are not counted by the per-block
// `remaining` decrement). Previously 512 one-rune blocks produced
// 1023 chars (512 runes + 511 separators).
func TestCollectDOCXText_BoundedByMaxLen(t *testing.T) {
// 600 one-rune text blocks before and after an image block.
const maxLen = 512
var flat []flatBlock
for i := 0; i < 600; i++ {
flat = append(flat, flatBlock{text: "x"})
}
imgIdx := len(flat)
flat = append(flat, flatBlock{image: "img"})
for i := 0; i < 600; i++ {
flat = append(flat, flatBlock{text: "x"})
}
prev := collectDOCXPrevText(flat, imgIdx, maxLen)
next := collectDOCXNextText(flat, imgIdx, maxLen)
if r := []rune(prev); len(r) > maxLen {
t.Errorf("prev context = %d runes, want <= %d", len(r), maxLen)
}
if r := []rune(next); len(r) > maxLen {
t.Errorf("next context = %d runes, want <= %d", len(r), maxLen)
}
// Both must still carry content (the closest blocks survive).
if prev == "" {
t.Error("prev context empty; expected some text")
}
if next == "" {
t.Error("next context empty; expected some text")
}
}
// rawJSON marshals v and returns it as json.RawMessage, for populating
// docxIRElement.Content in tests. Defined here (tagless) so it is
// available to both cgo and !cgo test files in package parser.
func rawJSON(v any) json.RawMessage {
data, _ := json.Marshal(v)
return json.RawMessage(data)
}
// para is a test helper returning a paragraph element whose single text
// run carries the given string.
func para(text string) docxIRElement {
return docxIRElement{
Type: "paragraph",
Content: rawJSON([]docxIRRun{{Type: "text", Text: text}}),
}
}
// TestExtractTextFromListItem_Nested verifies that nested sub-list text
// is decoded and recursed, not silently dropped. The IR shape mirrors
// office_oxide ir::ListItem { content, nested: Option<List> } where
// List { items: [ListItem, ...] } is recursive.
func TestExtractTextFromListItem_Nested(t *testing.T) {
// Top-level item "Top" carries a nested sub-list with two children
// "Child A" and "Child B"; Child B itself has a deeper sub-list
// "Grandchild" to exercise multi-level recursion.
item := docxIRListItem{
Content: []docxIRElement{para("Top")},
Nested: &docxIRList{Items: []docxIRListItem{
{Content: []docxIRElement{para("Child A")}},
{
Content: []docxIRElement{para("Child B")},
Nested: &docxIRList{Items: []docxIRListItem{
{Content: []docxIRElement{para("Grandchild")}},
}},
},
}},
}
got := extractTextFromListItem(item)
want := "Top\nChild A\nChild B\nGrandchild"
if got != want {
t.Errorf("extractTextFromListItem(nested) = %q, want %q", got, want)
}
}
// TestExtractTextFromListItem_NestedNull pins that "nested": null (the
// serde serialization of Option::None) does not trip recursion and the
// item's own text still comes through.
func TestExtractTextFromListItem_NestedNull(t *testing.T) {
irJSON := `{"sections":[{"elements":[
{"type":"list","items":[
{"content":[{"type":"paragraph","content":[{"type":"text","text":"only item"}]}],"nested":null}
]}
]}]}`
sections := buildDOCXJSONSections(irJSON)
if len(sections) != 1 || sections[0]["text"] != "only item" {
t.Fatalf("expected single item \"only item\", got %+v", sections)
}
}
// TestBuildDOCXJSONSections_NestedList verifies end-to-end that a
// multi-level list survives into the section output with all levels.
func TestBuildDOCXJSONSections_NestedList(t *testing.T) {
irJSON := `{"sections":[{"elements":[
{"type":"list","items":[
{"content":[{"type":"paragraph","content":[{"type":"text","text":"L1"}]}],
"nested":{"items":[
{"content":[{"type":"paragraph","content":[{"type":"text","text":"L2a"}]}]},
{"content":[{"type":"paragraph","content":[{"type":"text","text":"L2b"}]}]}
]}}
]}
]}]}`
sections := buildDOCXJSONSections(irJSON)
if len(sections) != 1 {
t.Fatalf("expected 1 section, got %d: %+v", len(sections), sections)
}
if got := sections[0]["text"]; got != "L1\nL2a\nL2b" {
t.Errorf("nested list text = %q, want %q", got, "L1\nL2a\nL2b")
}
}