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:
Jack
2026-07-28 11:12:52 +08:00
committed by GitHub
parent 326893811a
commit 9b0719fa94
41 changed files with 1544 additions and 332 deletions

View File

@@ -3,7 +3,6 @@
package parser
import (
"context"
"errors"
"testing"
)

View File

@@ -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:

View File

@@ -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) {

View File

@@ -3,7 +3,6 @@
package parser
import (
"context"
"errors"
"testing"
)

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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)}

View 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)
}

View File

@@ -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}