mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-07 03:48:44 +08:00
Moves doc_id blob resolution into Parser, tightens chunker/tokenizer to Python output_format semantics, updates extractor list handling, and fixes real-template integration tests.
640 lines
22 KiB
Go
640 lines
22 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 / Parallelism / Inputs / Outputs and registration
|
|
// under runtime.CategoryIngestion.
|
|
//
|
|
// - The Python fan-out pattern from rag/flow/parser/parser.py
|
|
// (parallel page parsing, deterministic merge by page number,
|
|
// see plan §8 R8) — the Go implementation uses
|
|
// golang.org/x/sync/errgroup with up to 4 goroutines and
|
|
// bounds each fan-out batch by a "page_size" input (default
|
|
// = ceil(total_pages / 4)).
|
|
//
|
|
// - TrackProgress (start/done callback), WithTimeout (60s per
|
|
// page-batch) and TrackElapsed (_created_time / _elapsed_time
|
|
// stamping) — see internal/agent/runtime/helpers.go for the
|
|
// helpers, plan §1 background.
|
|
//
|
|
// - 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"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
"ragflow/internal/agent/runtime"
|
|
"ragflow/internal/ingestion/component/schema"
|
|
)
|
|
|
|
const ComponentNameParser = "Parser"
|
|
|
|
// parserParallelism is the fan-out degree for the Parser component.
|
|
// Matches the plan §2 AD-5a choice ("Parser: 4 (parallel page
|
|
// parsing)"). Used by the pipeline runner when it needs to know how
|
|
// many goroutines the component is willing to absorb.
|
|
const parserParallelism = 4
|
|
|
|
// parserPageBatchTimeout is the per-batch timeout. Mirrors the
|
|
// Python component's `@timeout(60)` decorator on the page parse
|
|
// branch. WithTimeout collapses the dual-layer
|
|
// asyncio.wait_for / @timeout model into a single context, see
|
|
// plan §8 R1.
|
|
const parserPageBatchTimeout = 60 * time.Second
|
|
|
|
// 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
|
|
}
|
|
|
|
// Parallelism declares the goroutine fan-out degree. The pipeline
|
|
// runner uses this to decide how many worker slots the component
|
|
// can absorb. We return 4 to match the Python asyncio.gather
|
|
// pattern that fans one batch per page-range.
|
|
func (c *ParserComponent) Parallelism() int { return parserParallelism }
|
|
|
|
// 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.
|
|
// page_size (int, optional) — pages per goroutine for
|
|
// fan-out. Defaults to
|
|
// ceil(totalPages / Parallelism).
|
|
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.",
|
|
"page_size": "Optional integer. Pages per goroutine for fan-out. Default: ceil(totalPages / 4).",
|
|
}
|
|
}
|
|
|
|
// 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),
|
|
// }
|
|
//
|
|
// The fan-out is bounded by Parallelism() goroutines. Each
|
|
// goroutine parses its page-batch under a derived timeout
|
|
// (WithTimeout, 60s). The first error cancels the errgroup
|
|
// context; siblings observe ctx.Done() and abandon their work.
|
|
//
|
|
// DETERMINISTIC MERGE (plan §8 R8): after fan-out, the page slice
|
|
// 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 := dispatchParse(fileTypeExt, filename, binary, c.Param.Setups)
|
|
dispatched = hydrateEmptyDispatchPayload(dispatched, binary)
|
|
|
|
// 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}
|
|
}
|
|
}
|
|
totalPages := len(pages)
|
|
|
|
// 4. Fan-out: split pages into batches of pageSize. The
|
|
// default pageSize is ceil(totalPages / Parallelism),
|
|
// matching the plan §2 AD-5a target.
|
|
pageSize := resolvePageSize(inputs, totalPages)
|
|
batches := splitIntoBatches(pages, pageSize)
|
|
|
|
// 5. Drive the fan-out from TrackProgress (which delivers the
|
|
// start/done/fail callback sequence); stamp
|
|
// _created_time / _elapsed_time on the result via
|
|
// TrackElapsed without re-running the work.
|
|
var out map[string]any
|
|
progressErr := runtime.TrackProgress(ComponentNameParser, nil, func() error {
|
|
parsed, err := fanOutAndMerge(ctx, batches, parserParallelism)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Sort by PageNumber — DETERMINISTIC MERGE (plan §8 R8).
|
|
sortPagesByNumber(parsed)
|
|
out = buildParserOutputs(parsed, dispatched, filename, fileTypeExt)
|
|
return nil
|
|
})
|
|
if progressErr != nil {
|
|
return nil, fmt.Errorf("Parser: %w", progressErr)
|
|
}
|
|
// Stamp _created_time / _elapsed_time. We pass a closure
|
|
// that returns the pre-built `out` so the helper does not
|
|
// re-execute the fan-out.
|
|
return runtime.TrackElapsed(ComponentNameParser, func() (map[string]any, error) {
|
|
return out, nil
|
|
})
|
|
}
|
|
|
|
// fanOutAndMerge parses each batch in parallel and concatenates
|
|
// the per-batch results. The first error cancels the errgroup
|
|
// context; siblings see ctx.Done() and abandon their parse
|
|
// (returning ctx.Err()).
|
|
//
|
|
// Concurrency model: at most `parallelism` goroutines run
|
|
// concurrently. errgroup.WithContext provides the cancel-on-
|
|
// first-error behaviour, and golang.org/x/sync/errgroup is
|
|
// already in go.mod (line 59).
|
|
func fanOutAndMerge(parent context.Context, batches [][][]byte, parallelism int) ([]schema.Page, error) {
|
|
if len(batches) == 0 {
|
|
return nil, nil
|
|
}
|
|
if parallelism < 1 {
|
|
parallelism = 1
|
|
}
|
|
g, ctx := errgroup.WithContext(parent)
|
|
g.SetLimit(parallelism)
|
|
|
|
// One slot per batch; we collect the results in order so the
|
|
// caller can sort the merged slice by PageNumber without
|
|
// needing a mutex.
|
|
results := make([][]schema.Page, len(batches))
|
|
for i, batch := range batches {
|
|
i, batch := i, batch
|
|
g.Go(func() error {
|
|
pages, err := parseBatch(ctx, batch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
results[i] = pages
|
|
return nil
|
|
})
|
|
}
|
|
if err := g.Wait(); err != nil {
|
|
return nil, err
|
|
}
|
|
// Flatten in batch order. The caller is responsible for
|
|
// sorting by PageNumber — we deliberately do NOT sort here
|
|
// so the per-batch order is visible to tests.
|
|
total := 0
|
|
for _, r := range results {
|
|
total += len(r)
|
|
}
|
|
merged := make([]schema.Page, 0, total)
|
|
for _, r := range results {
|
|
merged = append(merged, r...)
|
|
}
|
|
return merged, nil
|
|
}
|
|
|
|
// parseBatch parses a single batch of pages under a derived
|
|
// 60-second timeout. The batch is the unit of fan-out: if a
|
|
// batch exceeds its timeout, ONLY that batch errors; siblings
|
|
// see ctx.Done() and abandon their work (errgroup cancel
|
|
// cascades).
|
|
//
|
|
// runtime.WithTimeout returns just an error; we capture the
|
|
// result pages in a closure-scoped variable and read it back
|
|
// after Wait. This keeps the helper at its single-purpose
|
|
// signature (ctx, fn -> error) without growing the runtime API.
|
|
func parseBatch(ctx context.Context, batch [][]byte) ([]schema.Page, error) {
|
|
var pages []schema.Page
|
|
err := runtime.WithTimeout(ctx, parserPageBatchTimeout, func(ctx context.Context) error {
|
|
pages = make([]schema.Page, 0, len(batch))
|
|
for _, raw := range batch {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
// Text-page mode: the bytes are already page text.
|
|
pages = append(pages, schema.Page{
|
|
"text": string(raw),
|
|
"doc_type_kwd": "text",
|
|
})
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return pages, 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
|
|
}
|
|
|
|
// splitIntoBatches partitions the page slice into batches of
|
|
// `size` consecutive pages. A non-positive size collapses to
|
|
// one batch.
|
|
func splitIntoBatches(pages [][]byte, size int) [][][]byte {
|
|
if size < 1 {
|
|
size = len(pages)
|
|
}
|
|
if size < 1 {
|
|
return nil
|
|
}
|
|
batches := make([][][]byte, 0, (len(pages)+size-1)/size)
|
|
for i := 0; i < len(pages); i += size {
|
|
end := i + size
|
|
if end > len(pages) {
|
|
end = len(pages)
|
|
}
|
|
batch := make([][]byte, end-i)
|
|
copy(batch, pages[i:end])
|
|
batches = append(batches, batch)
|
|
}
|
|
return batches
|
|
}
|
|
|
|
// resolvePageSize returns the inputs["page_size"] value when
|
|
// valid, otherwise ceil(totalPages / Parallelism). A page_size
|
|
// of 0 or 1 is treated as "use the default" so a caller that
|
|
// sets page_size=1 to mean "no batching" still fans out across
|
|
// `Parallelism` goroutines.
|
|
func resolvePageSize(inputs map[string]any, totalPages int) int {
|
|
if inputs != nil {
|
|
if v, ok := inputs["page_size"].(int); ok && v > 1 {
|
|
return v
|
|
}
|
|
if v, ok := inputs["page_size"].(int64); ok && v > 1 {
|
|
return int(v)
|
|
}
|
|
if v, ok := inputs["page_size"].(float64); ok && v > 1 {
|
|
return int(v)
|
|
}
|
|
}
|
|
if totalPages < 1 {
|
|
return 1
|
|
}
|
|
// ceil(totalPages / Parallelism)
|
|
size := (totalPages + parserParallelism - 1) / parserParallelism
|
|
if size < 1 {
|
|
size = 1
|
|
}
|
|
return size
|
|
}
|
|
|
|
// 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(),
|
|
})
|
|
}
|