Files
ragflow/internal/utility/file.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

459 lines
13 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 utility
import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"regexp"
"strings"
)
type FileType string
const (
FileTypePDF FileType = "pdf"
FileTypeDOC FileType = "doc"
FileTypeDOCX FileType = "docx"
FileTypePPT FileType = "ppt"
FileTypePPTX FileType = "pptx"
FileTypeXLS FileType = "xls"
FileTypeXLSX FileType = "xlsx"
FileTypeCSV FileType = "csv"
FileTypeHTML FileType = "html"
FileTypeMarkdown FileType = "md"
FileTypeTXT FileType = "txt"
FileTypeEPUB FileType = "epub"
FileTypeJSON FileType = "json"
FileTypeVIDEO FileType = "video"
FileTypeEMAIL FileType = "email"
FileTypeVISUAL FileType = "visual"
FileTypeAURAL FileType = "aural"
FileTypeFOLDER FileType = "folder"
FileTypeOTHER FileType = "other"
)
var (
filenameLenLimit = 255
)
func init() {
}
func normalizeFilename(filename string) (string, bool) {
if filename == "" {
return "", false
}
base := filepath.Base(filename)
base = strings.TrimSpace(base)
if base == "" || len(base) > filenameLenLimit {
return "", false
}
return strings.ToLower(base), true
}
func GetFileType(filename string) FileType {
ext := filepath.Ext(filename)
var suffix string
if len(ext) > 0 && ext[0] == '.' {
suffix = strings.ToLower(ext[1:])
} else {
suffix = strings.ToLower(ext)
}
switch suffix {
case "pdf":
return FileTypePDF
case "xls":
return FileTypeXLS
case "xlsx":
return FileTypeXLSX
case "csv":
return FileTypeCSV
case "doc":
return FileTypeDOC
case "docx":
return FileTypeDOCX
case "ppt":
return FileTypePPT
case "pptx":
return FileTypePPTX
case "html", "htm":
return FileTypeHTML
case "md", "markdown", "mdx":
return FileTypeMarkdown
case "txt", "py", "js", "java", "c", "cpp", "h", "php",
"go", "ts", "sh", "cs", "kt", "sql":
return FileTypeTXT
case "epub":
return FileTypeEPUB
case "json", "jsonl", "ldjson":
return FileTypeJSON
case "eml", "msg":
return FileTypeEMAIL
case "da", "wave", "wav", "mp3", "aac", "flac", "ogg",
"aiff", "au", "midi", "wma", "ape", "alac", "wv", "opus":
return FileTypeAURAL
case "mp4", "avi", "mkv", "mov", "webm", "flv", "mpeg",
"mpg", "wmv", "3gp", "3gpp":
return FileTypeVIDEO
case "png", "jpg", "jpeg", "gif", "bmp":
return FileTypeVISUAL
case "tiff", "tif", "webp", "svg", "ico":
return FileTypeVISUAL
case "avif", "heic", "apng":
return FileTypeVISUAL
default:
return FileTypeOTHER
}
}
func FilenameType(filename string) FileType {
normalized, ok := normalizeFilename(filename)
if !ok {
return FileTypeOTHER
}
if matched, _ := regexp.MatchString(`.*\.pdf$`, normalized); matched {
return FileTypePDF
}
docExtensions := []string{
"msg", "eml", "doc", "docx", "ppt", "pptx", "yml", "xml", "htm", "json", "jsonl", "ldjson",
"csv", "txt", "ini", "xls", "xlsx", "wps", "rtf", "hlp", "pages", "numbers", "key",
"md", "mdx", "py", "js", "java", "c", "cpp", "h", "php", "go", "ts", "sh", "cs", "kt",
"html", "sql", "epub",
}
for _, ext := range docExtensions {
if strings.HasSuffix(normalized, "."+ext) {
return FileTypeDOC
}
}
audioExtensions := []string{
"wav", "flac", "ape", "alac", "wv", "mp3", "aac", "ogg", "vorbis", "opus",
}
for _, ext := range audioExtensions {
if strings.HasSuffix(normalized, "."+ext) {
return FileTypeAURAL
}
}
visualExtensions := []string{
"jpg", "jpeg", "png", "tif", "gif", "pcx", "tga", "exif", "fpx", "svg", "psd", "cdr",
"pcd", "dxf", "ufo", "eps", "ai", "raw", "WMF", "webp", "avif", "apng", "icon", "ico",
"mpg", "mpeg", "avi", "rm", "rmvb", "mov", "wmv", "asf", "dat", "asx", "wvx", "mpe",
"mpa", "mp4", "mkv",
}
for _, ext := range visualExtensions {
if strings.HasSuffix(normalized, "."+ext) {
return FileTypeVISUAL
}
}
return FileTypeOTHER
}
func SanitizeFilename(filename string) string {
if filename == "" {
return ""
}
filename = strings.TrimSpace(filename)
if filename == "" {
return ""
}
filename = strings.ReplaceAll(filename, "\\", "/")
filename = strings.Trim(filename, "/")
parts := strings.Split(filename, "/")
var sanitizedParts []string
for _, part := range parts {
if part != "" && part != "." && part != ".." {
sanitizedParts = append(sanitizedParts, part)
}
}
unsafeRegex := regexp.MustCompile(`[^A-Za-z0-9_\-/]`)
for i, part := range sanitizedParts {
sanitizedParts[i] = unsafeRegex.ReplaceAllString(part, "")
}
result := strings.Join(sanitizedParts, "/")
return result
}
func GetFileExtension(filename string) string {
ext := filepath.Ext(filename)
if len(ext) > 0 && ext[0] == '.' {
return strings.ToLower(ext[1:])
}
return strings.ToLower(ext)
}
// CONTENT_TYPE_MAP maps file extensions to MIME content types
var CONTENT_TYPE_MAP = map[string]string{
// Office
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"doc": "application/msword",
"pdf": "application/pdf",
"csv": "text/csv",
"xls": "application/vnd.ms-excel",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
// Text/code
"txt": "text/plain",
"py": "text/plain",
"js": "text/plain",
"java": "text/plain",
"c": "text/plain",
"cpp": "text/plain",
"h": "text/plain",
"php": "text/plain",
"go": "text/plain",
"ts": "text/plain",
"sh": "text/plain",
"cs": "text/plain",
"kt": "text/plain",
"sql": "text/plain",
// Web
"md": "text/markdown",
"markdown": "text/markdown",
"mdx": "text/markdown",
"htm": "text/html",
"html": "text/html",
"json": "application/json",
// Image formats
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
"bmp": "image/bmp",
"tiff": "image/tiff",
"tif": "image/tiff",
"webp": "image/webp",
"svg": "image/svg+xml",
"ico": "image/x-icon",
"avif": "image/avif",
"heic": "image/heic",
// PPTX
"ppt": "application/vnd.ms-powerpoint",
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
// Video formats
"mp4": "video/mp4",
"mov": "video/quicktime",
"avi": "video/x-msvideo",
"mpg": "video/mpeg",
"mpeg": "video/mpeg",
"mkv": "video/x-matroska",
"wmv": "video/x-ms-wmv",
"webm": "video/webm",
"rm": "application/vnd.rn-realmedia",
"rmvb": "application/vnd.rn-realmedia",
"dat": "video/mpeg",
"asx": "video/x-ms-asf",
"wvx": "video/x-ms-wvx",
"mpe": "video/mpeg",
"mpa": "video/mpeg",
}
// FORCE_ATTACHMENT_EXTENSIONS are extensions that should always be downloaded as attachments
var FORCE_ATTACHMENT_EXTENSIONS = map[string]bool{
"htm": true,
"html": true,
"shtml": true,
"xht": true,
"xhtml": true,
"xml": true,
"mhtml": true,
"svg": true,
}
// FORCE_ATTACHMENT_CONTENT_TYPES are content types that should always be downloaded as attachments
var FORCE_ATTACHMENT_CONTENT_TYPES = map[string]bool{
"text/html": true,
"image/svg+xml": true,
"application/xhtml+xml": true,
"text/xml": true,
"application/xml": true,
"multipart/related": true,
}
// stripContentTypeParams strips "; charset=..." and similar parameters
// from a content type string. Mirrors Python's .split(";")[0].strip().
func stripContentTypeParams(ct string) string {
if before, _, found := strings.Cut(ct, ";"); found {
return strings.TrimSpace(before)
}
return strings.TrimSpace(ct)
}
// ShouldForceAttachment determines if the file should be forced as attachment
func ShouldForceAttachment(ext string, contentType string) bool {
normalizedExt := strings.ToLower(strings.TrimPrefix(ext, "."))
if normalizedExt != "" && FORCE_ATTACHMENT_EXTENSIONS[normalizedExt] {
return true
}
normalizedType := strings.ToLower(stripContentTypeParams(contentType))
return FORCE_ATTACHMENT_CONTENT_TYPES[normalizedType]
}
// GetContentType determines the content type based on extension and file type
// fallbackPrefix is "image" for visual files, "application" for others
func GetContentType(ext string, fileType string) string {
if ext == "" {
return ""
}
normalizedExt := strings.ToLower(strings.TrimPrefix(ext, "."))
if contentType, ok := CONTENT_TYPE_MAP[normalizedExt]; ok {
return contentType
}
fallbackPrefix := "application"
if fileType == string(FileTypeVISUAL) {
fallbackPrefix = "image"
}
return fallbackPrefix + "/" + normalizedExt
}
// SanitizeContentDispositionFilename sanitizes a filename for use in
// Content-Disposition headers. Strips non-ASCII, path separators,
// control characters, and quotes/percent signs. Falls back to "file"
// when the result is empty. Mirrors Python file_response.py:
// sanitize_content_disposition_filename().
func SanitizeContentDispositionFilename(filename string) string {
if filename == "" {
return "file"
}
// Strip non-ASCII.
var asciiOnly strings.Builder
for _, r := range filename {
if r < 0x80 {
asciiOnly.WriteRune(r)
}
}
sanitized := asciiOnly.String()
// Replace path separators, special chars.
sanitized = strings.ReplaceAll(sanitized, "/", "_")
sanitized = strings.ReplaceAll(sanitized, "\\", "_")
sanitized = strings.ReplaceAll(sanitized, ":", "_")
sanitized = strings.ReplaceAll(sanitized, "\"", "")
sanitized = strings.ReplaceAll(sanitized, "'", "")
sanitized = strings.ReplaceAll(sanitized, "%", "")
// Strip remaining control characters.
ctrlRe := regexp.MustCompile(`[\x00-\x1f\x7f]`)
sanitized = ctrlRe.ReplaceAllString(sanitized, "")
sanitized = strings.TrimSpace(sanitized)
if sanitized == "" {
return "file"
}
return sanitized
}
// ResolveAttachmentContentType resolves a content type and extension from
// query-parameter values. When mimeType is non-empty it is preferred and
// reverse-looked up in CONTENT_TYPE_MAP to also resolve the extension;
// otherwise the extension is looked up in CONTENT_TYPE_MAP, falling back
// to "application/<ext>". Returns (contentType, ext). Mirrors Python
// file_response.py: resolve_attachment_content_type().
func ResolveAttachmentContentType(ext string, mimeType string) (string, string) {
ext = strings.ToLower(strings.TrimSpace(ext))
ext = strings.TrimPrefix(ext, ".")
mimeType = strings.TrimSpace(mimeType)
contentType := ""
if mimeType != "" {
normalizedType := strings.ToLower(stripContentTypeParams(mimeType))
contentType = normalizedType
// Reverse-lookup extension from CONTENT_TYPE_MAP only when
// no explicit ext was provided. Never overwrite a caller-
// supplied extension (e.g. ext=svg&mime_type=image/png must
// stay ext=svg for the force-attachment check).
if ext == "" {
for knownExt, knownType := range CONTENT_TYPE_MAP {
if knownType == normalizedType {
ext = knownExt
break
}
}
}
} else if ext != "" {
if ct, ok := CONTENT_TYPE_MAP[ext]; ok {
contentType = ct
} else {
contentType = "application/" + ext
}
}
return contentType, ext
}
// SetPreviewFileResponseHeaders sets response headers for inline file
// preview. For force-attachment types (HTML, SVG, XML) it falls back to
// attachment disposition with nosniff. Mirrors Python file_response.py:
// apply_preview_file_response_headers().
func SetPreviewFileResponseHeaders(h http.Header, contentType, ext, filename string) {
if contentType != "" {
h.Set("Content-Type", contentType)
}
if ShouldForceAttachment(ext, contentType) {
h.Set("Content-Disposition", "attachment")
h.Set("X-Content-Type-Options", "nosniff")
} else {
safe := SanitizeContentDispositionFilename(filename)
h.Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, safe))
}
}
// SetDownloadFileResponseHeaders sets response headers for file download
// (always attachment disposition). Mirrors Python file_response.py:
// apply_download_file_response_headers().
func SetDownloadFileResponseHeaders(h http.Header, contentType, ext, filename string) {
if contentType != "" {
h.Set("Content-Type", contentType)
}
if ShouldForceAttachment(ext, contentType) {
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Content-Disposition", "attachment")
return
}
safe := SanitizeContentDispositionFilename(filename)
h.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safe))
}
// AgentAttachmentPreviewPath builds the preview URL path for an agent
// attachment. Query parameters ext and mime_type are URL-encoded when
// provided. Mirrors Python file_response.py:
// agent_attachment_preview_path().
func AgentAttachmentPreviewPath(attachmentID, ext, mimeType string) string {
path := "/api/v1/agents/attachments/" + attachmentID + "/preview"
params := url.Values{}
if ext != "" {
params.Set("ext", ext)
}
if mimeType != "" {
params.Set("mime_type", mimeType)
}
if qs := params.Encode(); qs != "" {
return path + "?" + qs
}
return path
}