Files
ragflow/internal/ingestion/component/parser.go
Zhichang Yu 12787996d1 feat(agent): Go ingestion pipeline progress mirroring and DeepDOC parser hardening (#16795)
feat(ingestion): mirror Go pipeline progress into the document table;
harden resume guards
- pipeline: bind the owning document via WithDocumentID; after each
TrackProgress event aggregate ingestion_task_log progress and mirror
progress/run/progress_msg back into the document table, so GET
/api/v1/datasets/{dataset_id}/documents reflects live Go pipeline
progress without a bespoke endpoint.
- canvas: extend the S3 resume guard to reject legacy no-op nodes (e.g.
ExitLoop) so component_total equals the count of progress-reporting
components and the aggregate percent can reach 100%.
- runtime/canvas: route progress through TrackProgress; add interrupt
test coverage (r3_interrupt_test.go).
- dao/entity: add IngestionTask.DocumentID column and AggregateProgress
support used by the mirror; IngestionTaskLog keeps a Checkpoint column
alongside the progress fields.

feat(deepdoc): cache DocAnalyzer inference results in Redis (1h TTL)
- Redis-backed DocAnalyzerCache decorator over inference.Client; cache
key = "ddoc:cache:<method>:" + sha256 of the JPEG-encoded image bytes
(deterministic).
- TTL = 1h; hits skip the inner HTTP call and return cached JSON; inner
errors are not cached.

refactor(deepdoc): align figure cropping with Python cropout + bounded
page caches
- CropSectionByDLA mirrors Python cropout: best-overlap DLA
figure/equation region, fallback to section bbox per page, vertical
concat on gray background.
- sliding-window page-image cache bounds peak memory to the recent
window instead of the whole PDF.
- rename DLADebug -> DLARegions across parser/chunker/tests.

refactor(parser): drop lib_type selector; align NewXxxParser with
NewPDFParser
- remove config["lib_type"] lookup and the libType param/field/switch
from all nine constructors; surface the CGO-required error at
ParseWithResult time instead of construction time; drop resolveLibType,
its test, and the four lib_type constants.

feat(utility): add a reusable workerpool for bounded concurrent
execution
- internal/utility/workerpool.go (+ tests).

refactor: translate Chinese prose comments to English in non-harness Go
files.

chore: upgrade github.com/cloudwego/eino from v0.9.9 to v0.9.12.
2026-07-10 10:36:10 +08:00

514 lines
19 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 component — Parser component (Phase 2.2 of
// port-rag-flow-pipeline-to-go.md §4).
//
// SCOPE (honest):
//
// - WHAT IS PORTED:
//
// - The component's lifecycle contract: NewParserComponent /
// Invoke / Inputs / Outputs and registration under
// runtime.CategoryIngestion.
//
// - Per-page parallelism is delegated to the parser backends
// (e.g. internal/deepdoc/parser/pdf fans out one worker per
// page and assembles the results in page order). This
// component only reshapes the parser output into the
// schema.Page layout and keeps the deterministic, page-number
// sorted merge contract (plan §8 R8) that the downstream
// chunker / tokenizer rely on for stable chunk IDs.
//
// - Progress (start/done callback) and elapsed-time stamping
// (_created_time / _elapsed_time) are owned by the canvas
// framework (internal/agent/canvas/node_body.go realComponentBody),
// which wraps every component Invoke. This component does not call
// those helpers itself. See internal/agent/runtime/helpers.go.
//
// - WHAT IS NOT YET PORTED:
//
// - The Python component dispatches to 13 file-format branches
// (pdf, markdown, text&code, html, spreadsheet, slides, doc,
// docx, image, audio, video, email, epub) — see parser.py
// function_map at line ~1273. The Go counterparts in
// internal/parser/parser/ are SKELETONS that print to
// stdout and return nil. The cgo-gated office variants
// (docx, doc, ppt, pptx, xls, xlsx) call office_oxide but
// discard the result.
//
// - Until the parser package returns real data, the Go Parser
// component uses a "raw text" fallback: it treats the input
// binary as UTF-8 and slices it into 1 page (or N pages
// when the upstream signals a page boundary with a literal
// "\f" form feed). This is the conservative, observable
// behaviour until the real format dispatch lands.
//
// - The Python side's "image2id" pipeline (parser.py:1317-1329)
// that uploads embedded images to MinIO is not replicated —
// the schema layer carries images as opaque map values, and
// the upload step is the responsibility of a separate
// side-effect component (out of scope for Phase 2.2).
//
// - The Python _param.check() business validation
// (parse_method whitelist, output_format whitelist, etc.) is
// not replicated. The component trusts the param block
// passed in at construction time; invalid values surface as
// runtime errors in the chosen parser branch.
//
// - NO PERSISTENCE: parsed pages live only in the per-run
// output map, exactly as the schema.Page type is intended.
package component
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"unicode/utf8"
"ragflow/internal/agent/runtime"
"ragflow/internal/ingestion/component/schema"
"ragflow/internal/utility"
)
const ComponentNameParser = "Parser"
// pageFormFeed is the byte that text-page mode treats as a
// hard page boundary. Matches the ASCII form feed (\f, 0x0C) — the
// same convention used by the Python TxtParser and by most
// "page-segmented text" codecs.
const pageFormFeed = '\f'
// ParserComponent runs the configured parser branch against the
// upstream "binary" payload and returns a deterministic, page-
// sorted slice of schema.Page values.
//
// The instance is safe for concurrent invocation: each Invoke call
// builds its own per-batch goroutine tree and merges results in
// the goroutine that returned from Invoke. The static Param is
// read-only after construction.
type ParserComponent struct {
// Param is the static configuration from schema.ParserParam.
// Kept as a value (not a pointer) so callers can pass literals
// and the component makes its own copy.
Param schema.ParserParam
}
// NewParserComponent constructs a Parser from a DSL param map.
// The map is decoded into schema.ParserParam.Defaults() and then
// overlaid with the supplied values. This matches the Python
// "default + override" pattern in parser.py:ParserParam.__init__.
//
// Param map shape (all keys optional; missing keys fall back to
// schema.ParserParam.Defaults() values):
//
// {
// "setups": map[string]map[string]any,
// "allowed_output_format": map[string][]string,
// }
//
// Errors here surface as canvas compile failures so a malformed
// param is caught at build time rather than mid-run.
func NewParserComponent(params map[string]any) (runtime.Component, error) {
p := schema.ParserParam{}.Defaults()
if params == nil {
return &ParserComponent{Param: p}, nil
}
// Setups — best-effort decode. A type mismatch in a single
// setup entry drops just that entry; the rest of the table
// remains usable. This matches the python behaviour of
// accepting whatever shape the JSON loader hands back.
if rawSetups, ok := params["setups"].(map[string]any); ok {
for fileType, raw := range rawSetups {
setupMap, ok := raw.(map[string]any)
if !ok {
continue
}
if p.Setups == nil {
p.Setups = make(map[string]schema.ParserSetup)
}
p.Setups[fileType] = schema.ParserSetup(setupMap)
}
}
if rawAllowed, ok := params["allowed_output_format"].(map[string]any); ok {
allowed := make(map[string][]string, len(rawAllowed))
for fileType, raw := range rawAllowed {
list, ok := raw.([]any)
if !ok {
continue
}
formats := make([]string, 0, len(list))
for _, item := range list {
if s, ok := item.(string); ok {
formats = append(formats, s)
}
}
allowed[fileType] = formats
}
p.AllowedOutputFormat = allowed
}
return &ParserComponent{Param: p}, nil
}
// Inputs returns the static parameter metadata. The component
// reads the following from the inputs map at Invoke time:
//
// binary ([]byte, optional) — file bytes from upstream File.
// When absent, Parser resolves
// them from bucket/path or doc_id.
// doc_id (string, optional) — document ID used for naming and,
// when binary is absent, storage lookup.
func (c *ParserComponent) Inputs() map[string]string {
return map[string]string{
"binary": "Optional file bytes ([]byte). When absent, Parser resolves them from bucket/path or doc_id.",
"doc_id": "Optional document ID (string). Used for downstream correlation and doc_id-driven storage lookup.",
"bucket": "Optional storage bucket override. Used when binary is absent.",
"path": "Optional storage object key override. Used when binary is absent.",
}
}
// Outputs returns the public surface that downstream ingestion
// components (Chunker, Tokenizer, Extractor) can wire into.
//
// pages []schema.Page — sorted by PageNumber. Deterministic
// merge per plan §8 R8.
// name string — carried over from the upstream file/document
// name (or doc_id when no name is available).
// output_format string — "text" when emitting text pages,
// otherwise the parser-selected wire
// format.
// _ERROR string — populated when the component short-
// circuits with an error message
// (mirrors Python set_output("_ERROR", ...)).
func (c *ParserComponent) Outputs() map[string]string {
return map[string]string{
"pages": "[]schema.Page: parsed pages sorted by PageNumber.",
"name": "string: the upstream file/document name (or doc_id when no name is available).",
"output_format": "string: the active output format (\"text\" when emitting text pages).",
"_ERROR": "string: set on short-circuit errors.",
}
}
// Invoke runs the parser against the upstream "binary" payload.
//
// Returns:
//
// {
// "pages": []schema.Page (sorted by PageNumber),
// "name": string (from inputs["doc_id"]),
// "output_format": "text",
// "_created_time": RFC3339Nano (via TrackElapsed),
// "_elapsed_time": float64 seconds (via TrackElapsed),
// }
//
// Per-page parallelism and aggregation now live in the parser
// backends (e.g. internal/deepdoc/parser/pdf fans out one worker
// per page and assembles the results in page order), so this
// component does no goroutine fan-out of its own.
//
// DETERMINISTIC MERGE (plan §8 R8): after the page slice is built,
// it is sorted by PageNumber. This guarantees the same input
// produces byte-identical output across runs and is the contract
// that downstream Chunker / Tokenizer rely on for stable chunk
// IDs (chunks that span pages must reference adjacent PageNumbers
// in input order).
func (c *ParserComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
// 1. Decode the binary input.
binary, err := readParserBinary(ctx, inputs)
if err != nil {
return nil, err
}
docID, _ := inputs["doc_id"].(string)
filename := parserInputName(inputs, docID)
// 2. Resolve the file family from the inputs. When the family
// is known, dispatchParse returns a typed parser payload.
// Otherwise the component stays in text-page mode.
//
// We track TWO forms:
//
// - fileTypeExt — the utility.FileType extension form ("md",
// "docx", ...). Used by parser.GetParser, whose switch
// arms are keyed off the utility constants.
//
// - fileTypeFam — the python-side family name ("markdown",
// "docx", ...). Used by setups[fileType] and
// allowed_output_format[fileType] lookups, which are keyed
// off the python family identifiers in schema.ParserParam.
//
// For most families the two forms coincide; the divergence
// exists for markdown ("md" vs "markdown") and slides
// ("ppt"/"pptx" vs "slides") and is intentional — the python
// ParserParam collapses the slide family into a single key.
fileTypeExt := fileTypeFromInputs(inputs)
fileTypeFam := pythonFamilyName(string(fileTypeExt))
// 2a. Validate the requested output_format against the
// family-specific allowed_output_format whitelist. We do
// this even when no setups entry exists so a misconfigured
// DSL surfaces as _ERROR instead of a silent fallback.
if _, hasSetup := c.Param.Setups[fileTypeFam]; hasSetup {
if _, verr := resolveOutputFormat(fileTypeFam, c.Param.Setups, c.Param.AllowedOutputFormat); verr != nil {
return nil, verr
}
}
dispatched, handledVision, visionErr := maybeDispatchPDFVision(fileTypeExt, filename, binary, inputs, c.Param.Setups)
if visionErr != nil {
return nil, visionErr
}
if !handledVision {
dispatched = dispatchParse(fileTypeExt, filename, binary, c.Param.Setups)
dispatched = hydrateEmptyDispatchPayload(dispatched, binary)
}
// Known/supported families must fail loudly when dispatch or
// parsing breaks. Only unknown families keep the raw-text fallback.
if dispatched.Err != nil && fileTypeExt != utility.FileTypeOTHER {
return nil, dispatched.Err
}
// 3. Build the legacy `pages` slice. When the dispatch path
// produced a JSON payload, we re-shape it into the page
// layout the chunker side consumes (`{text, doc_type_kwd,
// page_number?}`); when the dispatch produced a string
// payload we emit a single page carrying the rendered text;
// otherwise we slice the binary on ASCII form-feed and
// treat the input as text pages.
var pages [][]byte
var dispatchedPages []schema.Page
switch {
case dispatched.Err == nil && dispatched.OutputFormat == "json" && len(dispatched.JSON) > 0:
dispatchedPages = jsonItemsToPages(dispatched.JSON)
pages = pagesFromDispatch(dispatchedPages)
case dispatched.Err == nil && dispatched.OutputFormat != "":
var text string
switch dispatched.OutputFormat {
case "markdown":
text = dispatched.Markdown
case "html":
text = dispatched.HTML
case "text":
text = dispatched.Text
}
pages = [][]byte{[]byte(text)}
default:
pages = splitIntoPages(binary)
if len(pages) == 0 {
pages = [][]byte{nil}
}
}
// 4. Build the page slice sequentially. Per-page parallelism now
// lives in the parser backends (e.g. internal/deepdoc/parser/pdf
// fans out one worker per page and assembles in page order), so
// this component only reshapes the parser output. The DETERMINISTIC
// MERGE (plan §8 R8) keeps pages sorted by PageNumber so the
// downstream chunker / tokenizer get stable chunk IDs.
parsed, err := buildPagesFromBytes(ctx, pages)
if err != nil {
return nil, fmt.Errorf("Parser: %w", err)
}
sortPagesByNumber(parsed)
out := buildParserOutputs(parsed, dispatched, filename, fileTypeExt)
// Forward the storage references so a downstream chunker can
// re-acquire the source PDF and crop section images on demand,
// instead of carrying the binary across the component boundary.
if docID != "" {
out["doc_id"] = docID
}
if bucket, _ := getString(inputs, "bucket"); bucket != "" {
out["bucket"] = bucket
}
if path, _ := getString(inputs, "path"); path != "" {
out["path"] = path
}
// Progress (_created_time / _elapsed_time stamping, start/done
// callbacks) is owned by the canvas framework (realComponentBody),
// not by this component, so we return the work result directly.
return out, nil
}
// buildPagesFromBytes reshapes already-prepared page bytes into the
// schema.Page layout the downstream chunker consumes. The per-page
// parse (including any parallelism) now lives in the parser backends
// (internal/parser/parser and internal/deepdoc/parser/pdf); this
// component only wraps the bytes into pages and honors context
// cancellation so an abandoned run does not keep reshaping pages.
//
// The function is format-agnostic: it does not resolve parsers or
// inspect file families — it only carries the raw bytes under the
// "text" key with doc_type_kwd "text", matching the shape downstream
// readers expect from the raw-text fallback and dispatch paths.
func buildPagesFromBytes(ctx context.Context, pages [][]byte) ([]schema.Page, error) {
out := make([]schema.Page, 0, len(pages))
for _, raw := range pages {
if err := ctx.Err(); err != nil {
return nil, err
}
out = append(out, schema.Page{
"text": string(raw),
"doc_type_kwd": "text",
})
}
return out, nil
}
// --- input helpers ---
// readParserBinary pulls the "binary" payload out of the inputs
// map. The accepted shapes are:
//
// []byte — the in-process caller's normal form
// string — UTF-8 text (json callers' normal form)
// nil / absent — returns an empty page (not an error)
//
// A non-UTF-8 string is rejected with a clear error so a caller
// that mistakenly hands a base64 string sees the failure
// immediately (mirrors pipeline_chunker's "no try-base64" rule).
func readParserBinary(ctx context.Context, inputs map[string]any) ([]byte, error) {
if inputs == nil {
return nil, nil
}
if b, ok := inputs["binary"].([]byte); ok {
return b, nil
}
if s, ok := inputs["binary"].(string); ok {
if !utf8.ValidString(s) {
return nil, errors.New(
"Parser: binary string is not valid UTF-8. " +
"Text-page mode only accepts UTF-8 text input.")
}
return []byte(s), nil
}
bucket, _ := getString(inputs, "bucket")
path, _ := getString(inputs, "path")
if bucket != "" && path != "" {
return FetchBinary(ctx, bucket, path)
}
if docID, ok := getString(inputs, "doc_id"); ok && docID != "" {
ref, err := ResolveDocumentStorage(docID)
if err != nil {
return nil, fmt.Errorf("Parser: resolve doc_id %q: %w", docID, err)
}
return FetchBinary(ctx, ref.Bucket, ref.Path)
}
return nil, nil
}
// splitIntoPages segments the input bytes on ASCII form-feed
// (\f, 0x0C). An input with no form-feeds becomes a single page
// (the whole input). Empty pages are dropped — the python
// TxtParser skips empty splits the same way.
func splitIntoPages(b []byte) [][]byte {
if len(b) == 0 {
return nil
}
// Fast path: no form-feeds → single page.
if !containsFormFeed(b) {
return [][]byte{b}
}
parts := strings.Split(string(b), string(pageFormFeed))
out := make([][]byte, 0, len(parts))
for _, p := range parts {
if len(p) == 0 {
continue
}
out = append(out, []byte(p))
}
return out
}
// containsFormFeed is a tiny specialised byte-search to avoid
// pulling in bytes.Index for one call site.
func containsFormFeed(b []byte) bool {
for _, c := range b {
if c == pageFormFeed {
return true
}
}
return false
}
// sortPagesByNumber orders pages by their PageNumber key
// ascending. Pages without a PageNumber key (or with a non-int
// value) sort to the END so the deterministic contract is
// "numbered pages first, then unnumbered" — this matches the
// Python component's loop order (it processes pages in input
// order, not in PageNumber order, but the Go merge is
// intentionally stricter so the test can assert exact byte
// equality across runs).
func sortPagesByNumber(pages []schema.Page) {
sort.SliceStable(pages, func(i, j int) bool {
pi, oki := numericPageNumber(pages[i])
pj, okj := numericPageNumber(pages[j])
switch {
case oki && okj:
return pi < pj
case oki:
return true // i is numbered, j is not
case okj:
return false
default:
return false // stable
}
})
}
func numericPageNumber(p schema.Page) (int, bool) {
if p == nil {
return 0, false
}
v, ok := p["page_number"]
if !ok {
return 0, false
}
switch n := v.(type) {
case int:
return n, true
case int64:
return int(n), true
case float64:
return int(n), true
}
return 0, false
}
// toAnyPages is a tiny adapter that hands the page slice to
// the output map as `any`. We use it instead of a direct cast
// so the type stays `[]schema.Page` in the Go source and the
// output map value type is `any` — matching the runtime.Component
// contract.
func toAnyPages(pages []schema.Page) any { return pages }
// init registers Parser under CategoryIngestion per plan §4
// Phase 2.2. The factory is a thin closure that decodes the
// DSL param map; the static Metadata is derived from
// Inputs()/Outputs() on a zero-value instance.
func init() {
pc := &ParserComponent{}
runtime.MustRegister(ComponentNameParser, runtime.CategoryIngestion,
func(_ string, params map[string]any) (runtime.Component, error) {
return NewParserComponent(params)
},
runtime.Metadata{
Version: "1.0.0",
Inputs: pc.Inputs(),
Outputs: pc.Outputs(),
})
}