From 9b0719fa948c60bf06ab5746b8d7fac5b07737e2 Mon Sep 17 00:00:00 2001 From: Jack Date: Tue, 28 Jul 2026 11:12:52 +0800 Subject: [PATCH] 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) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- .../component/chunker/crop_title_test.go | 65 ++++++ internal/ingestion/component/chunker/group.go | 20 +- .../ingestion/component/chunker/hierarchy.go | 20 +- internal/ingestion/component/chunker/one.go | 15 +- .../ingestion/component/chunker/one_test.go | 43 ++++ internal/ingestion/component/chunker/qa.go | 10 +- .../component/chunker/qa_batch2_test.go | 4 +- .../ingestion/component/chunker/qa_test.go | 2 +- internal/ingestion/component/chunker/tag.go | 24 +- .../ingestion/component/chunker/tag_test.go | 67 ++++++ internal/ingestion/component/chunker/title.go | 4 +- internal/ingestion/component/chunker/token.go | 149 +++++++----- .../component/chunker/token_batch1_test.go | 4 +- .../component/chunker/token_tag_test.go | 87 +++++++ .../ingestion/component/chunker/token_test.go | 216 +++++++++++++----- .../ingestion/component/media_dispatch.go | 69 ++---- .../component/media_dispatch_test.go | 33 +++ internal/ingestion/component/parser.go | 9 +- .../component/pdf_vision_dispatch.go | 93 ++++++++ .../ingestion/component/schema/chunker.go | 2 +- internal/ingestion/component/tokenizer.go | 115 +++++----- .../ingestion/component/tokenizer_test.go | 49 +--- .../component/tokenizer_unit_test.go | 149 +++++++++++- .../pipeline/template_integration_test.go | 2 +- .../service/real_consumer_pipeline_test.go | 8 +- internal/ingestion/task/chunk_process_test.go | 7 +- internal/ingestion/task/position.go | 16 +- internal/ingestion/task/position_test.go | 27 ++- internal/ingestion/task/real_consumer_test.go | 10 +- .../parser/office_parsers_nocgo_test.go | 1 - internal/parser/parser/pdf_parser_common.go | 13 +- .../parser/parser/pdf_parser_common_test.go | 56 ++++- .../parser/parser/pdf_parser_nocgo_test.go | 1 - internal/parser/parser/pdf_parser_tcadp.go | 37 ++- .../parser/parser/pdf_parser_tcadp_test.go | 96 ++++++++ internal/parser/parser/pdf_postprocess.go | 23 ++ internal/parser/parser/ppt_parser.go | 17 +- internal/parser/parser/ppt_parser_cgo_test.go | 155 +++++++++++++ internal/parser/parser/pptx_parser.go | 60 +++++ internal/parser/parser/pptx_tcadp.go | 95 ++++++++ internal/parser/parser/xls_tcadp.go | 3 + 41 files changed, 1544 insertions(+), 332 deletions(-) create mode 100644 internal/ingestion/component/chunker/crop_title_test.go create mode 100644 internal/ingestion/component/chunker/token_tag_test.go create mode 100644 internal/parser/parser/pptx_tcadp.go diff --git a/internal/ingestion/component/chunker/crop_title_test.go b/internal/ingestion/component/chunker/crop_title_test.go new file mode 100644 index 0000000000..a64acd4a86 --- /dev/null +++ b/internal/ingestion/component/chunker/crop_title_test.go @@ -0,0 +1,65 @@ +//go:build cgo + +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package chunker + +import ( + "context" + "strings" + "testing" +) + +// TestCropTitleChunks_CropsPositionedChunk pins residual A: chunks +// produced by the Title/Group/Hierarchy chunkers must be cropped on demand +// (mirroring the TokenChunker JSON path). The chunk shape mirrors the real +// Group/Hierarchy output: it carries doc_type_kwd + positions but NO ck_type +// (buildChunksFromRecordGroups never sets ck_type). cropTitleChunks must +// derive ck_type from doc_type_kwd so needsCrop (pdfcrop_cgo.go:151) fires. +// A text chunk with positions gets a rendered preview; a chunk without +// positions is left untouched; and the derived ck_type must not leak into +// the returned chunk shape. +func TestCropTitleChunks_CropsPositionedChunk(t *testing.T) { + ctx := context.Background() + pos := jsonPositions(t, []float64{1, 10, 100, 10, 100}) + in := []map[string]any{ + {"text": "page text", "doc_type_kwd": "text", "positions": pos}, + {"text": "no coords", "doc_type_kwd": "text"}, + } + + got := cropTitleChunks(ctx, mockCropEngine{}, in) + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + img, _ := got[0]["image"].(string) + if !strings.HasPrefix(img, "data:image/png;base64,") { + t.Errorf("positioned text chunk: image = %q, want data:image/png;base64, prefix", img) + } + if got[1]["image"] != nil { + t.Errorf("chunk without positions should not be cropped, got %v", got[1]["image"]) + } + if _, ok := got[0]["ck_type"]; ok { + t.Errorf("derived ck_type must not leak into output chunk: %v", got[0]) + } +} + +// TestCropTitleChunks_NilEnginePassthrough asserts the helper is a no-op +// when no PDF engine is available (best-effort contract). +func TestCropTitleChunks_NilEnginePassthrough(t *testing.T) { + in := []map[string]any{{"text": "hello"}} + got := cropTitleChunks(context.Background(), nil, in) + if len(got) != 1 || got[0]["text"] != "hello" { + t.Fatalf("nil engine should pass through unchanged: %v", got) + } +} diff --git a/internal/ingestion/component/chunker/group.go b/internal/ingestion/component/chunker/group.go index 992d4d9825..fe263c6119 100644 --- a/internal/ingestion/component/chunker/group.go +++ b/internal/ingestion/component/chunker/group.go @@ -36,6 +36,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "regexp" "sort" "strings" @@ -108,7 +109,7 @@ func buildSectionIDs(levels []int, targetLevel int) []int { // supplied inputs. Detected headings + adjacent merges happen in two // goroutines (heading detection sequential, then a fan-out over // record-buckets for the merge pass). -func invokeGroup(_ context.Context, inputs map[string]any, p *titleChunkerParam) (map[string]any, error) { +func invokeGroup(parentCtx context.Context, db *gorm.DB, inputs map[string]any, p *titleChunkerParam) (map[string]any, error) { records := extractLineRecords(inputs) common.Debug("chunker stage", zap.String("component", "Chunker"), @@ -161,6 +162,21 @@ func invokeGroup(_ context.Context, inputs map[string]any, p *titleChunkerParam) zap.Int("chunks", len(chunks)), zap.Bool("plain_text", isPlainTextFormat(inputs)), ) + + // On-demand PDF preview cropping for image/table/text chunks, + // mirroring the TokenChunker JSON path (token.go:513). Best-effort: + // a missing or unreadable PDF simply skips cropping. + if upstream, uErr := decodeChunkerFromUpstream(inputs); uErr == nil { + engine, eErr := newPDFEngineFromUpstream(parentCtx, db, upstream) + if eErr != nil { + slog.Warn("GroupTitleChunker: could not open PDF for on-demand cropping", "err", eErr) + } + if engine != nil { + defer engine.Close() + chunks = cropTitleChunks(parentCtx, engine, chunks) + } + } + if len(chunks) == 0 { return emptyOutputs(), nil } @@ -484,7 +500,7 @@ func (c *GroupTitleChunkerComponent) Invoke(ctx context.Context, db *gorm.DB, in "_ERROR": "GroupTitleChunker: missing required upstream field \"name\"", }, nil } - return invokeGroup(ctx, withName(inputs, name), &c.param) + return invokeGroup(ctx, db, withName(inputs, name), &c.param) } // init registers GroupTitleChunker under CategoryIngestion. diff --git a/internal/ingestion/component/chunker/hierarchy.go b/internal/ingestion/component/chunker/hierarchy.go index 107e3b1233..c8dfd0d35c 100644 --- a/internal/ingestion/component/chunker/hierarchy.go +++ b/internal/ingestion/component/chunker/hierarchy.go @@ -41,6 +41,7 @@ package chunker import ( "context" "fmt" + "log/slog" "go.uber.org/zap" "gorm.io/gorm" @@ -139,7 +140,7 @@ func (n *chunkNode) getPaths(paths *[][]int, titles []int, depth int, includeHea } // invokeHierarchy runs the HierarchyTitleChunker strategy. -func invokeHierarchy(_ context.Context, inputs map[string]any, p *titleChunkerParam) (map[string]any, error) { +func invokeHierarchy(parentCtx context.Context, db *gorm.DB, inputs map[string]any, p *titleChunkerParam) (map[string]any, error) { records := extractLineRecords(inputs) common.Debug("chunker stage", zap.String("component", "Chunker"), @@ -246,6 +247,21 @@ func invokeHierarchy(_ context.Context, inputs map[string]any, p *titleChunkerPa zap.Int("chunks", len(chunks)), zap.Bool("plain_text", isPlainTextFormat(inputs)), ) + + // On-demand PDF preview cropping for image/table/text chunks, + // mirroring the TokenChunker JSON path (token.go:513). Best-effort: + // a missing or unreadable PDF simply skips cropping. + if upstream, uErr := decodeChunkerFromUpstream(inputs); uErr == nil { + engine, eErr := newPDFEngineFromUpstream(parentCtx, db, upstream) + if eErr != nil { + slog.Warn("HierarchyTitleChunker: could not open PDF for on-demand cropping", "err", eErr) + } + if engine != nil { + defer engine.Close() + chunks = cropTitleChunks(parentCtx, engine, chunks) + } + } + if len(chunks) == 0 { return emptyOutputs(), nil } @@ -302,7 +318,7 @@ func (c *HierarchyTitleChunkerComponent) Invoke(ctx context.Context, db *gorm.DB "_ERROR": "HierarchyTitleChunker: missing required upstream field \"name\"", }, nil } - return invokeHierarchy(ctx, withName(inputs, name), &c.param) + return invokeHierarchy(ctx, db, withName(inputs, name), &c.param) } // init registers HierarchyTitleChunker under CategoryIngestion. diff --git a/internal/ingestion/component/chunker/one.go b/internal/ingestion/component/chunker/one.go index daff2a4c1d..cbdb094017 100644 --- a/internal/ingestion/component/chunker/one.go +++ b/internal/ingestion/component/chunker/one.go @@ -141,10 +141,12 @@ func emitOneFromItems(items, chunks []schema.ChunkDoc) map[string]any { return emptyOutputs() } out := schema.ChunkDoc{ - Text: text, - DocType: docType, - CKType: docType, - Image: it.Image, + Text: text, + DocType: docType, + CKType: docType, + Image: it.Image, + Positions: it.Positions, + PDFPositions: it.PDFPositions, } return chunkOutputs([]schema.ChunkDoc{out}) } @@ -164,6 +166,11 @@ func emitOneFromItems(items, chunks []schema.ChunkDoc) map[string]any { return emptyOutputs() } out := schema.ChunkDoc{Text: merged, DocType: "text", CKType: "text"} + // Multi-item merge produces a single text-only chunk mirroring Python + // one.py:166-168. Per-item Positions/PDFPositions are intentionally + // not carried — merging coordinates from different source items would + // produce meaningless composite geometry, and the downstream + // processChunkPositions would map them to incorrect pages. if img != "" { out.Image = img } diff --git a/internal/ingestion/component/chunker/one_test.go b/internal/ingestion/component/chunker/one_test.go index 031774dfaf..5fda780c96 100644 --- a/internal/ingestion/component/chunker/one_test.go +++ b/internal/ingestion/component/chunker/one_test.go @@ -98,3 +98,46 @@ func TestOneChunker_JSONMultipleMerges(t *testing.T) { t.Errorf("image = %q, want first available media context", got) } } + +// TestOneChunker_PreservesPositions verifies that when a single upstream +// item carries PDF coordinates, the OneChunker preserves both Positions +// and PDFPositions (with their coordinate values) on the output chunk. +func TestOneChunker_PreservesPositions(t *testing.T) { + chunks := oneChunksOf(t, map[string]any{ + "name": "page.pdf", + "output_format": "json", + "json": []map[string]any{ + { + "text": "page text", + "positions": []any{[]any{10.0, 20.0, 30.0, 40.0}}, + "_pdf_positions": []any{[]any{1.0, 2.0, 3.0, 4.0, 5.0}}, + }, + }, + }) + if len(chunks) != 1 { + t.Fatalf("want 1 chunk, got %d", len(chunks)) + } + assertCoordTuple(t, "positions", chunks[0]["positions"], []float64{10.0, 20.0, 30.0, 40.0}) + assertCoordTuple(t, "_pdf_positions", chunks[0]["_pdf_positions"], []float64{1.0, 2.0, 3.0, 4.0, 5.0}) +} + +// assertCoordTuple verifies a positions/_pdf_positions field round-tripped +// as a [][]float64 with the expected single-row coordinate tuple. +func assertCoordTuple(t *testing.T, key string, got any, want []float64) { + t.Helper() + rows, ok := got.([][]float64) + if !ok { + t.Fatalf("%s = %v, want [][]float64 (got %T)", key, got, got) + } + if len(rows) != 1 { + t.Fatalf("%s has %d rows, want 1", key, len(rows)) + } + if len(rows[0]) != len(want) { + t.Fatalf("%s[0] = %v, want %v (len %d vs %d)", key, rows[0], want, len(rows[0]), len(want)) + } + for i, w := range want { + if rows[0][i] != w { + t.Errorf("%s[0][%d] = %v, want %v", key, i, rows[0][i], w) + } + } +} diff --git a/internal/ingestion/component/chunker/qa.go b/internal/ingestion/component/chunker/qa.go index 33c4359d99..9ec591ab38 100644 --- a/internal/ingestion/component/chunker/qa.go +++ b/internal/ingestion/component/chunker/qa.go @@ -101,7 +101,7 @@ func (c *QAChunkerComponent) invoke(_ context.Context, inputs map[string]any) (m qPrefix, aPrefix := "问题:", "回答:" // Python qa.py defaults to Chinese when no language is supplied; only - // an explicit "english" switches to English prefixes (diff Chunker-2.13). + // an explicit "english" switches to English prefixes eng := strings.EqualFold(c.param.Lang, "english") if eng { qPrefix, aPrefix = "Question: ", "Answer: " @@ -137,7 +137,7 @@ func (c *QAChunkerComponent) invoke(_ context.Context, inputs map[string]any) (m ContentLtks: contentLTKS, ContentSmLtks: contentSMLTKS, } - // Restore metadata lost before diff Chunker-1.8: top_int (row + // // index), image id + coordinates carried from the source item. if pair.RowNum >= 0 { chunk.TopInt = []int{pair.RowNum} @@ -172,14 +172,14 @@ type qaPair struct { RowNum int // Image and positions are carried from the upstream item so the QA // chunk preserves metadata that Python sets via beAdocPdf/beAdocDocx - // (diff Chunker-1.8). + // Image string PDFPositions json.RawMessage Positions json.RawMessage } // rmQAPrefixRe mirrors Python qa.py:241 `[\t:: ]+` — one-or-more separator -// chars, so "Q:: answer" is fully stripped (diff Chunker-2.12). +// chars, so "Q:: answer" is fully stripped var rmQAPrefixRe = regexp.MustCompile(`(?i)^(问题|答案|回答|user|assistant|Q|A|Question|Answer|问|答)[\t:: ]+`) func rmQAPrefix(txt string) string { @@ -424,7 +424,7 @@ func extractQAJSON(items []schema.ChunkDoc) []qaPair { } tmp := extractQAText(txt) // Preserve the source item's image id and coordinates on each - // extracted pair (diff Chunker-1.8). + // extracted pair for _, p := range tmp { p.Image = item.Image p.PDFPositions = item.PDFPositions diff --git a/internal/ingestion/component/chunker/qa_batch2_test.go b/internal/ingestion/component/chunker/qa_batch2_test.go index 84b334ac94..1bfb72bbbd 100644 --- a/internal/ingestion/component/chunker/qa_batch2_test.go +++ b/internal/ingestion/component/chunker/qa_batch2_test.go @@ -41,7 +41,7 @@ func qaInvoke(t *testing.T, inputs map[string]any) []map[string]any { return chunks } -// TestQAChunker_DefaultLangIsChinese exercises migration diff Chunker-2.13: +// TestQAChunker_DefaultLangIsChinese exercises migration : // when no language is supplied, Python defaults to Chinese prefixes // ("问题:"/"回答:"); the legacy Go code defaulted to English. func TestQAChunker_DefaultLangIsChinese(t *testing.T) { @@ -79,7 +79,7 @@ func TestRmQAPrefixStripsMultipleSeparators(t *testing.T) { } } -// TestQAChunker_SetsTopInt exercises migration diff Chunker-1.8 (top_int): +// TestQAChunker_SetsTopInt exercises migration (top_int): // each QA chunk must carry the source row index in `top_int`, matching // Python beAdoc(..., row_num=i). func TestQAChunker_SetsTopInt(t *testing.T) { diff --git a/internal/ingestion/component/chunker/qa_test.go b/internal/ingestion/component/chunker/qa_test.go index ad85e13486..e4ecefdf5c 100644 --- a/internal/ingestion/component/chunker/qa_test.go +++ b/internal/ingestion/component/chunker/qa_test.go @@ -213,7 +213,7 @@ func TestQAChunker_PrefixSpaceSeparatorStrips(t *testing.T) { } cww, _ := chunks[0]["content_with_weight"].(string) // Python qa.py:241 uses `[\t:: ]+`, so a space is a valid separator: - // a leading "A"/"Q" followed by a space is stripped (diff Chunker-2.12). + // a leading "A"/"Q" followed by a space is stripped. . if cww != "Question: language model is useful\tAnswer: How does it work" { t.Fatalf("space-separator prefix not stripped: %q", cww) } diff --git a/internal/ingestion/component/chunker/tag.go b/internal/ingestion/component/chunker/tag.go index e4a1e8496f..d1f341b987 100644 --- a/internal/ingestion/component/chunker/tag.go +++ b/internal/ingestion/component/chunker/tag.go @@ -125,6 +125,7 @@ func (c *TagChunkerComponent) invoke(_ context.Context, inputs map[string]any) ( ContentLtks: contentLTKS, ContentSmLtks: contentSMLTKS, TagKwd: splitTagKwd(pair.Tags), + TopInt: []int{pair.RowNum}, } chunks = append(chunks, chunk) } @@ -133,9 +134,13 @@ func (c *TagChunkerComponent) invoke(_ context.Context, inputs map[string]any) ( } // tagPair is a (content, tags) row extracted from the upstream payload. +// RowNum is the 0-based record START line (the first physical source +// line of the record), used by every extractor consistently and mapped +// to Python's top_int in beAdoc(tag.py:33). type tagPair struct { Content string Tags string + RowNum int } // extractTagText ports tag.py:60-89 (txt) and tag.py:91-113 (csv). @@ -157,18 +162,23 @@ func extractTagText(text string) []tagPair { func extractTagTextTab(lines []string) []tagPair { var pairs []tagPair content := "" - for _, line := range lines { + contentStart := -1 + for i, line := range lines { if strings.TrimSpace(line) == "" { continue } + if contentStart < 0 { + contentStart = i + } parts := strings.Split(line, "\t") if len(parts) != 2 { content += "\n" + line continue } content += "\n" + parts[0] - pairs = append(pairs, tagPair{Content: content, Tags: parts[1]}) + pairs = append(pairs, tagPair{Content: content, Tags: parts[1], RowNum: contentStart}) content = "" + contentStart = -1 } return pairs } @@ -202,6 +212,7 @@ func extractTagTextCSV(text string, lines []string) []tagPair { for curLine < len(lineStarts) && lineStarts[curLine] < endOff { curLine++ } + startLine := prevLine raw := strings.Join(lines[prevLine:curLine], "\n") prevLine = curLine @@ -210,7 +221,10 @@ func extractTagTextCSV(text string, lines []string) []tagPair { continue } content += "\n" + record[0] - pairs = append(pairs, tagPair{Content: content, Tags: record[1]}) + // RowNum is the 0-based record START line, kept consistent with + // extractTagTextTab (line i) and extractTagTable ( i) so every + // tag-pair source uses the same row-index convention. + pairs = append(pairs, tagPair{Content: content, Tags: record[1], RowNum: startLine}) content = "" } return pairs @@ -226,7 +240,7 @@ func extractTagTable(htmlStr string) []tagPair { } rows := htmlTR.FindAllStringSubmatch(htmlStr, -1) pairs := make([]tagPair, 0, len(rows)) - for _, row := range rows { + for i, row := range rows { cells := htmlTD.FindAllStringSubmatch(row[1], -1) var texts []string for _, cell := range cells { @@ -237,7 +251,7 @@ func extractTagTable(htmlStr string) []tagPair { } } if len(texts) >= 2 { - pairs = append(pairs, tagPair{Content: texts[0], Tags: texts[1]}) + pairs = append(pairs, tagPair{Content: texts[0], Tags: texts[1], RowNum: i}) } } return pairs diff --git a/internal/ingestion/component/chunker/tag_test.go b/internal/ingestion/component/chunker/tag_test.go index 7fb503f4ac..515e0b6979 100644 --- a/internal/ingestion/component/chunker/tag_test.go +++ b/internal/ingestion/component/chunker/tag_test.go @@ -133,3 +133,70 @@ func TestTagChunker_HTMLTable(t *testing.T) { t.Errorf("chunk0 tag_kwd = %v, want [a1 b1]", tags0) } } + +// TestTagChunk_TopInt verifies each tag-pair row carries the 0-based +// source row index as top_int, matching Python tag.py:33 beAdoc(row_num=i). +func TestTagChunk_TopInt(t *testing.T) { + chunks := tagChunksOf(t, map[string]any{ + "name": "tags.txt", + "output_format": "text", + "text": "tag1\tcontent1\ntag2\tcontent2", + }) + if len(chunks) != 2 { + t.Fatalf("want 2 chunks, got %d", len(chunks)) + } + if ti, ok := chunks[0]["top_int"].([]any); !ok || len(ti) != 1 || ti[0] != float64(0) { + t.Errorf("chunk 0: top_int = %v, want [0]", chunks[0]["top_int"]) + } + if ti, ok := chunks[1]["top_int"].([]any); !ok || len(ti) != 1 || ti[0] != float64(1) { + t.Errorf("chunk 1: top_int = %v, want [1]", chunks[1]["top_int"]) + } +} + +// TestTagChunk_CSVTopIntStartLine locks the RowNum convention from +// review finding #4: a multi-line quoted CSV record must report the +// record's START line, not the line after it ends. The first record +// spans physical lines 0-1, so its top_int is [0]; the second record +// is on line 2, so its top_int is [2]. +func TestTagChunk_CSVTopIntStartLine(t *testing.T) { + chunks := tagChunksOf(t, map[string]any{ + "name": "tags.csv", + "output_format": "text", + "text": "\"multi\nline content\",TAG\nsecond,OTHER", + }) + if len(chunks) != 2 { + t.Fatalf("want 2 chunks, got %d", len(chunks)) + } + if ti, ok := chunks[0]["top_int"].([]any); !ok || len(ti) != 1 || ti[0] != float64(0) { + t.Errorf("chunk 0: top_int = %v, want [0]", chunks[0]["top_int"]) + } + if ti, ok := chunks[1]["top_int"].([]any); !ok || len(ti) != 1 || ti[0] != float64(2) { + t.Errorf("chunk 1: top_int = %v, want [2]", chunks[1]["top_int"]) + } +} + +// TestTagChunk_TSVMultiLineRowNumStart pins the RowNum fix for TSV +// multi-line records: when a record spans multiple physical lines +// (because the first line does not contain a tab delimiter), the +// resulting chunk's top_int must report the first non-empty CONTENT +// start line, not the line where the tab-delimiter eventually +// terminates the record (PR #17419 review #1). +func TestTagChunk_TSVMultiLineRowNumStart(t *testing.T) { + chunks := tagChunksOf(t, map[string]any{ + "name": "tags.txt", + "output_format": "text", + // Line 0 is empty, line 1 has no tab (accumulated as prefix + // of the first record), line 2 is valid TSV. + // The prefix starts at line 1 → RowNum must be 1, not 2. + "text": "\nprefix\nrecord1\tTAG1\nmulti\nline-prefix\nrecord2\tTAG2", + }) + if len(chunks) != 2 { + t.Fatalf("want 2 chunks, got %d", len(chunks)) + } + if ti, ok := chunks[0]["top_int"].([]any); !ok || len(ti) != 1 || ti[0] != float64(1) { + t.Errorf("chunk 0: top_int = %v, want [1] (content start)", chunks[0]["top_int"]) + } + if ti, ok := chunks[1]["top_int"].([]any); !ok || len(ti) != 1 || ti[0] != float64(3) { + t.Errorf("chunk 1: top_int = %v, want [3] (content start after blank line)", chunks[1]["top_int"]) + } +} diff --git a/internal/ingestion/component/chunker/title.go b/internal/ingestion/component/chunker/title.go index d0ae0d9993..40662c341f 100644 --- a/internal/ingestion/component/chunker/title.go +++ b/internal/ingestion/component/chunker/title.go @@ -764,9 +764,9 @@ func (c *TitleChunkerComponent) Invoke(ctx context.Context, db *gorm.DB, inputs } switch c.param.Method { case "hierarchy": - return invokeHierarchy(ctx, inputs, &c.param) + return invokeHierarchy(ctx, db, inputs, &c.param) case "group": - return invokeGroup(ctx, inputs, &c.param) + return invokeGroup(ctx, db, inputs, &c.param) default: return map[string]any{ "output_format": "chunks", diff --git a/internal/ingestion/component/chunker/token.go b/internal/ingestion/component/chunker/token.go index aa12e31f03..ca445867dd 100644 --- a/internal/ingestion/component/chunker/token.go +++ b/internal/ingestion/component/chunker/token.go @@ -252,6 +252,45 @@ func stripChunkerRuntimeTimestamps(inputs map[string]any) map[string]any { return out } +// cropTitleChunks crops image/table/text previews for chunks produced by +// the Title/Group/Hierarchy chunkers, mirroring the TokenChunker JSON path +// (cropImageChunks at token.go:513). A nil engine — or an +// empty chunk list — leaves chunks unchanged (best-effort, matching the +// on-demand PDF crop contract used by the TokenChunker path). +func cropTitleChunks(ctx context.Context, engine deepdoctype.PDFEngine, chunks []map[string]any) []map[string]any { + if engine == nil || len(chunks) == 0 { + return chunks + } + docs, _, err := schema.ChunkDocsFromAny(chunks) + if err != nil || len(docs) == 0 { + return chunks + } + // The Title/Group/Hierarchy chunkers emit doc_type_kwd but not the + // ck_type field that cropImageChunks' needsCrop consults + // (pdfcrop_cgo.go:151). Derive ck_type from doc_type_kwd so the crop + // decision matches the TokenChunker path. The derived ck_type is + // stripped from the returned maps so the downstream chunk shape is + // unchanged (setting ck_type in the real output would also change + // how a downstream TokenChunker merges these chunks — a separate + // concern, out of scope here). + for i := range docs { + if docs[i].CKType == "" { + switch docs[i].DocType { + case "image", "table": + docs[i].CKType = docs[i].DocType + default: + docs[i].CKType = "text" + } + } + } + cropped := cropImageChunks(ctx, engine, docs) + out := schema.ChunkDocsToMaps(cropped) + for _, m := range out { + delete(m, "ck_type") + } + return out +} + // invokeTextPayload handles plain-text input (output_format in // {markdown,text,html} on the python side). func (c *TokenChunkerComponent) invokeTextPayload(_ context.Context, text string, delimPattern, childrenPattern *regexp.Regexp) map[string]any { @@ -290,18 +329,21 @@ func (c *TokenChunkerComponent) invokeTextPayload(_ context.Context, text string } // sentenceDelimiter is the sentence/clause-boundary regex used to split -// oversized sections. It mirrors Python's default delimiter "\n。;!?" -// plus the English ". " fallback, and also breaks on ASCII "!" and "?" -// (diff Chunker-2.1). -var sentenceDelimiter = regexp.MustCompile(`(\n|[!?。;!?]|\.\s)`) +// oversized sections. It mirrors the delimiter Python's chunker actually +// uses in production: rag/app/naive.py:1285 passes "\n!?。;!?" to +// naive_merge, which includes ASCII "!" and "?" as well as the CJK +// punctuation "。;!?". It deliberately does NOT include an English +// ". " fallback: Python's production delimiter has no "\.\s", so adding +// it would diverge from Python's chunk boundaries. +var sentenceDelimiter = regexp.MustCompile(`(\n|[!?。;!?])`) // mergeByTokenSize implements exact token-based chunk merging that mirrors // Python's naive_merge (rag/nlp/__init__.py:1156). It uses // tokenizeStr (= tokenizer.NumTokensFromString, cl100k_base BPE) for -// precise token counting, splits input into paragraph sections, further -// subdivides oversized sections on sentence delimiters, and greedily -// merges into chunks of approximately chunk_token_size tokens with -// optional overlap from the previous chunk. +// precise token counting, treats the payload as a single section, splits +// oversized sections on sentence delimiters (dropping the delimiter, as +// Python does), and greedily merges into chunks of approximately +// chunk_token_size tokens with optional overlap from the previous chunk. func (c *TokenChunkerComponent) mergeByTokenSize(text string, childrenPattern *regexp.Regexp) map[string]any { target := c.param.ChunkTokenSize overlapPct := c.param.OverlappedPercent @@ -316,16 +358,29 @@ func (c *TokenChunkerComponent) mergeByTokenSize(text string, childrenPattern *r overlapPct = 100 } - // Split into paragraph-aligned sections. - sections := splitIntoSections(text) + // Normalize line endings to LF before any splitting. Python's + // naive_merge (rag/nlp/__init__.py:1166) runs + // text = text.replace("\r\n", "\n").replace("\r", "\n") + // so CRLF/CR input must segment and split exactly like LF input. + // Without this, stray "\r" would survive inside chunks, diverging + // from Python. + text = strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n") + + // Treat the whole payload as a single section, mirroring Python's + // naive_merge (rag/nlp/__init__.py:1157) which wraps the input string + // as a one-element list. naive_merge does NOT pre-split on blank + // lines, and because "\n" is itself a delimiter it is dropped (blank + // lines collapse), exactly as Python does. CRLF/CR normalization + // already happened above. + sections := []string{text} if len(sections) == 0 { return emptyOutputs() } // Sentence/clause-boundary regex for splitting oversized sections. - // Matches Python's default delimiter "\n。;!?" plus English ". " - // fallback. The ASCII "!" and "?" are included to match Python's - // full delimiter set (diff Chunker-2.1). + // Mirrors Python's production delimiter (rag/app/naive.py:1285 passes + // "\n!?。;!?") — ASCII "!" and "?" plus the CJK punctuation, with no + // English ". " fallback. sentenceDelim := sentenceDelimiter var cks []string // chunk texts @@ -342,7 +397,7 @@ func (c *TokenChunkerComponent) mergeByTokenSize(text string, childrenPattern *r segTokens := tokens if overlapPct > 0 && len(cks) > 0 { // Strip parser tags before computing the overlap suffix, - // matching Python nlp/__init__.py:1181 (diff Chunker-2.2). + // matching Python nlp/__init__.py:1181 prev := removeTag(cks[len(cks)-1]) // Take the last overlapped_percent of the previous chunk // (in runes, matching Python's len(overlapped) * ratio). @@ -387,32 +442,36 @@ func (c *TokenChunkerComponent) mergeByTokenSize(text string, childrenPattern *r continue } - // Oversized section: split on sentence delimiters, then merge. + // Oversized section: split on sentence delimiters. Python's + // naive_merge (rag/nlp/__init__.py:1216-1225) splits with a + // capturing-group re.split but then SKIPS any segment that is a + // pure delimiter (re.fullmatch(dels, sub_sec)), so the delimiter + // character (\n / 。 / ! / ?) is DROPPED from the chunk text + // rather than retained. We mirror that by using regexp.Split + // (which discards the delimiter) and prepending a single "\n" to + // each segment, matching Python's add_chunk("\n"+sub_sec). parts := sentenceDelim.Split(sec, -1) for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } + // Route every segment — including tiny <8-token fragments — + // through mergeOrNew so it honours the token threshold, + // mirroring Python's add_chunk. The old shortcut appended + // unconditionally, merging fragments into an already-overfull + // chunk (review #2). p := "\n" + part - pn := tokenizeStr(p) - if pn < 8 { - if len(cks) > 0 { - cks[len(cks)-1] += p - tkns[len(tkns)-1] += pn - } else { - cks = append(cks, p) - tkns = append(tkns, pn) - } - continue - } - mergeOrNew(p, pn) + mergeOrNew(p, tokenizeStr(p)) } } docs := make([]schema.ChunkDoc, 0, len(cks)) for _, ch := range cks { - ch = strings.TrimSpace(ch) + // Strip parser position tags from the final text: + // the merge paths may carry @@...## markers that must not leak into + // indexed/embedded chunk text. + ch = removeTag(strings.TrimSpace(ch)) if ch == "" { continue } @@ -422,25 +481,6 @@ func (c *TokenChunkerComponent) mergeByTokenSize(text string, childrenPattern *r return chunkOutputs(final) } -// splitIntoSections partitions text into paragraph-level sections by -// splitting on blank-line boundaries. It uses \n\s*\n (not \n{2,}) so a -// CRLF paragraph break (\r\n\r\n) still matches — the Go chunker performs -// no \r\n normalization of its own, so a stricter \n{2,} would silently -// lose CRLF boundaries. NOTE: the precise 2.7/2.10 section-splitting -// semantics are still OPEN in docs/migration_python_go_diff.md (alignment -// target flow-vs-app Python undecided); this keeps the pre-existing, -// CRLF-safe behaviour until that is resolved in a dedicated change. -func splitIntoSections(text string) []string { - if text == "" { - return nil - } - // Split on a blank line: a newline, optional whitespace (absorbs a - // trailing \r on CRLF input), then another newline. - re := regexp.MustCompile(`\n\s*\n`) - parts := re.Split(text, -1) - return parts -} - // invokeJSONPayload handles structured upstream input. Items fan // across 4 goroutines; merge is by input index. func (c *TokenChunkerComponent) invokeJSONPayload(ctx context.Context, items []schema.ChunkDoc, delimPattern, childrenPattern *regexp.Regexp, engine deepdoctype.PDFEngine) map[string]any { @@ -502,7 +542,12 @@ func (c *TokenChunkerComponent) invokeJSONPayload(ctx context.Context, items []s out := make([]schema.ChunkDoc, 0, len(flat)) for _, m := range flat { - if strings.TrimSpace(m.Text) == "" { + // Strip parser position tags from the final text: + // the merge paths may carry @@...## markers that must not leak into + // indexed/embedded chunk text. Crop above reads positions, not text, + // so the ordering is safe. + m.Text = removeTag(strings.TrimSpace(m.Text)) + if m.Text == "" { continue } out = append(out, m) @@ -687,7 +732,7 @@ func collectContext(chunks []schema.ChunkDoc, i, ctxTokens int, above bool) stri } // takeFromEnd returns the smallest tail of text whose token count is >= -// tokens, counted exactly via tokenizeStr (diff Chunker-2.4). The previous +// tokens, counted exactly via tokenizeStr The previous // 4-bytes-per-token heuristic over-counted for CJK text. func takeFromEnd(text string, tokens int) string { runes := []rune(text) @@ -703,7 +748,7 @@ func takeFromEnd(text string, tokens int) string { } // takeFromStart returns the smallest prefix of text whose token count is >= -// tokens, counted exactly via tokenizeStr (diff Chunker-2.4). +// tokens, counted exactly via tokenizeStr func takeFromStart(text string, tokens int) string { runes := []rune(text) best := text @@ -758,7 +803,7 @@ func mergeByTokenSizeFromJSON(perItem [][]schema.ChunkDoc, chunkTokens int, over if len(merged) > 0 && merged[len(merged)-1].CKType == "text" && overlappedPct > 0 { // Strip parser tags before computing the overlap // suffix, matching Python nlp/__init__.py:1181 - // (diff Chunker-2.2). + // if prevText := removeTag(merged[len(merged)-1].Text); prevText != "" { runes := []rune(prevText) cut := int(float64(len(runes)) * (100 - overlappedPct) / 100.0) @@ -775,7 +820,7 @@ func mergeByTokenSizeFromJSON(perItem [][]schema.ChunkDoc, chunkTokens int, over prev := &merged[len(merged)-1] // Mirror Python token_chunker.py:236-239: when the accumulated // chunk has empty text, assign the incoming text directly instead - // of skipping it (diff Chunker-2.11). + // of skipping it if prev.Text == "" { prev.Text = ck.Text } else { diff --git a/internal/ingestion/component/chunker/token_batch1_test.go b/internal/ingestion/component/chunker/token_batch1_test.go index 0c79927f9c..7e869e1ce4 100644 --- a/internal/ingestion/component/chunker/token_batch1_test.go +++ b/internal/ingestion/component/chunker/token_batch1_test.go @@ -69,7 +69,7 @@ func TestMergeByTokenSizeFromJSON_OverlapStripsTags(t *testing.T) { } // The overlap prefix is prepended to the SECOND chunk. The original // first chunk legitimately keeps its own parser tag; only the overlap - // region (merged[1]) must be tag-free (diff Chunker-2.2). + // region (merged[1]) must be tag-free. . if strings.Contains(merged[1].Text, "@@") || strings.Contains(merged[1].Text, "##") { t.Errorf("overlap prefix leaked parser tag into chunk 1: %q", merged[1].Text) } @@ -155,7 +155,7 @@ func TestMergeByTokenSizeFromJSON_EmptyPrevKeepsChunk(t *testing.T) { } // TestTakeFromEndRespectsTokenCount and TestTakeFromStartRespectsTokenCount -// exercise migration diff Chunker-2.4: takeFromEnd/takeFromStart used a +// covers takeFromEnd/takeFromStart used a // fixed 4-bytes-per-token heuristic which badly over-counts for CJK text // (≈3 bytes/char, 1-2 tokens/char). They must now count tokens exactly via // tokenizeStr so the returned slice is close to the requested token budget. diff --git a/internal/ingestion/component/chunker/token_tag_test.go b/internal/ingestion/component/chunker/token_tag_test.go new file mode 100644 index 0000000000..97ba086a21 --- /dev/null +++ b/internal/ingestion/component/chunker/token_tag_test.go @@ -0,0 +1,87 @@ +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package chunker + +import ( + "context" + "strings" + "testing" +) + +// TestTokenChunker_TextPath_StripsParserTags pins residual B: the +// TokenChunker text path must not leak parser position tags (@@...##) +// into the final chunk text. The input mimics a PDF parser payload where +// the body text still carries coordinate markers. +func TestTokenChunker_TextPath_StripsParserTags(t *testing.T) { + c, err := NewTokenChunker(map[string]any{ + "delimiter_mode": "token_size", + "chunk_token_size": 1000, + "delimiters": []string{"\n"}, + }) + if err != nil { + t.Fatalf("NewTokenChunker: %v", err) + } + out, err := c.Invoke(context.Background(), nil, map[string]any{ + "name": "doc.txt", + "output_format": "text", + "text": "First line with tag@@123## inside.\nSecond line with tag@@456## inside.", + }) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + chunks, _ := out["chunks"].([]map[string]any) + if len(chunks) == 0 { + t.Fatal("chunks: want >=1, got 0") + } + for i, ck := range chunks { + txt, _ := ck["text"].(string) + if strings.Contains(txt, "@@") || strings.Contains(txt, "##") { + t.Errorf("chunk %d text leaks parser tag: %q", i, txt) + } + } +} + +// TestTokenChunker_JSONPath_StripsParserTags pins residual B for the +// structured (output_format == "json") path. +func TestTokenChunker_JSONPath_StripsParserTags(t *testing.T) { + c, err := NewTokenChunker(map[string]any{ + "delimiter_mode": "delimiter", + "delimiters": []string{"\n"}, + }) + if err != nil { + t.Fatalf("NewTokenChunker: %v", err) + } + items := []map[string]any{ + {"text": "Alpha@@1## text\nBeta@@2## text", "doc_type_kwd": "text"}, + {"text": "Gamma@@3## text\nDelta@@4## text", "doc_type_kwd": "text"}, + } + out, err := c.Invoke(context.Background(), nil, map[string]any{ + "name": "doc.md", + "output_format": "json", + "json": items, + }) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + chunks, _ := out["chunks"].([]map[string]any) + if len(chunks) == 0 { + t.Fatal("chunks: want >=1, got 0") + } + for i, ck := range chunks { + txt, _ := ck["text"].(string) + if strings.Contains(txt, "@@") || strings.Contains(txt, "##") { + t.Errorf("chunk %d text leaks parser tag: %q", i, txt) + } + } +} diff --git a/internal/ingestion/component/chunker/token_test.go b/internal/ingestion/component/chunker/token_test.go index 32ac5d9b44..bb0c432ff2 100644 --- a/internal/ingestion/component/chunker/token_test.go +++ b/internal/ingestion/component/chunker/token_test.go @@ -19,6 +19,8 @@ package chunker import ( "context" "math" + "reflect" + "strings" "testing" "ragflow/internal/agent/runtime" @@ -382,7 +384,7 @@ func TestTokenChunker_NewAcceptsPythonOverlappedRange(t *testing.T) { } // TestNormalizeOverlappedPercent is the Go port of Python -// common/float_utils.py:50-58 normalize_overlapped_percent (diff Chunker-2.6). +// common/float_utils.py:50-58 normalize_overlapped_percent. . // Python's user-facing input is a [0,1) fraction; the helper converts it to // the [0,90] integer-percentage scale the merge math expects. func TestNormalizeOverlappedPercent(t *testing.T) { @@ -400,6 +402,10 @@ func TestNormalizeOverlappedPercent(t *testing.T) { {"clamp 95 -> 90", 95, 90}, {"clamp -5 -> 0", -5, 0}, {"negative fraction -0.1 -> 0", -0.1, 0}, + {`numeric string "10" -> 10`, "10", 10}, + {`numeric string fraction "0.1" -> 10`, "0.1", 10}, + {"bad string -> 0", "abc", 0}, + {"nil -> 0", nil, 0}, // Huge out-of-range values must clamp to 90 (not 0). Go's // float->int is implementation-defined past int's range, so the // clamp must run before truncation (review finding #4). Python's @@ -407,10 +413,6 @@ func TestNormalizeOverlappedPercent(t *testing.T) { {"huge 1e300 -> 90", 1e300, 90}, {"huge -1e300 -> 0", -1e300, 0}, {"huge math.MaxFloat64 -> 90", math.MaxFloat64, 90}, - {`numeric string "10" -> 10`, "10", 10}, - {`numeric string fraction "0.1" -> 10`, "0.1", 10}, - {"bad string -> 0", "abc", 0}, - {"nil -> 0", nil, 0}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -421,8 +423,32 @@ func TestNormalizeOverlappedPercent(t *testing.T) { } } +// TestTokenChunkerParam_UpdatePreservesOverlappedPercent covers review +// finding #3: tokenChunkerParam.Update must not reset OverlappedPercent to 0 +// when the incoming config omits the key. All other fields use a presence +// guard, and a partial Update (e.g. changing only chunk_token_size) must +// preserve the previously configured overlap instead of clobbering it. +func TestTokenChunkerParam_UpdatePreservesOverlappedPercent(t *testing.T) { + p := defaultsToken(tokenChunkerParam{}) + p.TokenChunkerParam.OverlappedPercent = 30 // pre-existing config + + // Partial update: only chunk_token_size changes. + p.Update(map[string]any{"chunk_token_size": 100}) + if p.TokenChunkerParam.OverlappedPercent != 30 { + t.Errorf("after partial Update: overlapped_percent=%v, want 30 (preserved)", + p.TokenChunkerParam.OverlappedPercent) + } + + // Explicit key still wins and normalizes. + p.Update(map[string]any{"overlapped_percent": 0.5}) + if p.TokenChunkerParam.OverlappedPercent != 50 { + t.Errorf("after explicit Update: overlapped_percent=%v, want 50 (normalized)", + p.TokenChunkerParam.OverlappedPercent) + } +} + // TestTokenChunker_NormalizesOverlappedPercent asserts the stored value after -// construction matches Python's normalized [0,90] scale (diff Chunker-2.6). +// construction matches Python's normalized [0,90] scale. . func TestTokenChunker_NormalizesOverlappedPercent(t *testing.T) { cases := []struct { name string @@ -451,37 +477,6 @@ func TestTokenChunker_NormalizesOverlappedPercent(t *testing.T) { } } -// TestSplitIntoSections locks the CRLF-safe blank-line splitting used by -// mergeByTokenSize. It does NOT claim Chunker-2.7/2.10 coverage: that -// section-splitting semantics is still OPEN in -// docs/migration_python_go_diff.md (flow-vs-app Python alignment target -// undecided), so this only guards the existing behaviour. -func TestSplitIntoSections(t *testing.T) { - tests := []struct { - name string - text string - want int - }{ - {"two paragraphs", "a\n\nb", 2}, - {"three paragraphs with excess blank lines", "a\n\n\nb\n\nc", 3}, - {"single paragraph", "hello world", 1}, - // A blank line containing only whitespace is still a boundary - // under \n\s*\n (this is what reverted the stricter \n{2,}). - {"blank line with space is a boundary", "a\n \nb", 2}, - {"leading blank lines produce empty prefix (caller filters)", "\n\ntext", 2}, - // CRLF regression guard: \r\n\r\n must split the same as \n\n. - {"CRLF paragraph break splits", "a\r\n\r\nb", 2}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := splitIntoSections(tt.text) - if len(got) != tt.want { - t.Errorf("splitIntoSections(%q) = %d sections, want %d: %v", tt.text, len(got), tt.want, got) - } - }) - } -} - // TestTokenChunkerParam_ValidateOverlappedRange covers the strict overlap // handling in TokenChunkerParam.Validate (review findings #3 + #4): // a directly-constructed struct with a [0,1) fraction is scaled to its @@ -526,27 +521,134 @@ func TestTokenChunkerParam_ValidateOverlappedRange(t *testing.T) { } } -// TestTokenChunkerParam_UpdatePreservesOverlappedPercent locks review -// finding #3: tokenChunkerParam.Update must not reset OverlappedPercent to 0 -// when the incoming config omits the key. All other fields use a presence -// guard, and a partial Update (e.g. changing only chunk_token_size) must -// preserve the previously configured overlap instead of clobbering it. -func TestTokenChunkerParam_UpdatePreservesOverlappedPercent(t *testing.T) { - p := defaultsToken(tokenChunkerParam{}) - p.TokenChunkerParam.OverlappedPercent = 30 // pre-existing config - - // Partial update: only chunk_token_size changes. - p.Update(map[string]any{"chunk_token_size": 100}) - - if p.TokenChunkerParam.OverlappedPercent != 30 { - t.Errorf("after partial Update: overlapped_percent=%v, want 30 (preserved)", - p.TokenChunkerParam.OverlappedPercent) +// TestMergeByTokenSize_CRLFNormalization verifies that, like Python +// naive_merge, line endings are normalised +// (replace("\r\n","\n").replace("\r","\n")) before splitting, so CRLF/CR +// input must segment and split exactly like the equivalent LF input, and +// no carriage return may survive into a produced chunk. +func TestMergeByTokenSize_CRLFNormalization(t *testing.T) { + chunkTexts := func(t *testing.T, text string) []string { + t.Helper() + c := &TokenChunkerComponent{} + c.param.ChunkTokenSize = 128 + c.param.OverlappedPercent = 0 + out := c.mergeByTokenSize(text, nil) + raw, ok := out["chunks"].([]map[string]any) + if !ok { + t.Fatalf("mergeByTokenSize output missing chunks: %v", out) + } + texts := make([]string, 0, len(raw)) + for _, m := range raw { + if s, ok := m["text"].(string); ok { + texts = append(texts, s) + } + } + return texts } - // Explicit key still wins and normalizes. - p.Update(map[string]any{"overlapped_percent": 0.5}) - if p.TokenChunkerParam.OverlappedPercent != 50 { - t.Errorf("after explicit Update: overlapped_percent=%v, want 50 (normalized)", - p.TokenChunkerParam.OverlappedPercent) + t.Run("no carriage return survives", func(t *testing.T) { + texts := chunkTexts(t, "Para A\r\nPara B\r\nPara C") + if len(texts) == 0 { + t.Fatalf("expected chunks, got none") + } + for _, s := range texts { + if strings.Contains(s, "\r") { + t.Errorf("chunk text contains carriage return: %q", s) + } + } + }) + + t.Run("CRLF equals LF", func(t *testing.T) { + // With no blank-line pre-splitting, CRLF is + // normalised to LF and the blank-line run is preserved (not + // collapsed), so equal newline counts must yield equal chunks. + crlf := chunkTexts(t, "Para A\r\nPara B\r\nPara C") + lf := chunkTexts(t, "Para A\nPara B\nPara C") + if !reflect.DeepEqual(crlf, lf) { + t.Errorf("CRLF/LF divergence:\n crlf=%v\n lf =%v", crlf, lf) + } + }) +} + +// TestMergeByTokenSize_PreservesBlankLines verifies that an original +// blank-line run survives in the produced chunk: naive_merge treats the +// whole payload as a single section and does NOT split on blank lines. +func TestMergeByTokenSize_PreservesBlankLines(t *testing.T) { + c := &TokenChunkerComponent{} + c.param.ChunkTokenSize = 128 + c.param.OverlappedPercent = 0 + out := c.mergeByTokenSize("A\n\n\nB", nil) + raw, ok := out["chunks"].([]map[string]any) + if !ok { + t.Fatalf("mergeByTokenSize output missing chunks: %v", out) + } + var joined strings.Builder + for _, m := range raw { + if s, ok := m["text"].(string); ok { + joined.WriteString(s) + } + } + if got := joined.String(); !strings.Contains(got, "A\n\n\nB") { + t.Errorf("blank-line run not preserved: got chunk text %q, want it to contain %q", got, "A\n\n\nB") + } +} + +// TestMergeByTokenSize_OversizeDropsDelimiters verifies that when a +// section exceeds chunk_token_size, naive_merge splits on sentence +// delimiters with a capturing-group re.split but then SKIPS any segment +// that is a pure delimiter (re.fullmatch(dels, sub_sec)), so the +// delimiter character ("。") is DROPPED from the produced chunk text +// rather than retained (rag/nlp/__init__.py:1216-1225). The Go port uses +// regexp.Split (which discards the delimiter) and prepends a single "\n", +// so the merged chunk text must NOT contain the original "。". +func TestMergeByTokenSize_OversizeDropsDelimiters(t *testing.T) { + c := &TokenChunkerComponent{} + c.param.ChunkTokenSize = 5 + c.param.OverlappedPercent = 0 + text := "第一句。第二句。第三句。第四句。第五句。" + out := c.mergeByTokenSize(text, nil) + raw, ok := out["chunks"].([]map[string]any) + if !ok { + t.Fatalf("mergeByTokenSize output missing chunks: %v", out) + } + var joined strings.Builder + for _, m := range raw { + if s, ok := m["text"].(string); ok { + joined.WriteString(s) + } + } + if got := joined.String(); strings.Contains(got, "。") { + t.Errorf("sentence delimiter retained (Python drops it): got chunk text %q, want it to NOT contain %q", got, "。") + } +} + +// TestMergeByTokenSize_OversizeDropsBlankLines covers review #1: in the +// oversize sentence-split path, a blank line whose "\n" delimiter segments +// are dropped (matching Python naive_merge, rag/nlp/__init__.py:1216-1225) +// must NOT survive as a blank line. The lone "\n" delimiters produced by +// "\n\n" are dropped, so the merged chunk text must not contain "\n\n". +func TestMergeByTokenSize_OversizeDropsBlankLines(t *testing.T) { + c := &TokenChunkerComponent{} + c.param.ChunkTokenSize = 100 + c.param.OverlappedPercent = 0 + // Two ~70-char blocks (no sentence punctuation, so the only + // delimiters are the two blank-line newlines) separated by "\n\n". + // Total exceeds 100 tokens → oversize path; the "\n" delimiters are + // dropped, mirroring Python, so the blank line must not survive. + block := strings.Repeat("知识库检索增强生成技术", 7) // 70 chars + text := block + "\n\n" + block + out := c.mergeByTokenSize(text, nil) + raw, ok := out["chunks"].([]map[string]any) + if !ok { + t.Fatalf("mergeByTokenSize output missing chunks: %v", out) + } + var joined strings.Builder + for _, m := range raw { + if s, ok := m["text"].(string); ok { + joined.WriteString(s) + } + } + if got := joined.String(); strings.Contains(got, "\n\n") { + t.Errorf("blank line survived in oversize path (Python drops it): got chunk text %q, want no blank line (\\n\\n)", got) } } diff --git a/internal/ingestion/component/media_dispatch.go b/internal/ingestion/component/media_dispatch.go index 8d184bbb27..b359776feb 100644 --- a/internal/ingestion/component/media_dispatch.go +++ b/internal/ingestion/component/media_dispatch.go @@ -65,58 +65,22 @@ func maybeDispatchVideo( if fileType != utility.FileTypeVIDEO { return parserDispatchResult{}, false, nil } - setup, ok := setups["video"] - if !ok { + if _, ok := setups["video"]; !ok { return parserDispatchResult{}, false, nil } - tenantID := getStringOr(inputs, "tenant_id", "") - if tenantID == "" { - return parserDispatchResult{}, true, - fmt.Errorf("parser: video requires tenant_id") - } - // Resolve the tenant's IMAGE2TEXT model. - driver, modelName, apiConfig, _, err := resolveTenantModelByType(ctx, db, tenantID, entity.ModelTypeImage2Text) - if err != nil { - return parserDispatchResult{}, true, - fmt.Errorf("parser: video image2text model: %w", err) - } - - videoPrompt, _ := setup["prompt"].(string) - videoB64 := base64.StdEncoding.EncodeToString(binary) - - // Build a multimodal message with the video payload. - // Python uses cv_mdl.async_chat(video_bytes=blob, ...); - // Go ChatWithMessages is synchronous and uses a data URI. - mimeType := videoMIME(filename) - dataURI := "data:" + mimeType + ";base64," + videoB64 - messages := []modelModule.Message{{ - Role: "user", - Content: []interface{}{ - map[string]any{"type": "text", "text": videoPrompt}, - map[string]any{"type": "video_url", "video_url": map[string]any{"url": dataURI}}, - }, - }} - vision := true - resp, err := driver.ChatWithMessages(ctx, modelName, messages, apiConfig, &modelModule.ChatConfig{Vision: &vision}, nil) - if err != nil { - return parserDispatchResult{}, true, - fmt.Errorf("parser: video describe: %w", err) - } - txt := "" - if resp != nil && resp.Answer != nil { - txt = strings.TrimSpace(*resp.Answer) - } - - outputFormat, _ := setup["output_format"].(string) - if outputFormat == "" { - outputFormat = "text" - } - return parserDispatchResult{ - OutputFormat: outputFormat, - DocType: "video", - Text: txt, - }, true, nil + // Video parsing is intentionally not implemented yet: the underlying + // video-analysis capability is pending. The previously-shipped path sent a + // video_url data URI, but no model driver honors it — OpenAI-compatible + // drivers only accept image_url (the block is ignored), and Gemini's + // googleMessageParts only accepts text/image_url and silently drops + // video_url. Returning an explicit error is safer than silently producing + // a description from the prompt text alone. The real implementation must + // be provider-specific (OpenAI-compatible: frame extraction -> image_url; + // Gemini: raw-bytes inline_data; Qwen: file://) — see + // docs/migration_python_go_diff.md 2.7. + return parserDispatchResult{}, true, + fmt.Errorf("Parser: video parsing is not yet supported; underlying video analysis capability is pending") } // Image dispatch: OCR + IMAGE2TEXT vision describe --- @@ -321,7 +285,7 @@ func maybeDispatchAudio( outputFormat, _ := setup["output_format"].(string) if outputFormat == "" { - outputFormat = "text" + outputFormat = "json" } // Diff 2.11: when output_format is "json" the transcription must be // carried as a JSON item. Returning it only in Text made the Invoke @@ -415,7 +379,10 @@ func imageMIME(filename string) string { } // videoMIME maps common video filename extensions to MIME types -// for constructing base64 data URIs. +// for constructing base64 data URIs. Retained as a reference for the +// future real video-parsing implementation (provider-specific frame +// extraction / inline_data / file://); not currently used because +// maybeDispatchVideo returns an explicit unsupported error. func videoMIME(filename string) string { dot := strings.LastIndex(filename, ".") if dot == -1 { diff --git a/internal/ingestion/component/media_dispatch_test.go b/internal/ingestion/component/media_dispatch_test.go index 010dc0888b..61dfdd4bf2 100644 --- a/internal/ingestion/component/media_dispatch_test.go +++ b/internal/ingestion/component/media_dispatch_test.go @@ -324,6 +324,39 @@ func TestMaybeDispatchAudio_TextCarriesTranscription(t *testing.T) { } } +// TestMaybeDispatchAudio_DefaultOutputFormatJson covers Parser 2.11: +// the default audio output_format must be "json" (matching Python +// parser.py:232 and AllowedOutputFormat["audio"]={"json"}). +func TestMaybeDispatchAudio_DefaultOutputFormatJson(t *testing.T) { + const want = "hello world" + drv := &audioTranscribeDriver{transcription: want} + orig := resolveTenantModelByType + defer func() { resolveTenantModelByType = orig }() + resolveTenantModelByType = func(ctx context.Context, db *gorm.DB, tenantID string, modelType entity.ModelType) (modelModule.ModelDriver, string, *modelModule.APIConfig, int, error) { + return drv, "asr-model", &modelModule.APIConfig{}, 0, nil + } + setups := defaultSetups() + // Do NOT set output_format — exercise the default path. + res, dispatched, err := maybeDispatchAudio( + context.Background(), + nil, + utility.FileTypeAURAL, + "test.mp3", + []byte("fake-audio"), + map[string]any{"tenant_id": "t1"}, + setups, + ) + if err != nil { + t.Fatalf("maybeDispatchAudio: %v", err) + } + if !dispatched { + t.Fatal("expected dispatched=true for AURAL file") + } + if res.OutputFormat != "json" { + t.Fatalf("default OutputFormat = %q, want json", res.OutputFormat) + } +} + // TestMaybeDispatchMarkdownVision_EnhancesTables pins diff 2.5: markdown // vision enhancement must also process items whose doc_type_kwd is "table" // (Python checks {"image","table"} in parser/utils.py:181), not only "image". diff --git a/internal/ingestion/component/parser.go b/internal/ingestion/component/parser.go index 8659b9c092..dacfc6713d 100644 --- a/internal/ingestion/component/parser.go +++ b/internal/ingestion/component/parser.go @@ -311,7 +311,7 @@ func defaultSetups() map[string]schema.ParserSetup { "aiff", "au", "midi", "wma", "realaudio", "vqf", "oggvorbis", "ape", }, - "output_format": "text", + "output_format": "json", }, "video": { "suffix": []string{"mp4", "avi", "mkv"}, @@ -490,6 +490,13 @@ func (c *ParserComponent) Invoke(ctx context.Context, db *gorm.DB, inputs map[st // referenced images (![alt](url)). Mirrors Python's // enhance_media_sections_with_vision in _Markdown. dispatched, _, _ = maybeDispatchMarkdownVision(ctx, db, fileTypeExt, dispatched, inputs) + + // PDF vision figure enhancement: enrich parsed PDF JSON + // items with vision-model descriptions of embedded + // images/tables (doc_type_kwd "image"/"table" with non-empty + // image field). Mirrors Python's enhance_media_sections_with_vision + // in parser.py:_pdf + dispatched, _, _ = maybeDispatchPDFVisionEnhancement(ctx, db, fileTypeExt, dispatched, inputs) } // Known/supported families must fail loudly when dispatch or // parsing breaks. Only unknown families keep the raw-text fallback. diff --git a/internal/ingestion/component/pdf_vision_dispatch.go b/internal/ingestion/component/pdf_vision_dispatch.go index f98b6bb47c..ec9a67e5db 100644 --- a/internal/ingestion/component/pdf_vision_dispatch.go +++ b/internal/ingestion/component/pdf_vision_dispatch.go @@ -558,3 +558,96 @@ func isMinerUDriver(driver modelModule.ModelDriver) bool { } return false } + +// maybeDispatchPDFVisionEnhancement mirrors Python's +// enhance_media_sections_with_vision for PDF +// After the normal PDF parser produces JSON items, this function +// enriches image/table items by calling the tenant's IMAGE2TEXT +// model and appending vision descriptions to each item's text field. +// The markdown/text output paths are not enhanced (Python does the same). +// +// This follows the exact same convention as maybeDispatchDOCXVision and +// maybeDispatchMarkdownVision: it is not gated by a separate setup flag, +// it simply resolves the tenant's IMAGE2TEXT model and skips silently +// when none is configured — matching Python's try/except pass behaviour. +func maybeDispatchPDFVisionEnhancement( + ctx context.Context, + db *gorm.DB, + fileType utility.FileType, + dispatched parserDispatchResult, + inputs map[string]any, +) (parserDispatchResult, bool, error) { + if fileType != utility.FileTypePDF { + return dispatched, false, nil + } + if dispatched.Err != nil || dispatched.OutputFormat != "json" || len(dispatched.JSON) == 0 { + return dispatched, false, nil + } + tenantID := getStringOr(inputs, "tenant_id", "") + if tenantID == "" { + return dispatched, false, nil + } + driver, modelName, apiConfig, _, err := resolveTenantModelByType(ctx, db, tenantID, entity.ModelTypeImage2Text) + if err != nil { + return dispatched, false, nil + } + type target struct{ idx int } + var targets []target + for i, item := range dispatched.JSON { + kd, _ := item["doc_type_kwd"].(string) + if kd != "image" && kd != "table" { + continue + } + img, _ := item["image"].(string) + if img == "" { + continue + } + targets = append(targets, target{idx: i}) + } + if len(targets) == 0 { + return dispatched, false, nil + } + descriptions := make([]string, len(targets)) + var wg sync.WaitGroup + sem := make(chan struct{}, pdfVisionEnhanceConcurrency) + for slot, tg := range targets { + sem <- struct{}{} + wg.Add(1) + go func(slot int, itemIdx int) { + defer wg.Done() + defer func() { <-sem }() + img, _ := dispatched.JSON[itemIdx]["image"].(string) + if img == "" { + return + } + prompt, perr := docxVisionPromptBuilder("", "") + if perr != nil { + return + } + messages := buildVisionMessages(prompt, img) + resp, ierr := visionChatInvoker(ctx, driver, modelName, messages, apiConfig) + if ierr != nil { + return + } + descriptions[slot] = extractDOCXVisionAnswer(resp) + }(slot, tg.idx) + } + wg.Wait() + modified := false + for slot, tg := range targets { + desc := strings.TrimSpace(descriptions[slot]) + if desc == "" { + continue + } + existing, _ := dispatched.JSON[tg.idx]["text"].(string) + if existing != "" { + dispatched.JSON[tg.idx]["text"] = existing + "\n" + desc + } else { + dispatched.JSON[tg.idx]["text"] = desc + } + modified = true + } + return dispatched, modified, nil +} + +var pdfVisionEnhanceConcurrency = 10 diff --git a/internal/ingestion/component/schema/chunker.go b/internal/ingestion/component/schema/chunker.go index d8e7ddc952..6e9453dea8 100644 --- a/internal/ingestion/component/schema/chunker.go +++ b/internal/ingestion/component/schema/chunker.go @@ -188,7 +188,7 @@ type TokenChunkerParam struct { // OverlappedPercent is the overlap percentage in [0, 90]. Mirrors // Python common/float_utils.py:50-58 — an integer-like float in the // same range so DSL templates written for the Python pipeline work - // out of the box (diff Chunker-2.6). A [0,1) fraction input is also + // out of the box A [0,1) fraction input is also // accepted and normalized to this scale by tokenChunkerParam.Update // (via normalizeOverlappedPercent). OverlappedPercent float64 `json:"overlapped_percent"` diff --git a/internal/ingestion/component/tokenizer.go b/internal/ingestion/component/tokenizer.go index 524801a2e9..cf7c5644cc 100644 --- a/internal/ingestion/component/tokenizer.go +++ b/internal/ingestion/component/tokenizer.go @@ -35,18 +35,6 @@ // `internal/tokenizer/tokenizer.go:Tokenize` (Infinity engine // returns input unchanged; otherwise the C++ binding is used). // -// - CJK CAVEAT (plan §8 Q2): The `NumTokensFromString` helper in -// `internal/tokenizer` falls back to `len([]byte(s))` on a -// tiktoken-init failure (over-counts CJK). The Python equivalent -// returns 0. The Go port KEEPS the Go behaviour — the tokenizer -// package is the single source of truth for token counting and -// must not be re-implemented here. Test -// `TestTokenizerComponent_Invoke_Unicode` asserts only that the -// count is finite and non-negative, matching the test -// convention in plan §6 (coverage target: -// "Tokenizer returns finite token counts for empty / unicode / -// mixed-script text"). -// // - EMBEDDING MODEL RESOLUTION: mirrored. Python uses // `LLMBundle(tenant_id, embd_id).encode([...])` from // `rag/flow/tokenizer/tokenizer.py:54-66`; the Go port goes @@ -73,8 +61,8 @@ // Drivers that need to chunk internally can do so — the wire // call is one round-trip. // -// - TRACKING: WithTimeout (60s, matches python `@timeout(60)` on -// `batch_encode`), TrackProgress, TrackElapsed. See +// - TRACKING: TrackProgress, TrackElapsed. See +// `internal/agent/runtime/helpers.go` (plan §1 Phase 1). // `internal/agent/runtime/helpers.go` (plan §1 Phase 1). // // - WHAT IS NOT PORTED: @@ -99,7 +87,6 @@ import ( "slices" "strconv" "strings" - "time" "go.uber.org/zap" "gorm.io/gorm" @@ -114,25 +101,19 @@ import ( const ComponentNameTokenizer = "Tokenizer" -// tokenizerTimeout returns the per-batch timeout for embedding API calls. -// Reads COMPONENT_EXEC_TIMEOUT_TOKENIZER env var (seconds); defaults to 600s -// (10 min) to match the canvas-level component timeout default. -// Invalid / non-positive values fall back to the default. -func tokenizerTimeout() time.Duration { - if v := os.Getenv("COMPONENT_EXEC_TIMEOUT_TOKENIZER"); v != "" { - if secs, err := strconv.Atoi(v); err == nil && secs > 0 { - return time.Duration(secs) * time.Second +// embeddingBatchSize returns the embedding batch size, matching Python's +// settings.EMBEDDING_BATCH_SIZE. Reads TOKENIZER_EMBEDDING_BATCH_SIZE env +// var; defaults to 16. Invalid / non-positive values fall back to the +// default (diff Tokenizer Omission-3). +func embeddingBatchSize() int { + if v := os.Getenv("TOKENIZER_EMBEDDING_BATCH_SIZE"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n } } - return defaultTokenizerTimeout + return 16 } -var defaultTokenizerTimeout = 600 * time.Second - -// tokenizerEmbeddingBatchSize mirrors Python's -// settings.EMBEDDING_BATCH_SIZE default. -var tokenizerEmbeddingBatchSize = 16 - // titleExtRE strips a trailing file-extension (e.g. ".pdf") from the // upstream document name before tokenizing it. Mirrors the python // `re.sub(r"\.[a-zA-Z]+$", "", name)` in tokenizer.py:137. @@ -357,6 +338,15 @@ func (c *TokenizerComponent) Invoke(ctx context.Context, db *gorm.DB, inputs map normalizeChunkTextFallback(chunks) + // Diff Tokenizer-8: chunk_order_int must be set on all paths + // (Python sets it unconditionally). tokenizeChunks also sets it + // for the full_text path; this covers the embedding-only path. + for i := range chunks { + if chunks[i].ChunkOrderInt == nil { + chunks[i].ChunkOrderInt = intPtr(i) + } + } + language := globals.GlobalOrInput(ctx, inputs, "lang", "English") if contains(c.param.SearchMethod, "full_text") { @@ -439,7 +429,7 @@ func (c *TokenizerComponent) embedChunks(ctx context.Context, tenantID, kbID, em // tokenizer.py:95 which passes name verbatim to embedding. The // empty-name guard above still uses TrimSpace, matching Python's // `.strip()==""` check at tokenizer.py:200. - titleResults, err := encodeWithTimeout(ctx, embedder, []string{name}) + titleResults, err := embedder.Encode(ctx, []string{name}) if err != nil { return nil, 0, fmt.Errorf("Tokenizer: encode title: %w", err) } @@ -452,12 +442,12 @@ func (c *TokenizerComponent) embedChunks(ctx context.Context, tenantID, kbID, em } contentResults := make([]EmbeddingResult, 0, len(texts)) - for start := 0; start < len(texts); start += tokenizerEmbeddingBatchSize { - end := start + tokenizerEmbeddingBatchSize + for start := 0; start < len(texts); start += embeddingBatchSize() { + end := start + embeddingBatchSize() if end > len(texts) { end = len(texts) } - batchResults, err := encodeWithTimeout(ctx, embedder, texts[start:end]) + batchResults, err := embedder.Encode(ctx, texts[start:end]) if err != nil { return nil, 0, fmt.Errorf("Tokenizer: encode: %w", err) } @@ -486,24 +476,13 @@ func (c *TokenizerComponent) embedChunks(ctx context.Context, tenantID, kbID, em return chunks, tokenCount, nil } -func encodeWithTimeout(ctx context.Context, embedder Embedder, texts []string) ([]EmbeddingResult, error) { - var ( - results []EmbeddingResult - encErr error - ) - timeoutErr := runtime.WithTimeout(ctx, tokenizerTimeout(), func(timeoutCtx context.Context) error { - results, encErr = embedder.Encode(ctx, texts) - return encErr - }) - if timeoutErr != nil { - return nil, timeoutErr - } - return results, nil -} - +// truncateForEmbedding truncates text to fit within maxTokens, +// reserving 10 tokens for special/model overhead. Mirrors Python +// tokenizer.py truncation: when maxTokens <= 10, the text is +// truncated to empty func truncateForEmbedding(text string, maxTokens int) string { if maxTokens <= 10 { - return text + return "" } return tokenizer.TrimContentToTokenLimit(text, maxTokens-10) } @@ -552,18 +531,39 @@ func stripRuntimeTimestamps(inputs map[string]any) map[string]any { } func chunksFromTokenizerUpstream(in schema.TokenizerFromUpstream) []schema.ChunkDoc { + var raw []schema.ChunkDoc switch in.OutputFormat { case schema.PayloadFormatChunks: - return cloneChunkDocs(in.Chunks) + raw = cloneChunkDocs(in.Chunks) case schema.PayloadFormatMarkdown: - return textPayloadToChunks(in.MarkdownResult) + raw = textPayloadToChunks(in.MarkdownResult) case schema.PayloadFormatText: - return textPayloadToChunks(in.TextResult) + raw = textPayloadToChunks(in.TextResult) case schema.PayloadFormatHTML: - return textPayloadToChunks(in.HTMLResult) + raw = textPayloadToChunks(in.HTMLResult) default: - return cloneChunkDocs(in.JSONResult) + raw = cloneChunkDocs(in.JSONResult) } + // Discard zero-value ChunkDocs (no text, no content_with_weight, no + // image, no summary) so they don't produce phantom embeddings + // downstream. Python's pipeline filters none-chunks before the + // tokenizer. + filtered := raw[:0] + for _, ck := range raw { + if isPhantomChunk(ck) { + continue + } + filtered = append(filtered, ck) + } + return filtered +} + +// isPhantomChunk returns true when a ChunkDoc has no usable content for +// downstream tokenization or embedding. A chunk carrying only a Summary +// (no Text/Image/ContentWithWeight) is kept — tokenizeChunks tokenizes the +// Summary in that case. Mirrors Python's none-chunk filtering. +func isPhantomChunk(ck schema.ChunkDoc) bool { + return ck.Text == "" && ck.Image == "" && ck.ContentWithWeight == "" && ck.Summary == "" } func textPayloadToChunks(payload *string) []schema.ChunkDoc { @@ -683,7 +683,10 @@ func tokenizeChunks(chunks []schema.ChunkDoc, titleStem string, language string) return fmt.Errorf("Tokenizer: keyword tokens marshal: %w", err) } } - if s := ck.Summary; strings.TrimSpace(s) != "" { + // Keep Go: skip whitespace-only summaries so they don't shadow + // the real Text. Python's truthy check (tokenizer.py:155) treats + // " " as present and blanks out content_ltks; Go is more sensible. + if s := strings.TrimSpace(ck.Summary); s != "" { st, err := tok.Tokenize(s) if err != nil { return fmt.Errorf("Tokenizer: summary tokenize: %w", err) diff --git a/internal/ingestion/component/tokenizer_test.go b/internal/ingestion/component/tokenizer_test.go index afdebcb587..5dd986e6d2 100644 --- a/internal/ingestion/component/tokenizer_test.go +++ b/internal/ingestion/component/tokenizer_test.go @@ -365,7 +365,7 @@ func TestTokenizerComponent_Invoke_EncoderCountMismatch(t *testing.T) { // Inject an embedder that returns the wrong number of vectors // regardless of input. wrong := &countMismatchedEmbedder{want: 1} - cIntf, err := NewTokenizerComponentWithResolver(nil, func(_, _, _ string) (Embedder, error) { return wrong, nil }) + cIntf, err := NewTokenizerComponentWithResolver(nil, func(_ context.Context, _, _, _ string) (Embedder, error) { return wrong, nil }) if err != nil { t.Fatalf("NewTokenizerComponentWithResolver: %v", err) } @@ -385,7 +385,7 @@ func TestTokenizerComponent_Invoke_EncoderCountMismatch(t *testing.T) { type countMismatchedEmbedder struct{ want int } -func (c *countMismatchedEmbedder) MaxTokens() int { return 0 } +func (c *countMismatchedEmbedder) MaxTokens() int { return 2048 } func (c *countMismatchedEmbedder) Encode(ctx context.Context, texts []string) ([]EmbeddingResult, error) { out := make([]EmbeddingResult, c.want) @@ -395,31 +395,6 @@ func (c *countMismatchedEmbedder) Encode(ctx context.Context, texts []string) ([ return out, nil } -// TestTokenizerComponent_Invoke_HonorsTimeout installs an -// embedder that blocks past a (test-shrunk) tokenizerTimeout and -// asserts the component returns context.DeadlineExceeded. -func TestTokenizerComponent_Invoke_HonorsTimeout(t *testing.T) { - requireTokenizerPool(t) - t.Setenv("COMPONENT_EXEC_TIMEOUT_TOKENIZER", "1") - - c, stub := withStubEmbedder(t, 4) - stub.delay = 2 * time.Second - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - _, err := c.Invoke(ctx, nil, map[string]any{ - "output_format": "chunks", - "chunks": []map[string]any{{"text": "alpha"}}, - }) - if err == nil { - t.Fatal("expected timeout error, got nil") - } - if !errors.Is(err, context.DeadlineExceeded) { - t.Errorf("expected context.DeadlineExceeded, got %v", err) - } -} - // TestTokenizerComponent_Smoke_EndToEnd is the BLOCKER smoke test // (plan §8 R3). Drives 1 chunk of ~1000 tokens through the real // tokenizer and a stub embedder with no artificial latency, then @@ -435,7 +410,7 @@ func TestTokenizerComponent_Invoke_HonorsTimeout(t *testing.T) { // production path against a real embedding API was not exercised // in this CI sandbox; the helper `withStubEmbedder` deliberately // avoids the network round-trip while still exercising the full -// wiring (TrackElapsed, WithTimeout, batched Encode, vector +// wiring (TrackElapsed, batched Encode, vector // stamping). func TestTokenizerComponent_Smoke_EndToEnd(t *testing.T) { @@ -525,7 +500,7 @@ func TestTokenizerComponent_Embedding_UsesFilenameWeight(t *testing.T) { requireTokenizerPool(t) cIntf, err := NewTokenizerComponentWithResolver(map[string]any{ "filename_embd_weight": 0.25, - }, func(_, _, _ string) (Embedder, error) { + }, func(_ context.Context, _, _, _ string) (Embedder, error) { stub := newStubEmbedder(2) stub.resultsByCall = []embeddingCallResult{ {vectors: [][]float64{{8, 8}}, tokenCount: 3}, @@ -672,9 +647,7 @@ func TestTokenizerComponent_Embedding_SetsTokenConsumptionIncludingTitleCall(t * requireTokenizerPool(t) c, stub := withStubEmbedder(t, 2) stub.callTokens = []int{3, 5, 7} - prevBatchSize := tokenizerEmbeddingBatchSize - tokenizerEmbeddingBatchSize = 1 - t.Cleanup(func() { tokenizerEmbeddingBatchSize = prevBatchSize }) + t.Setenv("TOKENIZER_EMBEDDING_BATCH_SIZE", "1") out, err := c.Invoke(context.Background(), nil, map[string]any{ "name": "doc.pdf", @@ -692,9 +665,7 @@ func TestTokenizerComponent_Embedding_SetsTokenConsumptionIncludingTitleCall(t * func TestTokenizerComponent_Embedding_BatchesByConfiguredBatchSize(t *testing.T) { requireTokenizerPool(t) c, stub := withStubEmbedder(t, 2) - prevBatchSize := tokenizerEmbeddingBatchSize - tokenizerEmbeddingBatchSize = 2 - t.Cleanup(func() { tokenizerEmbeddingBatchSize = prevBatchSize }) + t.Setenv("TOKENIZER_EMBEDDING_BATCH_SIZE", "2") if _, err := c.Invoke(context.Background(), nil, map[string]any{ "name": "doc.pdf", @@ -732,7 +703,7 @@ func floatSliceClose(got, want []float64) bool { func TestTokenizerComponent_InstanceResolversDoNotLeakAcrossComponents(t *testing.T) { requireTokenizerPool(t) - compAIntf, err := NewTokenizerComponentWithResolver(nil, func(_, _, _ string) (Embedder, error) { + compAIntf, err := NewTokenizerComponentWithResolver(nil, func(_ context.Context, _, _, _ string) (Embedder, error) { stub := newStubEmbedder(2) stub.resultsByCall = []embeddingCallResult{{vectors: [][]float64{{10, 10}}, tokenCount: 1}, {vectors: [][]float64{{1, 1}}, tokenCount: 1}} return stub, nil @@ -740,7 +711,7 @@ func TestTokenizerComponent_InstanceResolversDoNotLeakAcrossComponents(t *testin if err != nil { t.Fatalf("NewTokenizerComponentWithResolver(A): %v", err) } - compBIntf, err := NewTokenizerComponentWithResolver(nil, func(_, _, _ string) (Embedder, error) { + compBIntf, err := NewTokenizerComponentWithResolver(nil, func(_ context.Context, _, _, _ string) (Embedder, error) { stub := newStubEmbedder(2) stub.resultsByCall = []embeddingCallResult{{vectors: [][]float64{{20, 20}}, tokenCount: 1}, {vectors: [][]float64{{2, 2}}, tokenCount: 1}} return stub, nil @@ -751,11 +722,11 @@ func TestTokenizerComponent_InstanceResolversDoNotLeakAcrossComponents(t *testin compA := compAIntf.(*TokenizerComponent) compB := compBIntf.(*TokenizerComponent) - outA, err := compA.Invoke(context.Background(), map[string]any{"name": "docA", "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha"}}}) + outA, err := compA.Invoke(context.Background(), nil, map[string]any{"name": "docA", "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha"}}}) if err != nil { t.Fatalf("Invoke A: %v", err) } - outB, err := compB.Invoke(context.Background(), map[string]any{"name": "docB", "output_format": "chunks", "chunks": []map[string]any{{"text": "beta"}}}) + outB, err := compB.Invoke(context.Background(), nil, map[string]any{"name": "docB", "output_format": "chunks", "chunks": []map[string]any{{"text": "beta"}}}) if err != nil { t.Fatalf("Invoke B: %v", err) } diff --git a/internal/ingestion/component/tokenizer_unit_test.go b/internal/ingestion/component/tokenizer_unit_test.go index b50ac430c4..9e3bdcd65b 100644 --- a/internal/ingestion/component/tokenizer_unit_test.go +++ b/internal/ingestion/component/tokenizer_unit_test.go @@ -23,6 +23,7 @@ package component import ( "context" "encoding/json" + "os" "strings" "sync/atomic" "testing" @@ -90,8 +91,10 @@ func (s *stubEmbedder) Encode(ctx context.Context, texts []string) ([]EmbeddingR } // newStubEmbedder returns a stub embedder for instance-level resolver injection. +// maxTokens defaults to 2048 so truncateForEmbedding (Diff 14: maxTokens <= 10 -> "") +// does not empty the content text; tests that exercise truncation set maxTokens explicitly. func newStubEmbedder(dim int) *stubEmbedder { - return &stubEmbedder{dim: dim} + return &stubEmbedder{dim: dim, maxTokens: 2048} } // withStubEmbedder constructs a TokenizerComponent with an instance-scoped @@ -348,3 +351,147 @@ func TestChunkDocsToMaps_PreservesPDFPositions(t *testing.T) { t.Errorf("positions page already converted; want raw 1-indexed page 1, got %v", got[0][0]) } } + +// TestIsPhantomChunk verifies that zero-value ChunkDocs (no Text, no +// Image, no ContentWithWeight, no Summary) are identified as phantom, +// while any one of those fields being present keeps the chunk. +func TestIsPhantomChunk(t *testing.T) { + if !isPhantomChunk(schema.ChunkDoc{}) { + t.Error("empty ChunkDoc must be phantom") + } + if !isPhantomChunk(schema.ChunkDoc{Text: ""}) { + t.Error("ChunkDoc with empty Text only must be phantom") + } + if isPhantomChunk(schema.ChunkDoc{Text: "hello"}) { + t.Error("ChunkDoc with Text must not be phantom") + } + if isPhantomChunk(schema.ChunkDoc{Image: "data:image/png;base64,abc"}) { + t.Error("ChunkDoc with Image must not be phantom") + } + if isPhantomChunk(schema.ChunkDoc{ContentWithWeight: "weight"}) { + t.Error("ChunkDoc with ContentWithWeight must not be phantom") + } + if isPhantomChunk(schema.ChunkDoc{Summary: "a summary"}) { + t.Error("ChunkDoc with Summary must not be phantom") + } +} + +// TestTruncateForEmbedding_SmallMaxTokens covers Tokenizer Diff-14: when +// maxTokens <= 10, Go must truncate to empty, matching Python's +// truncation-to-empty behaviour (common/token_utils.py:183-185). +func TestTruncateForEmbedding_SmallMaxTokens(t *testing.T) { + long := strings.Repeat("a", 100) + if got := truncateForEmbedding(long, 5); got != "" { + t.Errorf("truncateForEmbedding(maxTokens=5) = %q, want %q", got, "") + } + if got := truncateForEmbedding(long, 10); got != "" { + t.Errorf("truncateForEmbedding(maxTokens=10) = %q, want %q", got, "") + } + // Normal path: maxTokens > 10 should truncate (not return empty). + if got := truncateForEmbedding(long, 50); got == "" { + t.Error("truncateForEmbedding(maxTokens=50) returned empty, want truncated text") + } +} + +// TestEmbeddingBatchSizeEnvVar covers Tokenizer Omission-3: the batch size +// must be configurable via TOKENIZER_EMBEDDING_BATCH_SIZE env var, matching +// Python's configurable settings.EMBEDDING_BATCH_SIZE. +func TestEmbeddingBatchSizeEnvVar(t *testing.T) { + if got := embeddingBatchSize(); got != 16 { + t.Errorf("embeddingBatchSize() default = %d, want 16", got) + } + os.Setenv("TOKENIZER_EMBEDDING_BATCH_SIZE", "32") + t.Cleanup(func() { os.Unsetenv("TOKENIZER_EMBEDDING_BATCH_SIZE") }) + if got := embeddingBatchSize(); got != 32 { + t.Errorf("embeddingBatchSize() after env = %d, want 32", got) + } + // Invalid value falls back to default. + os.Setenv("TOKENIZER_EMBEDDING_BATCH_SIZE", "bad") + if got := embeddingBatchSize(); got != 16 { + t.Errorf("embeddingBatchSize() invalid env = %d, want 16", got) + } +} + +// TestChunkOrderInt_EmbeddingOnly covers Tokenizer Diff-8: chunk_order_int +// must be set even when search_method does not include "full_text" (i.e. +// embedding-only path). tokenizeChunks previously only set it for the +// full_text branch. +func TestChunkOrderInt_EmbeddingOnly(t *testing.T) { + stub := newStubEmbedder(3) + comp, err := NewTokenizerComponentWithResolver( + map[string]any{"search_method": []string{"embedding"}, "fields": []string{"text"}}, + func(ctx context.Context, _, _, _ string) (Embedder, error) { return stub, nil }, + ) + if err != nil { + t.Fatalf("NewTokenizerComponentWithResolver: %v", err) + } + inputs := map[string]any{ + "name": "doc.pdf", + "output_format": "json", + "json": []map[string]any{ + {"text": "first chunk", "doc_type_kwd": "text"}, + {"text": "second chunk", "doc_type_kwd": "text"}, + }, + } + out, err := comp.Invoke(context.Background(), nil, inputs) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + chunks := out["chunks"].([]map[string]any) + if len(chunks) != 2 { + t.Fatalf("want 2 chunks, got %d", len(chunks)) + } + for i, ck := range chunks { + coi, ok := ck["chunk_order_int"] + if !ok { + t.Errorf("chunk %d: chunk_order_int missing (embedding-only path must set it)", i) + } + if coi == nil { + t.Errorf("chunk %d: chunk_order_int is nil", i) + } + } +} + +// TestChunksFromTokenizerUpstream_FiltersPhantomChunks covers Tokenizer +// Omission-2 at the pipeline level: when upstream input contains a +// zero-value ChunkDoc (no Text, no Image, no ContentWithWeight), it must +// be silently dropped from the output before tokenization and embedding. +// This mirrors Python's `if not text and not d.get("image"): continue` +// in tokenizer.py:80-82. +func TestChunksFromTokenizerUpstream_FiltersPhantomChunks(t *testing.T) { + // JSON path: three items, the middle one is a phantom. + items := []map[string]any{ + {"text": "valid chunk", "doc_type_kwd": "text"}, + {}, // phantom: no text, no image, no content_with_weight + {"text": "another valid", "doc_type_kwd": "text"}, + } + // Use embedding-only mode to avoid CGo tokenizer dependency. + stub := newStubEmbedder(3) + comp, err := NewTokenizerComponentWithResolver( + map[string]any{"search_method": []string{"embedding"}, "fields": []string{"text"}}, + func(ctx context.Context, _, _, _ string) (Embedder, error) { return stub, nil }, + ) + if err != nil { + t.Fatalf("NewTokenizerComponentWithResolver: %v", err) + } + out, err := comp.Invoke(context.Background(), nil, map[string]any{ + "name": "doc.pdf", + "output_format": "json", + "json": items, + }) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + chunks := out["chunks"].([]map[string]any) + // Must drop the phantom — only 2 valid chunks remain. + if len(chunks) != 2 { + t.Fatalf("want 2 chunks (phantom filtered), got %d", len(chunks)) + } + // Verify the surviving chunks are the valid ones. + if chunks[0]["text"] != "valid chunk" { + t.Errorf("chunk 0 text = %q, want %q", chunks[0]["text"], "valid chunk") + } + if chunks[1]["text"] != "another valid" { + t.Errorf("chunk 1 text = %q, want %q", chunks[1]["text"], "another valid") + } +} diff --git a/internal/ingestion/pipeline/template_integration_test.go b/internal/ingestion/pipeline/template_integration_test.go index 8d22b96ff4..59ead62fe8 100644 --- a/internal/ingestion/pipeline/template_integration_test.go +++ b/internal/ingestion/pipeline/template_integration_test.go @@ -44,7 +44,7 @@ import ( type fixedEmbedder struct{} -func (fixedEmbedder) MaxTokens() int { return 0 } +func (fixedEmbedder) MaxTokens() int { return 2048 } func (fixedEmbedder) Encode(ctx context.Context, texts []string) ([]componentpkg.EmbeddingResult, error) { out := make([]componentpkg.EmbeddingResult, 0, len(texts)) diff --git a/internal/ingestion/service/real_consumer_pipeline_test.go b/internal/ingestion/service/real_consumer_pipeline_test.go index 9f8e4c7716..23d81f4527 100644 --- a/internal/ingestion/service/real_consumer_pipeline_test.go +++ b/internal/ingestion/service/real_consumer_pipeline_test.go @@ -79,11 +79,11 @@ func TestRealConsumer_PipelineMessageRoutesToExecuteTask(t *testing.T) { } ingestionTaskDAO := dao.NewIngestionTaskDAO() - _, err = ingestionTaskDAO.UpdateStatusIfCurrent(taskMsg.TaskID, common.CREATED, common.RUNNING) + _, err = ingestionTaskDAO.UpdateStatusIfCurrent(context.Background(), db, taskMsg.TaskID, common.CREATED, common.RUNNING) if err != nil { t.Fatalf("UpdateStatusIfCurrent: %v", err) } - task, err := ingestionTaskDAO.GetByID(taskMsg.TaskID) + task, err := ingestionTaskDAO.GetByID(context.Background(), db, taskMsg.TaskID) if err != nil || task == nil { t.Fatalf("task not found after publish: %s", taskMsg.TaskID) } @@ -102,7 +102,7 @@ func TestRealConsumer_PipelineMessageRoutesToExecuteTask(t *testing.T) { return nil } - ingestor.executeTask(taskCtx) + ingestor.executeTask(context.Background(), taskCtx) if !routedToPipeline { t.Fatal("expected executeTask to route queue-consumed pipeline task to runDocumentTask") @@ -111,7 +111,7 @@ func TestRealConsumer_PipelineMessageRoutesToExecuteTask(t *testing.T) { t.Fatalf("Ack: %v", err) } - finalTask, err := ingestionTaskDAO.GetByID(task.ID) + finalTask, err := ingestionTaskDAO.GetByID(context.Background(), db, task.ID) if err != nil { t.Fatalf("GetByID: %v", err) } diff --git a/internal/ingestion/task/chunk_process_test.go b/internal/ingestion/task/chunk_process_test.go index df251d8df4..1f1e409777 100644 --- a/internal/ingestion/task/chunk_process_test.go +++ b/internal/ingestion/task/chunk_process_test.go @@ -296,7 +296,8 @@ func TestProcessChunksForPipeline_PreservesContentWithWeight(t *testing.T) { func TestProcessChunkPositions_FlatFloat64(t *testing.T) { chunk := map[string]any{ - "positions": []float64{0, 100, 50, 200, 150}, + // positions is 1-indexed (parser normalized before we see it) + "positions": []float64{1, 100, 50, 200, 150}, } processChunkPositions(chunk) @@ -312,8 +313,8 @@ func TestProcessChunkPositions_FlatFloat64(t *testing.T) { func TestProcessChunkPositions_2DFloat64(t *testing.T) { chunk := map[string]any{ "positions": [][]float64{ - {0, 100, 50, 200, 150}, - {1, 200, 60, 300, 250}, + {1, 100, 50, 200, 150}, + {2, 200, 60, 300, 250}, }, } processChunkPositions(chunk) diff --git a/internal/ingestion/task/position.go b/internal/ingestion/task/position.go index cc9b1dc766..e9f53bf9ad 100644 --- a/internal/ingestion/task/position.go +++ b/internal/ingestion/task/position.go @@ -18,14 +18,14 @@ package task // AddPositions adds position fields to a chunk map. // Input positions is a flat []float64 grouped as [pn, left, right, top, bottom] -// every 5 elements. pn is 0-indexed; output is 1-indexed. +// every 5 elements. pn is ALREADY 1-indexed — the 0→1 conversion happens +// once, at the parser boundary (normalizePDFPageNumber for the DeepDoc PDF +// path; TCADP writes 1-indexed directly). This function is a passthrough: it +// must NOT add +1, otherwise the PDF path (which already normalized) would +// double-increment page numbers. // -// Mirrors Python: rag.nlp.add_positions() -// -// for pn, left, right, top, bottom in poss: -// page_num_int.append(int(pn + 1)) -// top_int.append(int(top)) -// position_int.append((int(pn + 1), int(left), int(right), int(top), int(bottom))) +// Mirrors Python: rag.nlp.add_positions() (Python adds +1 because its +// callers feed 0-indexed values; the Go pipeline normalizes earlier). func AddPositions(chunk map[string]any, positions []float64) { if len(positions) == 0 || len(positions)%5 != 0 { return @@ -36,7 +36,7 @@ func AddPositions(chunk map[string]any, positions []float64) { positionInt := make([][]int, 0, n) for i := 0; i < len(positions); i += 5 { - pn := int(positions[i]) + 1 // 0-indexed → 1-indexed + pn := int(positions[i]) // already 1-indexed left := int(positions[i+1]) right := int(positions[i+2]) top := int(positions[i+3]) diff --git a/internal/ingestion/task/position_test.go b/internal/ingestion/task/position_test.go index 6e31c6a1fc..2cb79159dc 100644 --- a/internal/ingestion/task/position_test.go +++ b/internal/ingestion/task/position_test.go @@ -7,13 +7,17 @@ import ( // ============================================================================= // AddPositions // Canonical format: [pageNum, left, right, top, bottom] × N -// Mirrors Python: rag.nlp.add_positions() +// Contract: input page numbers are ALREADY 1-indexed (the 0→1 conversion +// happens once, at the parser boundary in normalizePDFPageNumber). This +// function is a passthrough — it must NOT add +1, otherwise callers that +// already feed 1-indexed values (the PDF path after normalization) get a +// double-incremented page number. // ============================================================================= func TestAddPositions_Basic(t *testing.T) { chunk := map[string]any{} - // [pn=0, left=100, right=50, top=200, bottom=150] - positions := []float64{0, 100, 50, 200, 150} + // [pn=1 (first page, 1-indexed), left=100, right=50, top=200, bottom=150] + positions := []float64{1, 100, 50, 200, 150} AddPositions(chunk, positions) pageNum, ok := chunk["page_num_int"].([]int) @@ -36,8 +40,8 @@ func TestAddPositions_Basic(t *testing.T) { func TestAddPositions_MultiplePositions(t *testing.T) { chunk := map[string]any{} positions := []float64{ - 0, 100, 50, 200, 150, // pn=0, left=100, right=50, top=200, bottom=150 - 1, 200, 60, 300, 250, // pn=1, left=200, right=60, top=300, bottom=250 + 1, 100, 50, 200, 150, // pn=1, left=100, right=50, top=200, bottom=150 + 2, 200, 60, 300, 250, // pn=2, left=200, right=60, top=300, bottom=250 } AddPositions(chunk, positions) @@ -73,20 +77,25 @@ func TestAddPositions_EmptyPositions(t *testing.T) { func TestAddPositions_PartialPositions(t *testing.T) { chunk := map[string]any{} - positions := []float64{0, 100} // only 2 elements, not a complete position + positions := []float64{1, 100} // only 2 elements, not a complete position AddPositions(chunk, positions) if _, exists := chunk["page_num_int"]; exists { t.Error("page_num_int should not be set for partial positions") } } -func TestAddPositions_PageNumOffset(t *testing.T) { +func TestAddPositions_PassthroughNoOffset(t *testing.T) { + // page numbers are 1-indexed on entry; AddPositions must not add +1. chunk := map[string]any{} - positions := []float64{5, 100, 50, 200, 150} // pn=5 → 5+1=6 + positions := []float64{6, 100, 50, 200, 150} // pn=6, 1-indexed AddPositions(chunk, positions) pageNum := chunk["page_num_int"].([]int) if pageNum[0] != 6 { - t.Errorf("page_num_int = %d, want 6 (5+1)", pageNum[0]) + t.Errorf("page_num_int = %d, want 6 (passthrough, no +1)", pageNum[0]) + } + position := chunk["position_int"].([][]int) + if position[0][0] != 6 { + t.Errorf("position_int[0][0] = %d, want 6 (passthrough, no +1)", position[0][0]) } } diff --git a/internal/ingestion/task/real_consumer_test.go b/internal/ingestion/task/real_consumer_test.go index 24185f9049..3be0c70ac7 100644 --- a/internal/ingestion/task/real_consumer_test.go +++ b/internal/ingestion/task/real_consumer_test.go @@ -67,7 +67,7 @@ func TestRealProducerConsumer(t *testing.T) { DatasetID: "kb1", Status: common.CREATED, } - created, err := dao.NewIngestionTaskDAO().Create(ingestionTask) + created, err := dao.NewIngestionTaskDAO().Create(context.Background(), db, ingestionTask) if err != nil { t.Fatalf("Create: %v", err) } @@ -103,11 +103,11 @@ func TestRealProducerConsumer(t *testing.T) { // Mirrors Start():142-143 — UpdateStatusIfCurrent ingestionTaskDAO := dao.NewIngestionTaskDAO() - _, err = ingestionTaskDAO.UpdateStatusIfCurrent(taskMsg.TaskID, common.CREATED, common.RUNNING) + _, err = ingestionTaskDAO.UpdateStatusIfCurrent(context.Background(), db, taskMsg.TaskID, common.CREATED, common.RUNNING) if err != nil { t.Fatalf("UpdateStatusIfCurrent: %v", err) } - task, err := ingestionTaskDAO.GetByID(taskMsg.TaskID) + task, err := ingestionTaskDAO.GetByID(context.Background(), db, taskMsg.TaskID) if err != nil { t.Fatalf("GetByID: %v", err) } @@ -161,7 +161,7 @@ func TestRealProducerConsumer(t *testing.T) { t.Log("Consumer: PipelineExecutor.Execute() - OK") // Mirrors executeTask — mark as completed - if _, err := ingestionTaskDAO.UpdateStatusIfCurrent(task.ID, common.RUNNING, common.COMPLETED); err != nil { + if _, err := ingestionTaskDAO.UpdateStatusIfCurrent(context.Background(), db, task.ID, common.RUNNING, common.COMPLETED); err != nil { t.Fatalf("UpdateStatus: %v", err) } @@ -169,7 +169,7 @@ func TestRealProducerConsumer(t *testing.T) { taskHandle.Ack() // ── 6. Verify ── - final, _ := ingestionTaskDAO.GetByID(task.ID) + final, _ := ingestionTaskDAO.GetByID(context.Background(), db, task.ID) if final.Status != common.COMPLETED { t.Errorf("final status = %s, want %s", final.Status, common.COMPLETED) } diff --git a/internal/parser/parser/office_parsers_nocgo_test.go b/internal/parser/parser/office_parsers_nocgo_test.go index 19cb033221..f868264bb2 100644 --- a/internal/parser/parser/office_parsers_nocgo_test.go +++ b/internal/parser/parser/office_parsers_nocgo_test.go @@ -3,7 +3,6 @@ package parser import ( - "context" "errors" "testing" ) diff --git a/internal/parser/parser/pdf_parser_common.go b/internal/parser/parser/pdf_parser_common.go index 431bc376fe..7de46ede7b 100644 --- a/internal/parser/parser/pdf_parser_common.go +++ b/internal/parser/parser/pdf_parser_common.go @@ -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: diff --git a/internal/parser/parser/pdf_parser_common_test.go b/internal/parser/parser/pdf_parser_common_test.go index 757fce8f35..cf63a8f4c4 100644 --- a/internal/parser/parser/pdf_parser_common_test.go +++ b/internal/parser/parser/pdf_parser_common_test.go @@ -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) { diff --git a/internal/parser/parser/pdf_parser_nocgo_test.go b/internal/parser/parser/pdf_parser_nocgo_test.go index 59b8d88b2f..9e0350849f 100644 --- a/internal/parser/parser/pdf_parser_nocgo_test.go +++ b/internal/parser/parser/pdf_parser_nocgo_test.go @@ -3,7 +3,6 @@ package parser import ( - "context" "errors" "testing" ) diff --git a/internal/parser/parser/pdf_parser_tcadp.go b/internal/parser/parser/pdf_parser_tcadp.go index f32584dc6d..126d9cd99e 100644 --- a/internal/parser/parser/pdf_parser_tcadp.go +++ b/internal/parser/parser/pdf_parser_tcadp.go @@ -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 { diff --git a/internal/parser/parser/pdf_parser_tcadp_test.go b/internal/parser/parser/pdf_parser_tcadp_test.go index 838539f43d..fae95fbe97 100644 --- a/internal/parser/parser/pdf_parser_tcadp_test.go +++ b/internal/parser/parser/pdf_parser_tcadp_test.go @@ -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 diff --git a/internal/parser/parser/pdf_postprocess.go b/internal/parser/parser/pdf_postprocess.go index e116a924e8..969324d21b 100644 --- a/internal/parser/parser/pdf_postprocess.go +++ b/internal/parser/parser/pdf_postprocess.go @@ -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 diff --git a/internal/parser/parser/ppt_parser.go b/internal/parser/parser/ppt_parser.go index b57322225e..d7e4b12a42 100644 --- a/internal/parser/parser/ppt_parser.go +++ b/internal/parser/parser/ppt_parser.go @@ -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) } diff --git a/internal/parser/parser/ppt_parser_cgo_test.go b/internal/parser/parser/ppt_parser_cgo_test.go index 2cb7928604..81591d0142 100644 --- a/internal/parser/parser/ppt_parser_cgo_test.go +++ b/internal/parser/parser/ppt_parser_cgo_test.go @@ -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 { diff --git a/internal/parser/parser/pptx_parser.go b/internal/parser/parser/pptx_parser.go index 3386d48f5d..a8d50ab005 100644 --- a/internal/parser/parser/pptx_parser.go +++ b/internal/parser/parser/pptx_parser.go @@ -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)} diff --git a/internal/parser/parser/pptx_tcadp.go b/internal/parser/parser/pptx_tcadp.go new file mode 100644 index 0000000000..47620ffa72 --- /dev/null +++ b/internal/parser/parser/pptx_tcadp.go @@ -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) +} diff --git a/internal/parser/parser/xls_tcadp.go b/internal/parser/parser/xls_tcadp.go index 43321dfc28..e0c31e0972 100644 --- a/internal/parser/parser/xls_tcadp.go +++ b/internal/parser/parser/xls_tcadp.go @@ -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}