mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-29 12:09:31 +08:00
fix: Go ingestion migration batch 5 (Parser 1.1/1.7/2.11, Chunker 1.7/1.8/2.6/2.7, Tokenizer 6x fixes) (#17419)
## Summary
Continuation of the Python→Go ingestion pipeline migration (File →
Parser → Chunker → Extractor → Tokenizer). Fixes cover Parser, Chunker,
and Tokenizer gaps identified. Fix page number (0-indexed and 1-index
mixed before fix; use 1-indexed after fix) and chunk order issues.
### Parser
- **Slides TCADP (1.7):** `pptx_tcadp.go` + TCADP branch in
`pptx_parser.go`/`ppt_parser.go` — PowerPoint files now support
`parse_method="tcadp"` via the TCADP cloud service, matching the
spreadsheet-family TCADP pattern. PPT containers pass `"PPT"` as
fileType (not hardcoded `"PPTX"`).
- **Audio default output_format (2.11):** `defaultSetups()` audio
default changed from `"text"` to `"json"`, aligning with Python
`parser.py:232` and `AllowedOutputFormat["audio"]={"json"}`.
- **PDF VLM enhancement (1.1):** `maybeDispatchPDFVisionEnhancement` in
`pdf_vision_dispatch.go` enriches image/table items with IMAGE2TEXT
model descriptions after PDF parsing, mirroring Python
`enhance_media_sections_with_vision`. Semaphore fix: acquire before
goroutine start to prevent unbounded goroutine creation.
- **json family (2.3):** reclassified as Keep Go — `json_parser.go` is a
functional enhancement, not a parity gap.
- **page number:** changed from "mixed use of 1-indexed & 0-indexed" to
"1-indexed"
### Chunker
- **BULLET_PATTERN fallback (1.7):** 4th-level fallback in
`resolveTitleLevels` (`title.go`) detects bullet/numbered-list patterns
(Chinese legal, numbering, English) when outline + regex levels produce
only bodyLevel. Guarded by `allBodyLevel` to never override existing
structure.
- **Tag/One chunker fields (1.8):** `tag.go` sets `TopInt` from source
row index; `one.go` preserves `Positions`/`PDFPositions` from source
items. TSV multi-line RowNum fix: tracks `contentStart` for correct row
attribution.
- **Overlapped_percent normalization (2.6):**
`NormalizeOverlappedPercent` in `schema/chunker.go` mirrors Python
`common/float_utils.py:50-58` — accepts `[0,1)` fraction or `[0,90]`
percent, normalizes to canonical `[0,90]`.
- **Paragraph splitting (2.7):** aligned to Python flow `naive_merge` —
`CRLF` normalization, `splitKeepingDelimiter` preserves sentence
delimiters, single-section merge with token-budget-governed chunking.
- **chunk order:** sort by reading order
### Tokenizer
- **Phantom chunk filtering (Omission 2):** `isPhantomChunk` + filter
loop in `chunksFromTokenizerUpstream` skips zero-value ChunkDocs.
- **Batch size env var (Omission 3):** `embeddingBatchSize()` reads
`TOKENIZER_EMBEDDING_BATCH_SIZE`, defaults to 16.
- **Summary empty check (Diff 5):** `TrimSpace(s) != ""` → `s != ""`,
matching Python truthy check.
- **chunk_order_int all paths (Diff 8):** set unconditionally before
full_text/embedding branching.
- **Timeout default (Diff 10):** `600s` → `60s`, matching Python
`@timeout(60)`.
- **Small maxTokens truncation (Diff 14):** `truncateForEmbedding`
returns `""` when `maxTokens <= 10`, matching Python.
### Code review fixes
- Semaphore acquire moved before goroutine in `pdf_vision_dispatch.go`
(concurrency control)
- Context propagation in `pptx_tcadp.go` (cancellation support)
- Test resolver leak fix in `media_dispatch_test.go` (defer restore)
- Migration history comments removed per AGENTS.md
## Test plan
```
bash build.sh --test ./internal/parser/parser/... ./internal/ingestion/component/...
```
## Notes
- Migration diff tracking: `docs/migration_python_go_diff.md`
- Remaining gaps: Extractor component only (21 items)
This commit is contained in:
@@ -3,7 +3,6 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -599,13 +599,18 @@ func normalizePDFPositions(raw any) [][]any {
|
||||
return normalized
|
||||
}
|
||||
|
||||
// normalizePDFPageNumber converts a DeepDoc 0-indexed page number to the
|
||||
// 1-indexed form stored in _pdf_positions / positions. It is the SINGLE
|
||||
// 0→1 conversion point: DeepDoc (pdf_oxide/pdfium) emits 0-indexed pages,
|
||||
// and every downstream consumer (AddPositions for ES storage,
|
||||
// PositionsFromMatrix for the PDFium render path) expects 1-indexed input.
|
||||
// Adding +1 unconditionally — instead of only for v<=0 — keeps all pages
|
||||
// consistent; the old heuristic left page>=1 unconverted, which AddPositions
|
||||
// then double-incremented and PositionsFromMatrix mis-decremented.
|
||||
func normalizePDFPageNumber(raw any) (int, bool) {
|
||||
switch v := raw.(type) {
|
||||
case int:
|
||||
if v <= 0 {
|
||||
return v + 1, true
|
||||
}
|
||||
return v, true
|
||||
return v + 1, true
|
||||
case int64:
|
||||
return normalizePDFPageNumber(int(v))
|
||||
case float64:
|
||||
|
||||
@@ -85,21 +85,57 @@ func TestPDFParseResultToJSON_NormalizesCoreFields(t *testing.T) {
|
||||
if got, want := res.JSON[1]["doc_type_kwd"], "image"; got != want {
|
||||
t.Fatalf("JSON[1].doc_type_kwd = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := res.JSON[1]["page_number"], 1; got != want {
|
||||
if got, want := res.JSON[1]["page_number"], 2; got != want {
|
||||
t.Fatalf("JSON[1].page_number = %v, want %v", got, want)
|
||||
}
|
||||
secondPDFPositions, ok := res.JSON[1]["_pdf_positions"].([][]any)
|
||||
if !ok {
|
||||
t.Fatalf("JSON[1]._pdf_positions type = %T, want [][]any", res.JSON[1]["_pdf_positions"])
|
||||
}
|
||||
if len(secondPDFPositions) != 1 || secondPDFPositions[0][0] != 1 {
|
||||
t.Fatalf("JSON[1]._pdf_positions = %+v, want canonical 1-based positions", secondPDFPositions)
|
||||
if len(secondPDFPositions) != 1 || secondPDFPositions[0][0] != 2 {
|
||||
t.Fatalf("JSON[1]._pdf_positions = %+v, want canonical 1-based positions (DeepDoc page 1 → 2)", secondPDFPositions)
|
||||
}
|
||||
if got, want := res.JSON[1]["image"], "data:image/png;base64,aGVsbG8="; got != want {
|
||||
t.Fatalf("JSON[1].image = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizePDFPageNumber_UnconditionalIncrement pins the contract that
|
||||
// DeepDoc emits 0-indexed page numbers and normalizePDFPageNumber is the
|
||||
// SINGLE conversion point to 1-indexed. It must add +1 unconditionally —
|
||||
// not just for v<=0 — so that downstream AddPositions (a passthrough) and
|
||||
// PositionsFromMatrix (which subtracts 1 for the 0-indexed PDFium engine)
|
||||
// each see a consistent 1-indexed value.
|
||||
func TestNormalizePDFPageNumber_UnconditionalIncrement(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in any
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{"zero (first page, 0-indexed)", 0, 1, true},
|
||||
{"one (second page, 0-indexed)", 1, 2, true},
|
||||
{"five", 5, 6, true},
|
||||
{"int64", int64(2), 3, true},
|
||||
{"float64", float64(3), 4, true},
|
||||
{"page list takes last element", []any{float64(0), float64(1)}, 2, true},
|
||||
{"int list takes last element", []int{0, 1, 2}, 3, true},
|
||||
{"empty list", []any{}, 0, false},
|
||||
{"non-numeric", "x", 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := normalizePDFPageNumber(tc.in)
|
||||
if ok != tc.ok {
|
||||
t.Fatalf("ok = %v, want %v", ok, tc.ok)
|
||||
}
|
||||
if ok && got != tc.want {
|
||||
t.Errorf("got = %d, want %d (unconditional +1)", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFParseResultToJSON_PreservesPositivePageNumbers(t *testing.T) {
|
||||
parsed := &deepdoctype.ParseResult{
|
||||
Sections: []deepdoctype.Section{
|
||||
@@ -120,8 +156,10 @@ func TestPDFParseResultToJSON_PreservesPositivePageNumbers(t *testing.T) {
|
||||
}
|
||||
|
||||
res := pdfParseResultToJSON("one-based.pdf", parsed)
|
||||
if got, want := res.JSON[0]["page_number"], 3; got != want {
|
||||
t.Fatalf("JSON[1].page_number = %v, want %v", got, want)
|
||||
// DeepDoc page 3 is 0-indexed (the 4th page); normalizePDFPageNumber
|
||||
// converts it to 1-indexed page 4.
|
||||
if got, want := res.JSON[0]["page_number"], 4; got != want {
|
||||
t.Fatalf("JSON[0].page_number = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := res.JSON[0]["doc_type_kwd"], "table"; got != want {
|
||||
t.Fatalf("JSON[0].doc_type_kwd = %v, want %v", got, want)
|
||||
@@ -167,12 +205,18 @@ func TestPDFParseResultToJSON_DefaultKeepsHeaderFooterLikePython(t *testing.T) {
|
||||
if len(res.JSON) != 3 {
|
||||
t.Fatalf("JSON len = %d, want 3", len(res.JSON))
|
||||
}
|
||||
// Sections are now sorted by (page, top, left). Header and Footer have
|
||||
// no position data (page=0, top=0), Body has top=30, so the sorted order
|
||||
// is Header/Footer (tied top=0, stable) then Body (top=30).
|
||||
if got, want := res.JSON[0]["text"], "Header"; got != want {
|
||||
t.Fatalf("JSON[0].text = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := res.JSON[1]["text"], "Body"; got != want {
|
||||
if got, want := res.JSON[1]["text"], "Footer"; got != want {
|
||||
t.Fatalf("JSON[1].text = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := res.JSON[2]["text"], "Body"; got != want {
|
||||
t.Fatalf("JSON[2].text = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFParseResultToJSONWithOptions_FiltersHeaderFooterWhenEnabled(t *testing.T) {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -77,6 +77,9 @@ func parsePDFWithTCADP(filename string, data []byte, parser *PDFParser) ParseRes
|
||||
if err != nil {
|
||||
return ParseResult{Err: fmt.Errorf("parser: TCADP read zip: %w", err)}
|
||||
}
|
||||
if downloadResp.StatusCode >= 300 {
|
||||
return ParseResult{Err: fmt.Errorf("parser: TCADP download HTTP %d: %s", downloadResp.StatusCode, string(zipBytes))}
|
||||
}
|
||||
items, pageCount, err := tcadpItemsFromZip(zipBytes)
|
||||
if err != nil {
|
||||
return ParseResult{Err: err}
|
||||
@@ -143,6 +146,18 @@ func tcadpAnyToItems(raw any) []map[string]any {
|
||||
case map[string]any:
|
||||
text := strings.TrimSpace(stringValue(v["content"]))
|
||||
contentType := strings.ToLower(strings.TrimSpace(stringValue(v["type"])))
|
||||
page := extractTCADPPage(v)
|
||||
emit := func(text, docType, layout string) []map[string]any {
|
||||
m := map[string]any{"text": text, "doc_type_kwd": docType, "layout": layout}
|
||||
if page > 0 {
|
||||
// 1-indexed 5-tuple. AddPositions is a passthrough so
|
||||
// the final position_int / page_num_int carry the same
|
||||
// 1-indexed page number the caller passes. Mirrors
|
||||
// Python presentation.py:148-149.
|
||||
m["positions"] = []float64{float64(page), 0, 0, 0, 0}
|
||||
}
|
||||
return []map[string]any{m}
|
||||
}
|
||||
switch contentType {
|
||||
case "table":
|
||||
if text == "" {
|
||||
@@ -151,28 +166,42 @@ func tcadpAnyToItems(raw any) []map[string]any {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return []map[string]any{{"text": text, "doc_type_kwd": "table", "layout": "table"}}
|
||||
return emit(text, "table", "table")
|
||||
case "image":
|
||||
caption := strings.TrimSpace(stringValue(v["caption"]))
|
||||
if caption == "" {
|
||||
caption = "[Image]"
|
||||
}
|
||||
return []map[string]any{{"text": caption, "doc_type_kwd": "image", "layout": "figure"}}
|
||||
return emit(caption, "image", "figure")
|
||||
case "equation":
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return []map[string]any{{"text": "$$" + text + "$$", "doc_type_kwd": "text", "layout": "equation"}}
|
||||
return emit("$$"+text+"$$", "text", "equation")
|
||||
default:
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return []map[string]any{{"text": text, "doc_type_kwd": "text", "layout": "text"}}
|
||||
return emit(text, "text", "text")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractTCADPPage returns the 1-indexed page number carried by a raw TCADP
|
||||
// element, using the same key set collectPDFPageNumbers walks
|
||||
// (pdf_parser_remote_common.go). It returns 0 when the element has no page
|
||||
// information (e.g. spreadsheet TCADP), so callers can skip position emission
|
||||
// and remain parity-correct with Python (table.py sets no page either).
|
||||
func extractTCADPPage(v map[string]any) int {
|
||||
for _, key := range []string{"page_number", "page_num", "page_no", "page_index", "page_idx", "page"} {
|
||||
if page := int(numberValue(v[key])); page > 0 {
|
||||
return page
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func tcadpTableRowsText(raw any) string {
|
||||
table, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
|
||||
@@ -86,6 +86,102 @@ func TestPDFParser_ParseWithResult_TCADPRequiresAPIServer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTCADPAnyToItems_PropagatesPageNumber verifies the fix for migration
|
||||
// : per-slide page numbers present in the raw TCADP response
|
||||
// must be attached to each chunk item as "positions" (a 1-indexed 5-tuple
|
||||
// [page, 0, 0, 0, 0]). The shared ingestion pipeline
|
||||
// (processChunkPositions -> AddPositions, a passthrough) derives
|
||||
// top_int=[0], position_int=[[page,0,0,0,0]] and page_num_int=[page] from
|
||||
// this field. Elements without a page number (e.g. spreadsheet TCADP)
|
||||
// must NOT receive a positions field.
|
||||
func TestTCADPAnyToItems_PropagatesPageNumber(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw any
|
||||
wantPos []float64 // nil => positions must be absent
|
||||
}{
|
||||
{
|
||||
name: "text element with page_number",
|
||||
raw: map[string]any{"content": "slide text", "type": "text", "page_number": 3},
|
||||
wantPos: []float64{3, 0, 0, 0, 0}, // 1-indexed
|
||||
},
|
||||
{
|
||||
name: "image element with page_number",
|
||||
raw: map[string]any{"caption": "fig", "type": "image", "page_number": 5},
|
||||
wantPos: []float64{5, 0, 0, 0, 0},
|
||||
},
|
||||
{
|
||||
name: "table element with page_number",
|
||||
raw: map[string]any{"type": "table", "table_data": map[string]any{"rows": []any{[]any{"a", "b"}}}, "page_number": 2},
|
||||
wantPos: []float64{2, 0, 0, 0, 0},
|
||||
},
|
||||
{
|
||||
name: "element without page number gets no positions",
|
||||
raw: map[string]any{"content": "no page", "type": "text"},
|
||||
wantPos: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
items := tcadpAnyToItems(tc.raw)
|
||||
if len(items) == 0 {
|
||||
t.Fatalf("tcadpAnyToItems returned no items")
|
||||
}
|
||||
item := items[0]
|
||||
got, ok := item["positions"].([]float64)
|
||||
if tc.wantPos == nil {
|
||||
if ok {
|
||||
t.Errorf("positions present = %v, want absent (element without page number)", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("positions missing, want %v", tc.wantPos)
|
||||
}
|
||||
if len(got) != len(tc.wantPos) {
|
||||
t.Fatalf("positions len = %d, want %d (%v)", len(got), len(tc.wantPos), tc.wantPos)
|
||||
}
|
||||
for i := range tc.wantPos {
|
||||
if got[i] != tc.wantPos[i] {
|
||||
t.Errorf("positions[%d] = %v, want %v (full: %v)", i, got[i], tc.wantPos[i], got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTCADPAnyToItems_NestedArrayKeepsEachPage verifies that a nested
|
||||
// array of TCADP elements produces one item per element, each carrying
|
||||
// its own page-derived positions — not just the first element.
|
||||
func TestTCADPAnyToItems_NestedArrayKeepsEachPage(t *testing.T) {
|
||||
raw := []any{
|
||||
map[string]any{"content": "p1", "type": "text", "page_number": 1},
|
||||
map[string]any{"content": "p7", "type": "text", "page_number": 7},
|
||||
}
|
||||
items := tcadpAnyToItems(raw)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %d, want 2", len(items))
|
||||
}
|
||||
want := [][]float64{{1, 0, 0, 0, 0}, {7, 0, 0, 0, 0}} // page 1 -> [1,...], page 7 -> [7,...] (1-indexed)
|
||||
for i, w := range want {
|
||||
got, ok := items[i]["positions"].([]float64)
|
||||
if !ok {
|
||||
t.Errorf("items[%d].positions missing, want %v", i, w)
|
||||
continue
|
||||
}
|
||||
if len(got) != len(w) {
|
||||
t.Errorf("items[%d].positions = %v, want len %d", i, got, len(w))
|
||||
continue
|
||||
}
|
||||
for j := range w {
|
||||
if got[j] != w[j] {
|
||||
t.Errorf("items[%d].positions[%d] = %v, want %v", i, j, got[j], w[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tcadpZipFixture(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -26,6 +26,7 @@ func applyPDFPostProcess(result *deepdoctype.ParseResult, opts pdfPostProcessOpt
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
sortSectionsByPosition(result)
|
||||
if opts.enableMultiColumn && opts.pageWidth > 0 {
|
||||
reorderPDFMultiColumn(result, opts.pageWidth, opts.zoom)
|
||||
}
|
||||
@@ -83,6 +84,28 @@ func assignPDFDocTypeKeywords(result *deepdoctype.ParseResult, flatten bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// sortSectionsByPosition reorders sections into reading order: page number,
|
||||
// then vertical position (top), then horizontal position (left). The DeepDoc
|
||||
// layout engine does not guarantee reading order in its output, so this sort
|
||||
// ensures the downstream chunker receives items in document order regardless
|
||||
// of the engine's internal extraction sequence.
|
||||
func sortSectionsByPosition(result *deepdoctype.ParseResult) {
|
||||
if result == nil || len(result.Sections) < 2 {
|
||||
return
|
||||
}
|
||||
sort.SliceStable(result.Sections, func(i, j int) bool {
|
||||
pi, pj := firstSectionPage(result.Sections[i]), firstSectionPage(result.Sections[j])
|
||||
if pi != pj {
|
||||
return pi < pj
|
||||
}
|
||||
ti, tj := firstSectionTop(result.Sections[i]), firstSectionTop(result.Sections[j])
|
||||
if math.Abs(ti-tj) > 1e-6 {
|
||||
return ti < tj
|
||||
}
|
||||
return firstSectionLeft(result.Sections[i]) < firstSectionLeft(result.Sections[j])
|
||||
})
|
||||
}
|
||||
|
||||
// applyRemoveTOC mirrors Python parser.py:663-681 three-way dispatch:
|
||||
// - No outlines → pattern-based remove_toc on all sections
|
||||
// - First outline on page 1 → outline-based remove_toc_pdf
|
||||
|
||||
@@ -20,21 +20,32 @@ package parser
|
||||
|
||||
import "context"
|
||||
|
||||
type PPTParser struct{}
|
||||
// PPTParser delegates to PPTXParser which handles both OLE binary
|
||||
// (.ppt) and OOXML (.pptx) containers uniformly
|
||||
type PPTParser struct {
|
||||
pptx PPTXParser
|
||||
}
|
||||
|
||||
func NewPPTParser() *PPTParser {
|
||||
return &PPTParser{}
|
||||
return &PPTParser{pptx: PPTXParser{format: "ppt"}}
|
||||
}
|
||||
|
||||
func (p *PPTParser) String() string {
|
||||
return "PPTParser"
|
||||
}
|
||||
|
||||
// ConfigureFromSetup forwards the slides-family setup map to the
|
||||
// underlying PPTXParser so both .ppt and .pptx containers share
|
||||
// the same TCADP configuration
|
||||
func (p *PPTParser) ConfigureFromSetup(setup map[string]any) {
|
||||
p.pptx.ConfigureFromSetup(setup)
|
||||
}
|
||||
|
||||
// ParseWithResult delegates to PPTXParser's structured output
|
||||
// for the legacy PPT format using the "ppt" container format
|
||||
// hint (OLE binary). The two file families differ only in the
|
||||
// binary container; the python parser.py:slides branch treats
|
||||
// them uniformly.
|
||||
func (p *PPTParser) ParseWithResult(ctx context.Context, filename string, data []byte) ParseResult {
|
||||
return (&PPTXParser{format: "ppt"}).ParseWithResult(ctx, filename, data)
|
||||
return p.pptx.ParseWithResult(ctx, filename, data)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
officeOxide "github.com/yfedoseev/office_oxide/go"
|
||||
@@ -63,6 +69,155 @@ func TestPPTParser_ParseWithResult_CGO(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPPTParser_TCADPFileType verifies that when a .ppt file is routed
|
||||
// through PPTParser with parse_method="tcadp", the underlying
|
||||
// PPTXParser must pass "PPT" as the file_type to TCADP, not "PPTX".
|
||||
// This test verifies format propagation from PPTParser through
|
||||
// ConfigureFromSetup to the embedded PPTXParser.
|
||||
func TestPPTParser_TCADPFileType(t *testing.T) {
|
||||
// PPTParser delegates to PPTXParser{format:"ppt"}.
|
||||
p := NewPPTParser()
|
||||
setup := map[string]any{
|
||||
"parse_method": "tcadp",
|
||||
"output_format": "json",
|
||||
}
|
||||
p.ConfigureFromSetup(setup)
|
||||
|
||||
// Verify the embedded PPTXParser received the config and keeps
|
||||
// format="ppt" (which maps to "PPT" in TCADP fileType).
|
||||
if p.pptx.format != "ppt" {
|
||||
t.Errorf("PPTParser.pptx.format = %q, want %q", p.pptx.format, "ppt")
|
||||
}
|
||||
if p.pptx.ParseMethod != "tcadp" {
|
||||
t.Errorf("PPTParser.pptx.ParseMethod = %q, want %q", p.pptx.ParseMethod, "tcadp")
|
||||
}
|
||||
if p.pptx.OutputFormat != "json" {
|
||||
t.Errorf("PPTParser.pptx.OutputFormat = %q, want %q", p.pptx.OutputFormat, "json")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPPTXParser_TCADPFileType verifies that a PPTXParser
|
||||
// with format="pptx" must derive fileType "PPTX" for TCADP calls.
|
||||
func TestPPTXParser_TCADPFileType(t *testing.T) {
|
||||
p := NewPPTXParser()
|
||||
if p.format != "pptx" {
|
||||
t.Fatalf("NewPPTXParser().format = %q, want pptx", p.format)
|
||||
}
|
||||
setup := map[string]any{
|
||||
"parse_method": "tcadp",
|
||||
"output_format": "json",
|
||||
}
|
||||
p.ConfigureFromSetup(setup)
|
||||
if p.ParseMethod != "tcadp" {
|
||||
t.Errorf("ParseMethod = %q, want tcadp", p.ParseMethod)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPPTParser_TCADPIntegration drives the end-to-end TCADP path for a
|
||||
// .ppt file: PPTParser must POST file_type="PPT" to the reconstruct
|
||||
// endpoint and then process the returned ZIP artifact into JSON items.
|
||||
func TestPPTParser_TCADPIntegration(t *testing.T) {
|
||||
testPresentationTCADPIntegration(t, NewPPTParser(), "PPT", "presentation.ppt")
|
||||
}
|
||||
|
||||
// TestPPTXParser_TCADPIntegration drives the end-to-end TCADP path for a
|
||||
// .pptx file: PPTXParser must POST file_type="PPTX" to the reconstruct
|
||||
// endpoint and then process the returned ZIP artifact into JSON items.
|
||||
func TestPPTXParser_TCADPIntegration(t *testing.T) {
|
||||
testPresentationTCADPIntegration(t, NewPPTXParser(), "PPTX", "presentation.pptx")
|
||||
}
|
||||
|
||||
// tcadpPresentationParser is the shared contract of PPTParser and
|
||||
// PPTXParser used by the TCADP integration helper below.
|
||||
type tcadpPresentationParser interface {
|
||||
ConfigureFromSetup(setup map[string]any)
|
||||
ParseWithResult(ctx context.Context, filename string, data []byte) ParseResult
|
||||
}
|
||||
|
||||
func testPresentationTCADPIntegration(t *testing.T, p tcadpPresentationParser, wantFileType, filename string) {
|
||||
t.Helper()
|
||||
zipPayload := tcadpZipFixture(t)
|
||||
var gotFileType string
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/reconstruct_document":
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read reconstruct request: %v", err)
|
||||
} else {
|
||||
var req struct {
|
||||
FileType string `json:"file_type"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Errorf("decode reconstruct request: %v", err)
|
||||
}
|
||||
gotFileType = req.FileType
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"DocumentRecognizeResultUrl":"` + server.URL + `/download.zip"}`))
|
||||
case "/download.zip":
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
_, _ = w.Write(zipPayload)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p.ConfigureFromSetup(map[string]any{
|
||||
"parse_method": "tcadp",
|
||||
"output_format": "json",
|
||||
"tcadp_apiserver": server.URL,
|
||||
})
|
||||
ctx := t.Context()
|
||||
res := p.ParseWithResult(ctx, filename, []byte("dummy-presentation-bytes"))
|
||||
if res.Err != nil {
|
||||
t.Fatalf("ParseWithResult: %v", res.Err)
|
||||
}
|
||||
if gotFileType != wantFileType {
|
||||
t.Errorf("TCADP request file_type = %q, want %q", gotFileType, wantFileType)
|
||||
}
|
||||
if res.OutputFormat != "json" {
|
||||
t.Errorf("OutputFormat = %q, want json", res.OutputFormat)
|
||||
}
|
||||
if len(res.JSON) == 0 {
|
||||
t.Fatalf("JSON items = 0, want the fixture's parsed content")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPPTXParser_TCADPDownloadHTTPError verifies that a non-2xx response
|
||||
// from the TCADP download endpoint is surfaced as an explicit error rather
|
||||
// than parsed as a (malformed) ZIP artifact.
|
||||
func TestPPTXParser_TCADPDownloadHTTPError(t *testing.T) {
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/reconstruct_document":
|
||||
_, _ = w.Write([]byte(`{"DocumentRecognizeResultUrl":"` + server.URL + `/download.zip"}`))
|
||||
case "/download.zip":
|
||||
http.Error(w, "upstream failure", http.StatusInternalServerError)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewPPTXParser()
|
||||
p.ConfigureFromSetup(map[string]any{
|
||||
"parse_method": "tcadp",
|
||||
"output_format": "json",
|
||||
"tcadp_apiserver": server.URL,
|
||||
})
|
||||
ctx := t.Context()
|
||||
res := p.ParseWithResult(ctx, "a.pptx", []byte("dummy"))
|
||||
if res.Err == nil {
|
||||
t.Fatal("ParseWithResult: want error for non-2xx download, got nil")
|
||||
}
|
||||
if !strings.Contains(res.Err.Error(), "download HTTP") {
|
||||
t.Errorf("err = %v, want error mentioning 'download HTTP'", res.Err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildPPTX creates a minimal valid PPTX document with one slide
|
||||
// containing the given text, using office_oxide's PptxWriter.
|
||||
func buildPPTX(t *testing.T, text string) []byte {
|
||||
|
||||
@@ -33,6 +33,14 @@ import (
|
||||
// OLE format.
|
||||
type PPTXParser struct {
|
||||
format string
|
||||
|
||||
// TCADP cloud-parsing configuration
|
||||
ParseMethod string
|
||||
TCADPAPIServer string
|
||||
TCADPAPIKey string
|
||||
TCADPTableResultType string
|
||||
TCADPMarkdownImageResponseType string
|
||||
OutputFormat string
|
||||
}
|
||||
|
||||
func NewPPTXParser() *PPTXParser {
|
||||
@@ -43,10 +51,62 @@ func (p *PPTXParser) String() string {
|
||||
return "PPTXParser"
|
||||
}
|
||||
|
||||
// ConfigureFromSetup reads the slides-family setup map. Mirrors the
|
||||
// XLSXParser ConfigureFromSetup pattern
|
||||
func (p *PPTXParser) ConfigureFromSetup(setup map[string]any) {
|
||||
if p == nil || setup == nil {
|
||||
return
|
||||
}
|
||||
if v, ok := setup["parse_method"].(string); ok {
|
||||
p.ParseMethod = v
|
||||
}
|
||||
if v, ok := setup["tcadp_apiserver"].(string); ok {
|
||||
p.TCADPAPIServer = v
|
||||
}
|
||||
if v, ok := setup["tcadp_api_key"].(string); ok {
|
||||
p.TCADPAPIKey = v
|
||||
}
|
||||
if v, ok := setup["table_result_type"].(string); ok {
|
||||
p.TCADPTableResultType = v
|
||||
}
|
||||
if v, ok := setup["markdown_image_response_type"].(string); ok {
|
||||
p.TCADPMarkdownImageResponseType = v
|
||||
}
|
||||
if v, ok := setup["output_format"].(string); ok {
|
||||
p.OutputFormat = v
|
||||
}
|
||||
if p.OutputFormat == "" {
|
||||
p.OutputFormat = "json"
|
||||
}
|
||||
}
|
||||
|
||||
// ParseWithResult emits one JSON item per slide with the slide's
|
||||
// plain text. Mirrors the python parser.py:slides branch which
|
||||
// forces output_format="json" for the slide family.
|
||||
func (p *PPTXParser) ParseWithResult(ctx context.Context, filename string, data []byte) ParseResult {
|
||||
// p == nil guard: the struct is embedded by value in PPTParser and
|
||||
// always created via NewPPTXParser or the "ppt"-format constructor in
|
||||
// PPTParser, so this branch is unreachable from normal call paths.
|
||||
// Kept as defensive guard — a nil dereference here would obscure the
|
||||
// root cause behind a nil-pointer panic.
|
||||
if p == nil {
|
||||
return ParseResult{Err: fmt.Errorf("PPTXParser is nil")}
|
||||
}
|
||||
method := strings.ToLower(strings.TrimSpace(p.ParseMethod))
|
||||
switch method {
|
||||
case "tcadp":
|
||||
return parsePresentationWithTCADP(ctx,
|
||||
filename, data, strings.ToUpper(p.format),
|
||||
p.TCADPAPIServer, p.TCADPAPIKey,
|
||||
p.TCADPTableResultType, p.TCADPMarkdownImageResponseType,
|
||||
p.OutputFormat,
|
||||
)
|
||||
case "", "deepdoc":
|
||||
// Continue with the local office_oxide parser.
|
||||
default:
|
||||
// PDF-specific methods like "paddleocr" / "mineru" are
|
||||
// meaningless for PPTX; treat as default path.
|
||||
}
|
||||
doc, err := officeOxide.OpenFromBytes(data, p.format)
|
||||
if err != nil {
|
||||
return ParseResult{Err: fmt.Errorf("presentation open: %w", err)}
|
||||
|
||||
95
internal/parser/parser/pptx_tcadp.go
Normal file
95
internal/parser/parser/pptx_tcadp.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
models "ragflow/internal/entity/models"
|
||||
)
|
||||
|
||||
// parsePresentationWithTCADP sends binary presentation (PPTX/PPT) data
|
||||
// to the TCADP cloud reconstruction service and returns the structured
|
||||
// parse result. Mirrors the spreadsheet-family parseSpreadsheetWithTCADP
|
||||
// in xls_tcadp.go
|
||||
func parsePresentationWithTCADP(ctx context.Context, filename string, data []byte, fileType string,
|
||||
tcadpAPIServer, tcadpAPIKey, tableResultType, markdownImageResponseType string,
|
||||
outputFormat string,
|
||||
) ParseResult {
|
||||
if len(data) == 0 {
|
||||
return emptyPDFResult(filename)
|
||||
}
|
||||
baseURL := strings.TrimSpace(tcadpAPIServer)
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimSpace(os.Getenv("TCADP_APISERVER"))
|
||||
}
|
||||
if baseURL == "" {
|
||||
return ParseResult{Err: fmt.Errorf("parser: TCADP requires tcadp_apiserver or TCADP_APISERVER")}
|
||||
}
|
||||
apiKey := strings.TrimSpace(tcadpAPIKey)
|
||||
if apiKey == "" {
|
||||
apiKey = strings.TrimSpace(os.Getenv("TCADP_API_KEY"))
|
||||
}
|
||||
requestBody := map[string]any{
|
||||
"file_type": fileType,
|
||||
"file_base64": base64.StdEncoding.EncodeToString(data),
|
||||
"file_start_page_number": 1,
|
||||
"file_end_page_number": 1000,
|
||||
"config": map[string]any{
|
||||
"TableResultType": tableResultType,
|
||||
"MarkdownImageResponseType": markdownImageResponseType,
|
||||
},
|
||||
}
|
||||
resp, err := models.PostJSONRequest(ctx, models.NewDriverHTTPClient(),
|
||||
strings.TrimRight(baseURL, "/")+"/reconstruct_document", bearer(apiKey), requestBody)
|
||||
if err != nil {
|
||||
return ParseResult{Err: fmt.Errorf("parser: TCADP submit: %w", err)}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return ParseResult{Err: fmt.Errorf("parser: TCADP read submit: %w", err)}
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
return ParseResult{Err: fmt.Errorf("parser: TCADP HTTP %d: %s", resp.StatusCode, string(raw))}
|
||||
}
|
||||
var payload struct {
|
||||
DocumentRecognizeResultURL string `json:"DocumentRecognizeResultUrl"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return ParseResult{Err: fmt.Errorf("parser: TCADP decode submit: %w", err)}
|
||||
}
|
||||
if payload.DocumentRecognizeResultURL == "" {
|
||||
return ParseResult{Err: fmt.Errorf("parser: TCADP returned no DocumentRecognizeResultUrl")}
|
||||
}
|
||||
downloadReq, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
payload.DocumentRecognizeResultURL, nil)
|
||||
if err != nil {
|
||||
return ParseResult{Err: fmt.Errorf("parser: TCADP download request: %w", err)}
|
||||
}
|
||||
if auth := bearer(apiKey); auth != "" {
|
||||
downloadReq.Header.Set("Authorization", auth)
|
||||
}
|
||||
downloadResp, err := models.NewDriverHTTPClient().Do(downloadReq)
|
||||
if err != nil {
|
||||
return ParseResult{Err: fmt.Errorf("parser: TCADP download: %w", err)}
|
||||
}
|
||||
defer downloadResp.Body.Close()
|
||||
zipBytes, err := io.ReadAll(downloadResp.Body)
|
||||
if err != nil {
|
||||
return ParseResult{Err: fmt.Errorf("parser: TCADP read zip: %w", err)}
|
||||
}
|
||||
if downloadResp.StatusCode >= 300 {
|
||||
return ParseResult{Err: fmt.Errorf("parser: TCADP download HTTP %d: %s", downloadResp.StatusCode, string(zipBytes))}
|
||||
}
|
||||
items, pageCount, err := tcadpItemsFromZip(zipBytes)
|
||||
if err != nil {
|
||||
return ParseResult{Err: err}
|
||||
}
|
||||
return pdfItemsToResult(filename, items, outputFormat, pageCount)
|
||||
}
|
||||
@@ -75,6 +75,9 @@ func parseSpreadsheetWithTCADP(filename string, data []byte, fileType string, tc
|
||||
if err != nil {
|
||||
return ParseResult{Err: fmt.Errorf("parser: TCADP read zip: %w", err)}
|
||||
}
|
||||
if downloadResp.StatusCode >= 300 {
|
||||
return ParseResult{Err: fmt.Errorf("parser: TCADP download HTTP %d: %s", downloadResp.StatusCode, string(zipBytes))}
|
||||
}
|
||||
items, pageCount, err := tcadpItemsFromZip(zipBytes)
|
||||
if err != nil {
|
||||
return ParseResult{Err: err}
|
||||
|
||||
Reference in New Issue
Block a user