Files
ragflow/internal/ingestion/component/chunker/title.go
Jack 8669c469d5 fix(ingestion): align laws DSL with Python — heading fallback, colon-title, short-line filter, remove_toc, and image extension mapping (#17200)
## Summary

This PR aligns the Go ingestion pipeline's **Laws** DSL template with
the Python implementation by fixing heading-detection gaps, adds
image-extension support, refactors the **Extractor** component's LLM
resolution, hardens heading detection for CJK text, and makes the
Extractor accept the Python DSL prompt key names
(`sys_prompt`/`prompts`) alongside the Go names.

## Changes

### 1. Picture file-type detection (`internal/utility/file.go`)

Adds explicit mapping for common image extensions (png, jpg, jpeg, gif,
bmp, tiff, tif, webp, svg, ico, avif, heic, apng) → `FileTypeVISUAL`,
with regression tests.

### 2. Laws DSL heading-detection alignment
(`internal/ingestion/component/chunker/`)

Four fixes to `resolveTitleLevels`:

| Fix | What changed | Why |
|-----|-------------|-----|
| **DOCX `ck_type` fallback** | `ckType` field on `lineRecord`,
propagated from `ChunkDoc.CKType` in `recordsFromStructured`. When
`ck_type=="heading"`, assign `fallbackLevel`. | office_oxide extracts
DOCX heading metadata, but the info was lost before reaching the heading
detector. Word headings whose text doesn't match any regex (e.g.
"Introduction") were treated as body. |
| **`make_colon_as_title` promotion** | `isColonTitle()`: promotes lines
ending with `:`/`:` that have sentence-ending punctuation before the
colon and ≥32 runes between them. | Mirrors Python's
`make_colon_as_title` in `rag/nlp/__init__.py`. Triple guard prevents
false positives. |
| **Short/numeric line filter** | Lines with ≤1 rune or purely numeric
are pinned to body level. | Mirrors Python `tree_merge`'s filter of
`sections` where `len(...) <= 1` or `re.match(r"[0-9]+$", ...)`. |
| **PDF `remove_toc`** | `"remove_toc": true` added to the PDF parser
setup in `ingestion_pipeline_laws.json`. | The Go PDF parser already
supports TOC removal; the Book template already enables it. |

### 3. Extractor llm_id resolution
(`internal/ingestion/component/extractor.go`)

Refactored to handle both **bare tenant_model UUIDs** and **composite
model@provider** strings via the shared `resolveModelConfig`
(`dispatch_model.go`):

- **`resolveExtractorChatConfig`** — UUID path calls
`resolveModelConfigByID` directly (one DB hit); composite path goes
through `resolveModelConfig`. Added `isBareTenantModelID` pre-check for
clear errors when a UUID doesn't exist.
- **`resolveExtractorChatTarget`** — propagates resolution errors
instead of silently returning empty driver.
- **`Chat()`** — removed `driver = "dummy"` fallback. Missing driver is
now an explicit error.
- **Removed dead code**: `splitExtractorLLID`,
`findExtractorSoleActiveInstance`.

### 4. `InjectExtractorLLMID` — fallback when no user config
(`internal/common/parser_config.go`)

Injects the tenant's global default LLM into extractor components **only
when their `llm_id` is empty**. Preserves user-selected UUID or
model@provider values.

Priority: user-configured llm_id > tenant global default > error (no
silent dummy fallback).

### 5. `ResponseHeaderTimeout` increase
(`internal/entity/models/base_model.go`)

`ResponseHeaderTimeout` 60s → 120s in `NewDriverHTTPClient`. Reasoning
models with large extraction prompts can take longer than 60s to produce
the first response token.

### 6. CJK rune-aware heading detection
(`internal/ingestion/component/chunker/title.go`)

Two byte-vs-rune bugs that only manifest on CJK text:

