Files
ragflow/internal/ingestion/component/chunker/common.go
Jack bb6b43b5c9 fix: docx/email parsing, extractor LLM driver, and chunker alignment (#17144)
## Summary

Three groups of changes across the Go ingestion pipeline:

### 1. DOCX parsing improvements
- **docx_parser.go**: Enhanced DOCX parsing with better structure
extraction and media handling
- **docx_parser_cgo_test.go**, **docx_parser_test.go**: Companion tests
- **office_parsers_no_cgo.go**: Stub sync for non-CGO builds

### 2. Email (.eml) parsing: base64 Content-Transfer-Encoding decoding
- **email_parser.go** (`decodeCTE`): Added Content-Transfer-Encoding
decoding for base64 and quoted-printable. Go's `mime/multipart.Reader`
does not decode Content-Transfer-Encoding automatically, so attachments
with `Content-Transfer-Encoding: base64` remained base64-encoded in the
output. The new `decodeCTE` helper is called after reading each
multipart part's raw bytes in `readMailBody`, mirroring Python's
`part.get_payload(decode=True)`.
- **email_parser_test.go**: Two new tests — simple base64 attachment and
nested multipart/alternative with base64 attachment.

### 3. Extractor LLM driver fix + ModelDriver consolidation
- **extractor.go**: Fixed a bug where the Extractor component used
`ModelFactory.CreateModelDriver()`, which creates bare model instances
without API keys or provider configuration. Switched to
`models.GetPreconfiguredDriver()` which resolves the actual
pre-configured driver from `ProviderManager`, matching the codepath used
by `llm.go`. This fixes auto keyword/question extraction in DSL
pipelines that require LLM calls.
- **get_driver.go** (new): Extracted shared `GetPreconfiguredDriver()`
from `llm.go:newChatModelDriver()` so both `llm.go` and `extractor.go`
use the same codepath.
- **get_driver_test.go** (new): Tests for the shared driver resolution.
- **llm.go**: Replaced inline driver resolution with
`models.GetPreconfiguredDriver()`.

### 4. Chunker fixes and observability
- **group.go** (`extractLineRecords`): Fixed to also read `markdown` and
`html` payload keys — previously it only read `text`/`content`, causing
GroupTitleChunker to silently return empty results for markdown-format
parser output.
- **common.go** (`compileDelimPattern`): Aligned with Python's
`_compile_delimiter_pattern` — only backtick-wrapped delimiters produce
an active regex pattern; plain delimiters are not compiled into the
split regex.
- **token.go** (`applyChildrenDelim`): Set `DocType` and `CKType` to
`"text"` on created ChunkDocs so the token-size merge path correctly
identifies and merges text segments.
- **parser.go**, **extractor.go**, **tokenizer.go**, **group.go**,
**hierarchy.go**: Added debug-level logging for pipeline diagnostics.
- **parser_dispatch_test.go**, **group_test.go**: New tests.

## Verification
- All Go tests pass: `bash build.sh --test ./internal/parser/parser/...`
and `bash build.sh --test ./internal/ingestion/component/...`
- Build succeeds: `bash build.sh --go`
2026-07-21 13:51:17 +08:00

259 lines
7.9 KiB
Go

//
// 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.
// You may obtain a copy of the License at
//
// 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 (
"fmt"
"regexp"
"sort"
"strings"
"ragflow/internal/agent/runtime"
"ragflow/internal/ingestion/component/schema"
"ragflow/internal/tokenizer"
)
// newChunkerByName dispatches the DSL name to a typed constructor.
// Centralised here so each chunker file only needs an init() that
// declares its registered name (see register.go). The returned
// runtime.Component interface is satisfied directly by each
// constructor (NewTokenChunker etc.) — no intermediate assertion
// is needed.
func newChunkerByName(name string, params map[string]any) (runtime.Component, error) {
switch name {
case ComponentNameTokenChunker:
return NewTokenChunker(params)
case ComponentNameTitleChunker:
return NewTitleChunker(params)
case ComponentNameGroupTitleChunker:
return NewGroupTitleChunker(params)
case ComponentNameHierarchyTitleChunker:
return NewHierarchyTitleChunker(params)
case ComponentNameQAChunker:
return NewQAChunker(params)
case ComponentNameOneChunker:
return NewOneChunker(params)
case ComponentNameTagChunker:
return NewTagChunker(params)
case ComponentNameTableChunker:
return NewTableChunker(params)
case ComponentNamePresentationChunker:
return NewPresentationChunker(params)
default:
return nil, fmt.Errorf("chunker: unknown component %q", name)
}
}
// ---------------------------------------------------------------------------
// numeric / list conversion helpers (shared across chunker variants)
// ---------------------------------------------------------------------------
// numericFromAny normalises JSON-decoded ints to float64 so the
// schema-defaults-vs-Param-Update convention doesn't depend on the
// encoding source (yaml/toml/json all behave the same).
func numericFromAny(v any) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case float32:
return float64(x), true
case int:
return float64(x), true
case int32:
return float64(x), true
case int64:
return float64(x), true
case uint:
return float64(x), true
case uint32:
return float64(x), true
case uint64:
return float64(x), true
}
return 0, false
}
func stringListFromAny(in []any) []string {
out := make([]string, 0, len(in))
for _, x := range in {
if s, ok := x.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
// ---------------------------------------------------------------------------
// regex / split helpers
// ---------------------------------------------------------------------------
// compileDelimPattern joins all delimiter entries into a single
// alternation. Entries wrapped in backticks are treated as regex
// literals and regex-escaped; plain strings are simply regex-escaped.
// Longer patterns win (matches python `sorted(set, key=len, reverse=True)`).
// Mirrors Python _compile_delimiter_pattern: only backtick-wrapped delimiters
// produce an active pattern. Plain delimiters are not compiled — they are only
// used by naive_merge / mergeByTokenSize for sentence-level splitting when no
// active pattern exists.
func compileDelimPattern(delims []string) *regexp.Regexp {
var custom []string
for _, d := range delims {
if d == "" {
continue
}
if strings.HasPrefix(d, "`") && strings.HasSuffix(d, "`") && len(d) >= 2 {
custom = append(custom, regexp.QuoteMeta(d[1:len(d)-1]))
}
}
if len(custom) == 0 {
return nil
}
sort.SliceStable(custom, func(i, j int) bool { return len(custom[i]) > len(custom[j]) })
return regexp.MustCompile(strings.Join(custom, "|"))
}
// splitKeepingDelim is the Go equivalent of python's
// `re.split((pattern), text, flags=re.DOTALL)` with the matched
// delimiter preserved (alternation keeps the original delimiter text
// in the output stream so the rebuilding at token_chunker.py:88-93
// stays lossy-free).
func splitKeepingDelim(text string, pattern *regexp.Regexp) []string {
if pattern == nil {
return []string{text}
}
idxs := pattern.FindAllStringSubmatchIndex(text, -1)
if len(idxs) == 0 {
return []string{text}
}
var out []string
cursor := 0
for _, idx := range idxs {
start, end := idx[0], idx[1]
if start > cursor {
out = append(out, text[cursor:start])
}
out = append(out, text[start:end])
cursor = end
}
if cursor < len(text) {
out = append(out, text[cursor:])
}
return out
}
// ---------------------------------------------------------------------------
// chunk-doc helpers
// ---------------------------------------------------------------------------
// itemText returns the text payload from a JSON-style chunk item,
// preferring "text", then "content_with_weight".
func itemText(it schema.ChunkDoc) (string, bool) {
if it.Text != "" {
return it.Text, true
}
if it.ContentWithWeight != "" {
return it.ContentWithWeight, true
}
return "", false
}
// itemDocType mirrors _build_json_chunks's type derivation.
func itemDocType(it schema.ChunkDoc) string {
switch strings.ToLower(strings.TrimSpace(it.DocType)) {
case "table":
return "table"
case "image":
return "image"
}
return "text"
}
// itemTextOrFallback returns the item's preferred text, or "".
func itemTextOrFallback(it schema.ChunkDoc) string {
if t, ok := itemText(it); ok {
return t
}
return ""
}
// tokenizeStr is the shared NumTokensFromString wrapper used by
// Table/Image context attachment. Lives here so we can centrally
// swizzle the count strategy in one place if needed.
func tokenizeStr(s string) int { return tokenizer.NumTokensFromString(s) }
// toString normalises a chunk-map field to a string. Empty strings
// for missing fields.
func toString(v any) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return ""
}
// emptyOutputs returns the canonical no-chunks payload.
func emptyOutputs() map[string]any {
return map[string]any{
"output_format": "chunks",
"chunks": []map[string]any{},
}
}
func emptyChunkDocs() []schema.ChunkDoc { return []schema.ChunkDoc{} }
// chunkOutputs builds the canonical chunker output (output_format="chunks" +
// chunks). The Go runtime passes only this explicit output to the next node,
// so the run-level metadata that downstream components still need (e.g.
// `name` for Tokenizer title embedding, or tenant_id/kb_id for embedding
// model resolution) is NOT re-emitted here — it lives in the workflow-wide
// CanvasState.Globals bag (seeded at pipeline start, published by the File
// component) and read directly from ctx. See runtime.CanvasState.Globals.
func chunkOutputs(chunks []schema.ChunkDoc) map[string]any {
return map[string]any{
"output_format": "chunks",
"chunks": schema.ChunkDocsToMaps(chunks),
}
}
// withName returns a shallow copy of inputs with name set, so a component can
// guarantee `name` is present on the map it forwards to a decode step without
// mutating the caller's snapshot.
func withName(inputs map[string]any, name string) map[string]any {
cp := make(map[string]any, len(inputs)+1)
for k, v := range inputs {
cp[k] = v
}
cp["name"] = name
return cp
}
// cloneInputs returns a shallow copy of m with room for one extra key. Used to
// inject the Globals-resolved `name` into the decode input without mutating
// the caller's input snapshot.
func cloneInputs(m map[string]any) map[string]any {
if m == nil {
return map[string]any{}
}
cp := make(map[string]any, len(m)+1)
for k, v := range m {
cp[k] = v
}
return cp
}