| Fix | What changed | Why |
|-----|-------------|-----|
| **`isColonTitle` byte offset** | `body[lastPunct+1:]` →
`body[lastPunct+runeLen:]` via `utf8.DecodeRuneInString` |
`strings.LastIndexAny` returns a byte index; `+1` skips only 1 byte,
corrupting multi-byte CJK punctuation (e.g. `。` = 3 bytes) and inflating
the rune count past the 32-rune threshold → false-positive heading
promotion. |
| **Short-line filter byte count** | `len(text) <= 1` →
`utf8.RuneCountInString(text) <= 1` | Go `len` is UTF-8 bytes; a single
CJK char (3 bytes) passed the filter, but Python's `len` returns 1 →
mismatch. |

### 7. `extractor_tag.go` — log error when llm fails

When `resolveExtractorChatTarget` returned an error, `runAutoTags` will
log error.

### 8. Python DSL prompt-key compatibility
(`internal/ingestion/component/extractor.go`)

The Resume DSL template uses Python-side key names (`sys_prompt`,
`prompts`). `NewExtractorComponent` now accepts them as fallbacks
alongside the Go names:

- `system_prompt` (Go) ← `sys_prompt` (Python) as fallback
- `prompt` (Go string) ← `prompts` (Python array `[{"role","content"}]`,
takes `[0].content`) as fallback

Mirrors the alias pattern already in `internal/agent/component/llm.go`.
`resolveInputs` accepts per-call `sys_prompt` override too.

## Remaining gaps vs Python

| Gap | Scope | Impact |
|-----|-------|--------|
| **TOC removal for TXT/MD/HTML** | Python's `remove_contents_table`
works on all text formats; Go's `remove_toc` is PDF-only. | Low —
plain-text documents rarely contain structured TOCs. |
| **Regex pattern details** | Minor differences in quantifiers, missing
H5/H6 markdown patterns, missing 4-level numbering pattern. | Low — Go's
variants are stricter; DOCX headings are covered by `ck_type` fallback.
|

## Testing

- `TestHierarchyTitleChunker_CKTypeHeadingFallback` — DOCX `ck_type`
heading promotion
- `TestHierarchyTitleChunker_ColonTitlePromotion` /
`_ColonTitleShortLine_Negative` — colon-title promotion + guard
- `TestIsColonTitle_CJKEdgeCase` / `TestIsColonTitle_ASCII_NoRegression`
— CJK byte-offset fix + ASCII regression
- `TestHierarchyTitleChunker_ColonTitlePromotion_CJK_EdgeCase` — CJK
colon edge case through full pipeline
- `TestHierarchyTitleChunker_ShortSingleCJKLineFilter` — single CJK char
filtered to body
- `TestHierarchyTitleChunker_ShortNumericLineFilter` — purely numeric
lines filtered
- `TestGetFileType_ImageExtensions` / `_ExistingFormats_NoRegression` —
image extension mapping
- `TestInjectExtractorLLMID_SkipWhenUUID` / `_SkipWhenComposite` /
`_InjectWhenEmpty` — llm_id injection guard
- `TestIsBareTenantModelID` — UUID detection
- `TestResolveExtractorChatTarget_AtSplitFallback` / `_NoDriver` — @
split fallback without DB
- `TestNewExtractorComponent_SysPromptAlias` / `_PromptsArray` /
`_PromptsArray_PromptWins` / `_SystemPromptWinsOverSysPrompt` — Python
key compatibility
- `TestBuildDOCXJSONSections_List` / `_TextBox` / `_MixedWithList` —
DOCX list/text_box parsing
- Full ingestion test suite passes (chunker, pipeline, task, service,
component packages)
2026-07-22 19:14:32 +08:00

521 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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.
//
// SCOPE (honest) for title.go:
//
// - TitleChunker is the dispatcher for the three Go chunks: this
// file holds the TitleChunker variant itself, which mirrors
// python `rag/flow/chunker/title_chunker/title_chunker.py` — it
// holds no business logic of its own; the actual chunking
// happens in group.go / hierarchy.go depending on the
// `method` param.
//
// TitleChunker.Invoke dispatches synchronously to group.go or
// hierarchy.go.
//
// - HEADING DETECTION PARITY:
// The Python side uses two heading-detection strategies in
// `common.py:resolve_title_levels`:
// (1) PDF outlines (extract_pdf_outlines, requires deepdoc/parser)
// (2) Regex families (the user's `levels` param) with a layout-hint
// fallback (layout field matches section/title/head).
// The Go port ships strategy (2) IN FULL, including the
// match_layout_level fallback ported from common.py (Gap C closed):
// a non-regex text record whose layout flags it as a
// section/title/head and whose text passes not_title is promoted to
// fallback_level = len(selected_group) + 1. Strategy (1) —
// PDF-outline detection — still requires deepdoc/parser binary
// access and remains the only parity gap.
//
// - The Go port has NO hardcoded BULLET_PATTERN fallback. Heading
// detection relies entirely on the user-supplied `levels` param
// (which templates carry as comprehensive regex families) paired
// with the layout-hint fallback. A PDF without matching levels
// produces BODY_LEVEL-only records — Python's BULLET_PATTERN-based
// tree_merge / hierarchical_merge would still find structure.
//
// - GROUP-TITLE and HIERARCHY-TITLE are separate Go files
// (`group.go`, `hierarchy.go`); they share the resolve_levels
// path with TitleChunker but their build_chunks logic differs.
package chunker
import (
"context"
"fmt"
"regexp"
"strings"
"unicode/utf8"
"ragflow/internal/agent/runtime"
"ragflow/internal/ingestion/component/globals"
"ragflow/internal/ingestion/component/schema"
)
const ComponentNameTitleChunker = "TitleChunker"
type titleChunkerParam struct {
schema.TitleChunkerParam
}
func (p *titleChunkerParam) Update(conf map[string]any) {
if conf == nil {
return
}
if v, ok := conf["method"].(string); ok {
p.TitleChunkerParam.Method = v
}
if v, ok := conf["levels"].([]any); ok {
p.TitleChunkerParam.Levels = parseLevels(v)
} else if v, ok := conf["levels"].([][]string); ok {
p.TitleChunkerParam.Levels = v
}
if v, ok := numericFromAny(conf["hierarchy"]); ok {
n := int(v)
p.TitleChunkerParam.Hierarchy = &n
}
if v, ok := conf["include_heading_content"].(bool); ok {
p.TitleChunkerParam.IncludeHeadingContent = v
}
if v, ok := conf["root_chunk_as_heading"].(bool); ok {
p.TitleChunkerParam.RootChunkAsHeading = v
}
}
// parseLevels accepts a [[string]] representation — the natural
// JSON form — and rebroadcasts the strings verbatim. Stricter
// validation lives in schema.TitleChunkerParam.Validate.
func parseLevels(in []any) [][]string {
out := make([][]string, 0, len(in))
for _, lvl := range in {
group, ok := lvl.([]any)
if !ok {
continue
}
row := make([]string, 0, len(group))
for _, pat := range group {
if s, ok := pat.(string); ok && s != "" {
row = append(row, s)
}
}
if len(row) > 0 {
out = append(out, row)
}
}
return out
}
func defaultsTitle() titleChunkerParam {
return titleChunkerParam{TitleChunkerParam: schema.TitleChunkerParam{}.Defaults()}
}
// selectLevelGroup mirrors common.py:select_level_group. Returns the
// regex-list family with the highest hit count across the input
// lines.
func selectLevelGroup(lines []string, rawLevels [][]string) []string {
if len(rawLevels) == 0 {
return nil
}
hits := make([]int, len(rawLevels))
for i, group := range rawLevels {
for _, line := range lines {
text := trim(line)
if text == "" {
continue
}
// Mirror common.py:select_level_group — a bullet-looking
// line cannot count as a heading hit for any family.
if notBullet(text) {
continue
}
for _, pattern := range group {
if re := compileLevelPattern(pattern); re != nil {
if re.MatchString(text) {
hits[i]++
break
}
}
}
}
}
bestIdx := -1
best := 0
for i, h := range hits {
if h > best {
best = h
bestIdx = i
}
}
if bestIdx < 0 {
return nil
}
group := make([]string, 0, len(rawLevels[bestIdx]))
for _, p := range rawLevels[bestIdx] {
if p != "" {
group = append(group, p)
}
}
return group
}
// matchRegexLevel mirrors common.py:match_regex_level. Levels are
// 1-indexed; "" or no-match returns 0 (BODY).
func matchRegexLevel(text string, group []string) int {
stripped := trim(text)
if stripped == "" {
return 0
}
// Mirror common.py:match_regex_level — a bullet-looking line is not
// a heading regardless of the regex family.
if notBullet(stripped) {
return 0
}
for lvl, pattern := range group {
re := compileLevelPattern(pattern)
if re != nil && re.MatchString(stripped) {
return lvl + 1
}
}
return 0
}
// notBulletPatterns mirrors rag/nlp.not_bullet. A line matching any of
// these looks like a numbered/bulleted list item rather than a section
// heading, so it must not be promoted to a title level by the regex
// families.
var notBulletPatterns = []*regexp.Regexp{
regexp.MustCompile(`^0`),
regexp.MustCompile(`^[0-9]+ +[0-9~个只-]`),
regexp.MustCompile(`^[0-9]+\.{2,}`),
regexp.MustCompile(`^[0-9]+(\.[0-9]+){2,}[的中]`),
}
// notBullet mirrors rag/nlp.not_bullet: true when the line looks like a
// numbered/bulleted list entry rather than a genuine section heading.
func notBullet(s string) bool {
for _, re := range notBulletPatterns {
if re.MatchString(s) {
return true
}
}
return false
}
// notTitlePatterns mirrors rag/nlp.not_title:
// - notTitleException: `第...条` is always a title (never "not a title").
// - notTitlePunct: a body line typically carries one of these punctuation
// marks, so their presence flags the line as body text.
var (
notTitleException = regexp.MustCompile(`^第[零一二三四五六七八九十百0-9]+条`)
notTitlePunct = regexp.MustCompile(`[,;,。;!!]`)
layoutHeadingRe = regexp.MustCompile(`(?i)(section|title|head)`)
numericOnly = regexp.MustCompile(`^[0-9]+$`)
)
// notTitle mirrors rag/nlp.not_title. Returns true when the line looks
// like body text rather than a section heading, so layout-based heading
// detection must skip it.
func notTitle(s string) bool {
if notTitleException.MatchString(s) {
return false
}
if len(strings.Fields(s)) > 12 || (!strings.Contains(s, " ") && utf8.RuneCountInString(s) >= 32) {
return true
}
return notTitlePunct.MatchString(s)
}
// beforeAt mirrors python's `text.split("@")[0]` (used by
// match_layout_level / not_title): the part of the line before the first
// "@" separator, whitespace-trimmed.
func beforeAt(s string) string {
if i := strings.Index(s, "@"); i >= 0 {
return strings.TrimSpace(s[:i])
}
return strings.TrimSpace(s)
}
// matchLayoutLevel mirrors common.py:match_layout_level. When the
// record's layout field flags it as a section/title/head and the text is
// title-like (not not_title), the record is promoted to `fallback_level`
// (common.py:resolve_frequency_levels sets this to len(level_group) + 1).
// Otherwise it stays BODY_LEVEL.
func matchLayoutLevel(text, layout string, fallbackLevel int) int {
if layoutHeadingRe.MatchString(layout) && !notTitle(beforeAt(text)) {
return fallbackLevel
}
return bodyLevel
}
// resolveTitleLevels mirrors common.py:resolve_frequency_levels over the
// full record stream. It is the "frequency" branch of
// common.py:resolve_title_levels (the outline branch is parity-gap
// territory per the SCOPE comment above).
//
// For each record:
// - a non-text record is pinned to BODY_LEVEL directly (python skips
// regex/layout detection for doc_type_kwd != "text");
// - a text record first tries the selected regex group (match_regex_level);
// on no match it falls back to match_layout_level (layout hint), else
// BODY_LEVEL.
//
// fallback_level is len(selected_group) + 1, exactly as in python.
// isColonTitle mirrors Python make_colon_as_title intent: returns true
// when a line ends with colon, has sentence-ending punctuation before
// the colon, and the text between them is at least 32 runes long.
func isColonTitle(s string) bool {
if s == "" {
return false
}
if !strings.HasSuffix(s, ":") && !strings.HasSuffix(s, "") {
return false
}
body := strings.TrimSuffix(s, ":")
body = strings.TrimSuffix(body, "")
if body == s {
return false
}
lastPunct := strings.LastIndexAny(body, "。?!!?;.")
if lastPunct < 0 {
return false
}
// Use rune-aware slicing: body[lastPunct+1] would be wrong for CJK
// punctuation (e.g. "。" is 3 bytes in UTF-8). Decode the rune at
// lastPunct to get the correct byte width and skip the full rune.
_, runeLen := utf8.DecodeRuneInString(body[lastPunct:])
between := strings.TrimSpace(body[lastPunct+runeLen:])
return utf8.RuneCountInString(between) >= 32
}
func resolveTitleLevels(records []lineRecord, p *titleChunkerParam) []int {
lines := make([]string, len(records))
for i, r := range records {
lines[i] = r.text
}
group := selectLevelGroup(lines, p.Levels)
fallbackLevel := len(group) + 1
out := make([]int, len(records))
for i, rec := range records {
if !rec.isText() {
out[i] = bodyLevel
continue
}
// Python tree_merge short/numeric line filter:
// sections = [s for s in sections if len(s.split("@")[0].strip()) > 1
// and not re.match(r"[0-9]+$", s.split("@")[0].strip())]
if text := beforeAt(rec.text); utf8.RuneCountInString(text) <= 1 || numericOnly.MatchString(text) {
out[i] = bodyLevel
continue
}
if lvl := matchRegexLevel(rec.text, group); lvl != 0 {
out[i] = lvl
continue
}
if rec.ckType == "heading" {
out[i] = fallbackLevel
continue
}
// Python make_colon_as_title: promote lines ending with colon
// that have sentence-ending punctuation before it and at least
// 32 chars between the punctuation and the colon.
if text := beforeAt(rec.text); isColonTitle(text) {
out[i] = fallbackLevel
continue
}
out[i] = matchLayoutLevel(rec.text, rec.layout, fallbackLevel)
}
return out
}
// bodyLevel is the sentinel python uses for non-heading lines. We use
// the same large int (sys.maxsize - 1) for parity. Practically this
// just needs to be "larger than any realistic heading level"; tests
// only check relative ordering.
const bodyLevel = 1<<31 - 1
// lineRecords mirrors common.py:extract_line_records' markdown/text/html
// branch. Returns one record per non-empty input line.
func lineRecordsFromText(text string) []lineRecord {
if text == "" {
return nil
}
out := make([]lineRecord, 0)
for _, ln := range strings.Split(text, "\n") {
if trim(ln) == "" {
continue
}
out = append(out, lineRecord{
text: ln,
docType: "text",
imgID: nil,
layout: "",
pdfPos: nil,
})
}
return out
}
// lineRecord is the internal common shape — same fields as
// common.py:extract_line_records yields. Used by Group/Hierarchy
// chunk-builders.
type lineRecord struct {
text string
docType string
imgID *string
layout string
ckType string
pdfPos []map[string]any
parentMeta map[string]any
}
func (r lineRecord) textOrEmpty() string { return r.text }
func (r lineRecord) isText() bool { return r.docType == "text" }
// trim mirrors python's str.strip(): remove leading/trailing Unicode
// whitespace. Used for emptiness checks and regex matching so the Go
// port matches python's `text.strip()` exactly.
func trim(s string) string {
return strings.TrimSpace(s)
}
// compileLevelPattern returns a compiled regex for `pattern`. Returns
// nil on empty/error to skip the entry — same effect as a regex
// mismatch in python (the row falls through to body).
//
// Python's match_regex_level / select_level_group use `re.match`, which
// anchors at the START of the string (not the end). We mirror that by
// prepending `^` (unless the caller already anchored) so Go's
// MatchString behaves exactly like re.match.
func compileLevelPattern(pattern string) *regexp.Regexp {
if pattern == "" {
return nil
}
anchored := pattern
if !strings.HasPrefix(pattern, "^") {
anchored = "^(?:" + pattern + ")"
}
re, err := regexp.Compile(anchored)
if err != nil {
return nil
}
return re
}
// LevelContext groups the level-detection artefacts (per-line levels
// + the most-common heading level) so the strategy implementations
// don't recompute.
type LevelContext struct {
levels []int
mostLevel int
}
func newLevelContext(records []lineRecord, p *titleChunkerParam) LevelContext {
levels := resolveTitleLevels(records, p)
// most_level is the most-frequent non-body heading level
// (common.py:resolve_frequency_levels). Python computes this via
// Counter(levels).most_common() over the heading levels only. Walk
// levels in input order so ties resolve to the first-encountered
// level, matching python's insertion-order tie-break.
counts := make(map[int]int)
for _, lvl := range levels {
if lvl < bodyLevel {
counts[lvl]++
}
}
most := 0
best := 0
for _, lvl := range levels {
if c := counts[lvl]; c > best {
best = c
most = lvl
}
}
return LevelContext{levels: levels, mostLevel: most}
}
func (lc LevelContext) Levels() []int {
out := make([]int, len(lc.levels))
copy(out, lc.levels)
return out
}
// ---------------------------------------------------------------------------
// Component implementations
// ---------------------------------------------------------------------------
// TitleChunkerComponent dispatches based on `param.method`. Heading
// detection is shared (resolveTitleLevels); the actual chunk-build
// logic lives in group.go / hierarchy.go.
type TitleChunkerComponent struct {
name string
param titleChunkerParam
}
// NewTitleChunker constructs a TitleChunker from the DSL param map.
// Errors here surface as canvas compile failures.
func NewTitleChunker(params map[string]any) (runtime.Component, error) {
p := defaultsTitle()
p.Method = "group" // default to group
p.Update(params)
if err := p.TitleChunkerParam.Validate(); err != nil {
return nil, fmt.Errorf("TitleChunker: %w", err)
}
return &TitleChunkerComponent{
name: ComponentNameTitleChunker,
param: p,
}, nil
}
// Inputs is exposed so callers can introspect.
func (c *TitleChunkerComponent) Inputs() map[string]string { return ChunkerInputs }
// Outputs is exposed so callers can introspect.
func (c *TitleChunkerComponent) Outputs() map[string]string { return ChunkerOutputs }
// Invoke delegates to the chosen strategy (group or hierarchy).
func (c *TitleChunkerComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
if inputs == nil {
inputs = map[string]any{}
}
// `name` is read from the workflow-wide Globals bag (seeded at
// pipeline start, published by the File component), not from the
// upstream output map.
name := globals.GlobalOrInput(ctx, inputs, "name", "")
if name == "" {
return map[string]any{
"output_format": "chunks",
"chunks": []map[string]any{},
"_ERROR": "TitleChunker: missing required upstream field \"name\"",
}, nil
}
switch c.param.Method {
case "hierarchy":
return invokeHierarchy(ctx, inputs, &c.param)
case "group":
return invokeGroup(ctx, inputs, &c.param)
default:
return map[string]any{
"output_format": "chunks",
"chunks": []map[string]any{},
"_ERROR": fmt.Sprintf("TitleChunker: unsupported method %q", c.param.Method),
}, nil
}
}
// init registers TitleChunker under CategoryIngestion.
func init() {
MustRegisterChunker(ComponentNameTitleChunker)
}