2026-07-08 19:08:11 +08:00
|
|
|
//
|
|
|
|
|
// 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 task
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
2026-07-17 14:40:09 +08:00
|
|
|
"strings"
|
2026-07-08 19:08:11 +08:00
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"ragflow/internal/common"
|
|
|
|
|
"ragflow/internal/utility"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// RenameTextToContentWithWeight renames the "text" key to "content_with_weight".
|
|
|
|
|
// If "content_with_weight" already exists, the "text" key is simply removed.
|
|
|
|
|
// Mirrors Python: ck["content_with_weight"] = ck["text"]; del ck["text"]
|
|
|
|
|
func RenameTextToContentWithWeight(chunk map[string]any) {
|
|
|
|
|
if _, exists := chunk["content_with_weight"]; !exists {
|
|
|
|
|
if text, ok := chunk["text"]; ok {
|
|
|
|
|
chunk["content_with_weight"] = text
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
delete(chunk, "text")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetEmbeddingTokenConsumption extracts the embedding token consumption from pipeline output.
|
|
|
|
|
// Handles both int (Go native) and float64 (after JSON round-trip).
|
|
|
|
|
func GetEmbeddingTokenConsumption(output map[string]any) int {
|
|
|
|
|
if output == nil {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
switch v := output[EmbeddingTokenConsumptionKey].(type) {
|
|
|
|
|
case int:
|
|
|
|
|
return v
|
|
|
|
|
case float64:
|
|
|
|
|
return int(v)
|
|
|
|
|
default:
|
|
|
|
|
common.Warn(fmt.Sprintf("unexpected type %T for embedding token consumption, key=%q", v, EmbeddingTokenConsumptionKey))
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 11:08:04 +08:00
|
|
|
// ProcessChunksForPipeline mutates chunks into the pre-index structure used by
|
2026-07-15 14:53:16 +08:00
|
|
|
// the pipeline and returns merged metadata. It returns an error if a chunk's
|
|
|
|
|
// "text" field is present but not a string: that is an upstream contract
|
|
|
|
|
// violation (the chunker/parser must emit string text), and continuing would
|
|
|
|
|
// collapse every such chunk onto the same empty-text ChunkID, silently
|
|
|
|
|
// overwriting each other in the index. The caller fails the task so the
|
|
|
|
|
// violation surfaces instead of corrupting the index.
|
2026-07-13 11:08:04 +08:00
|
|
|
func ProcessChunksForPipeline(
|
2026-07-08 19:08:11 +08:00
|
|
|
chunks []map[string]any,
|
|
|
|
|
docID string,
|
|
|
|
|
kbID string,
|
|
|
|
|
docName string,
|
|
|
|
|
now time.Time,
|
2026-07-15 14:53:16 +08:00
|
|
|
) (map[string]any, error) {
|
2026-07-08 19:08:11 +08:00
|
|
|
if chunks == nil {
|
2026-07-15 14:53:16 +08:00
|
|
|
return nil, nil
|
2026-07-08 19:08:11 +08:00
|
|
|
}
|
|
|
|
|
metadata := make(map[string]any)
|
|
|
|
|
timeStr := now.Format("2006-01-02 15:04:05")
|
|
|
|
|
timestamp := float64(now.UnixMicro()) / 1e6
|
|
|
|
|
|
|
|
|
|
for _, ck := range chunks {
|
|
|
|
|
ck["doc_id"] = docID
|
|
|
|
|
ck["kb_id"] = []string{kbID}
|
|
|
|
|
ck["docnm_kwd"] = docName
|
|
|
|
|
ck["create_time"] = timeStr
|
|
|
|
|
ck["create_timestamp_flt"] = timestamp
|
|
|
|
|
|
|
|
|
|
if _, exists := ck["id"]; !exists {
|
Feat(ingestion): align image to MinIO upload, unify chunk-id computation and add PPT parsing support (#17111)
## Summary
Align the Go ingestion pipeline with Python's `image` → `img_id`
persistence semantics, and unify the chunk-id computation across all
paths.
### Changes
**1. Image upload at chunker stage **
- Add `ImageUploader` type and `DefaultImageUploader` in
`internal/ingestion/component/image_uploader.go` — the write-side
counterpart to `FetchBinary`, storing raw image bytes at `(bucket=kbID,
key=chunkID)`, no re-encoding.
- Add `uploadOneImage` — pure upload primitive (bytes in, `img_id` out),
does not touch chunk maps.
- Add `uploadChunkImages` / `uploadChunkImage` — caller-side helper:
decodes `image` from a chunk, uploads bytes, writes `ck["img_id"]`,
`delete(ck,"image")` , bounded by a process-wide semaphore (default 10,
env `MAX_CONCURRENT_MINIO`).
- Wire via `imageUploadDecorator` in `register.go`: every chunker runs
the upload pass at invocation time, writing `ck["id"]` before upload and
dropping image bytes right after — peak memory = single chunk image
lifetime.
**2. Unify chunk-id computation**
- Consolidate three separate id-computation paths (`component.ChunkID`,
`task.ChunkID`, inline `FormatUint` in API) into one:
`common.ChunkID(docID, text string)`, using `%016x` +
`xxhash.Sum64String(text+docID)` (matching Python `hexdigest()`).
- The chunker decorator writes `ck["id"]` via `common.ChunkID`; the
persist stage (`ProcessChunksForPipeline`) falls back to the same
function (`if !exists id`).
- The API AddChunk path now also calls `common.ChunkID` instead of the
divergent `FormatUint(xxhash.Sum64(...))` — fixing a pre-existing
inconsistency.
- Delete `internal/ingestion/component/chunk_id.go` and
`internal/ingestion/task/chunk_builder.go` (both were pure forwarding
shells).
**3. Preserve `img_id` (never deleted)**
- `img_id` is a persistent index field (Infinity, OB) and the only
consumer-side reference for image retrieval; it is NEVER removed from
the chunk map. Only `image` (raw data URL) is dropped after upload.
**4. PPT parser support**
Previously PPT parsing failed. Add support to parse.
### Key design decisions
| Decision | Choice |
|----------|--------|
| Upload timing | Chunker stage (not persist), so image bytes are
dropped immediately — bounds peak memory to one chunk image |
| Upload concurrency | Process-wide semaphore, default 10 (matches
Python `minio_limiter`), env `MAX_CONCURRENT_MINIO` |
| Image encoding | Store as-is, no JPEG re-encoding (unlike Python) |
| `img_id` format | `"<kb_id>-<chunk_id>"` — matches Python
task_executor path |
| id function | Single `common.ChunkID(docID, text)`, concatenation
`text+docID` inside hash (matching Python) |
| `removeInternalChunkFields` | Retains `delete(ck,"image")` as
defensive fallback for non-chunker paths |
### Files touched
| File | Change |
|------|--------|
| `internal/common/format.go` | Add `ChunkID(docID, text)` |
| `internal/common/format_test.go` | Add ChunkID golden-value test |
| `internal/ingestion/component/image_uploader.go` | Add `ImageUploader`
type + `DefaultImageUploader` |
| `internal/ingestion/component/chunker/image_upload.go` | Add
`uploadOneImage`, `uploadChunkImages`, `uploadChunkImage`,
`decodeChunkImage`, semaphore |
| `internal/ingestion/component/chunker/image_upload_test.go` | Tests:
upload/drop, skip, no-image, concurrency, missing-id error |
| `internal/ingestion/component/chunker/register.go` | Add
`imageUploadDecorator` (writes `ck["id"]`, runs upload) |
| `internal/ingestion/task/chunk_process.go` | Use `common.ChunkID` for
persist fallback |
| `internal/service/chunk/chunk.go` | Use `common.ChunkID` instead of
`FormatUint` |
| `internal/ingestion/component/chunk_id.go` | **Deleted** (moved to
`common`) |
| `internal/ingestion/task/chunk_builder.go` | **Deleted** (shell, no
callers left) |
| `internal/ingestion/task/chunk_builder_test.go` | **Deleted** (test
migrated to `common/format_test.go`) |
### Verification
```
bash build.sh --test ./internal/service/chunk/... ./internal/common/... ./internal/ingestion/component/... ./internal/ingestion/task/...
→ ok service/chunk / common / component / chunker / schema / task
```
2026-07-20 19:33:51 +08:00
|
|
|
text, _ := ck["text"].(string)
|
|
|
|
|
ck["id"] = common.ChunkID(docID, text)
|
2026-07-08 19:08:11 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-15 14:53:16 +08:00
|
|
|
cleanupConsumedChunkFields(ck)
|
2026-07-08 19:08:11 +08:00
|
|
|
metadata = mergeChunkMetadata(metadata, ck)
|
|
|
|
|
RenameTextToContentWithWeight(ck)
|
|
|
|
|
processChunkPositions(ck)
|
|
|
|
|
}
|
2026-07-15 14:53:16 +08:00
|
|
|
return metadata, nil
|
2026-07-08 19:08:11 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-15 14:53:16 +08:00
|
|
|
// cleanupConsumedChunkFields materializes the stored array form of the
|
|
|
|
|
// questions/keywords fields (when the upstream Tokenizer did not already set
|
|
|
|
|
// them) and strips the consumed source fields (questions/keywords/summary)
|
|
|
|
|
// before persist. This is persist-schema mapping, NOT linguistic tokenization:
|
|
|
|
|
// question_tks / important_tks / content_ltks are owned by the Tokenizer
|
|
|
|
|
// component; the executor no longer falls back to producing them.
|
|
|
|
|
func cleanupConsumedChunkFields(ck map[string]any) {
|
|
|
|
|
if q, ok := ck["questions"].(string); ok {
|
|
|
|
|
if _, has := ck["question_kwd"]; !has {
|
|
|
|
|
ck["question_kwd"] = utility.SplitQuestions(q)
|
2026-07-08 19:08:11 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
delete(ck, "questions")
|
|
|
|
|
|
2026-07-15 14:53:16 +08:00
|
|
|
if kws, ok := ck["keywords"].(string); ok {
|
|
|
|
|
if _, has := ck["important_kwd"]; !has {
|
|
|
|
|
ck["important_kwd"] = utility.SplitKeywords(kws)
|
2026-07-08 19:08:11 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
delete(ck, "keywords")
|
|
|
|
|
|
|
|
|
|
delete(ck, "summary")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func mergeChunkMetadata(metadata map[string]any, ck map[string]any) map[string]any {
|
|
|
|
|
metaVal, exists := ck["metadata"]
|
|
|
|
|
if !exists {
|
|
|
|
|
return metadata
|
|
|
|
|
}
|
|
|
|
|
if metaMap, ok := metaVal.(map[string]any); ok {
|
|
|
|
|
metadata = utility.UpdateMetadataTo(metadata, metaMap)
|
|
|
|
|
}
|
|
|
|
|
delete(ck, "metadata")
|
|
|
|
|
return metadata
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 14:53:16 +08:00
|
|
|
// processChunkPositions converts the raw "positions" field into indexable
|
|
|
|
|
// position fields (page_num_int, top_int, position_int) via AddPositions,
|
feat: parser pages range and parse type validation for dataset/document (#17293)
## Summary
Adds page-range parsing support to the Go-native pipeline path and
introduces strict `parse_type` validation for both dataset and document
update endpoints.
## What changed
### Pages range parsing
- **`internal/utility/pdf_pages.go`** — `NormalizePDFPages`: normalizes
raw page ranges (list of `[from,to]` 1-indexed inclusive ranges) into
sorted, merged, deduplicated `[][]int`. Invalid ranges are dropped.
- **`internal/ingestion/pipeline/pdf_pages.go`** —
`NormalizeParserConfigPages`: walks any parser_config map and normalizes
`"pages"` values under every component → filetype setup, so the
persisted config always carries clean, merged ranges.
- **`internal/deepdoc/parser/pdf/parser.go`** — integrates
`resolvePagesToProcess` to filter parsed PDF pages by the configured
ranges.
- Pipeline integration (parser pages):
`internal/parser/parser/pdf_parser_common.go`, `chunk_process.go`, plus
associated e2e and unit tests.
### Parse type validation (shared logic)
- **`internal/service/parser_mode.go`** (new) — `ValidateParseTypeMode`:
shared function that validates `parse_type` (1=BuiltIn/parser_id,
2=Pipeline/pipeline_id) and ensures the corresponding field is present.
Used by both dataset and document update endpoints.
- **`internal/service/dataset/crud.go`** / `update.go` — replaces inline
`isPipelineMode`/`isBuiltinMode` computation with the shared
`service.ValidateParseTypeMode`.
- **`internal/service/document/document_dataset_update.go`** — adds
strict `parse_type` validation in `validateDatasetDocumentUpdate`,
simplifies the reparse logic to a two-way switch (isBuiltin/isPipeline)
now that parse_type is always valid.
- **`internal/service/document/document.go`** — adds `ParseType` field
to `UpdateDatasetDocumentRequest`.
- **`internal/service/document/document_dataset_update.go`** —
`updateDocumentParserConfig` fallback path when DSL loading fails.
- **`internal/service/parser_mode_test.go`** (new) — test coverage for
nil, invalid, and missing-field scenarios.
### Frontend
- **`web/src/interfaces/request/document.ts`** — adds `parseType` to
`IChangeParserRequestBody`.
- **`web/src/hooks/use-document-request.ts`** —
`useSetDocumentPipelineParser` sends `parse_type` in the PATCH payload.
- **`web/src/pages/dataset/dataset/use-change-document-parser.ts`** —
Go/Python branching for the document parser config dialog.
-
**`web/src/components/document-pipeline-dialog/use-document-pipeline-form.ts`**
— `buildSubmitData` returns `parseType` (bugfix: was dropped from the
return value).
### Test changes
- **Removed**: 2 tests that verified the old "mutually exclusive" error
(replaced by `ValidateParseTypeMode` coverage).
- **Modified**: 6 tests across document and dataset packages to include
`ParseType` in request structs.
- **Added**: new e2e tests for pages parsing (`pages_e2e_test.go`,
`pdf_parser_pages_e2e_test.go`) and unit tests for `NormalizePDFPages`,
`NormalizeParserConfigPages`, `resolvePagesToProcess`.
## Backward compatibility
- The `parse_type` field is **required** when `parser_id` or
`pipeline_id` is sent. This changes the contract for both dataset and
document PATCH endpoints, but aligns the Go backend with the existing
frontend behavior (the frontend already sends `parse_type`). Callers
that omit `parse_type` when updating parser/pipeline selections will
receive a clear error message.
- Existing callers that only update fields like `name`, `enabled`, or
`meta_fields` are unaffected.
- Test updates ensure all known call sites are compliant.
2026-07-23 19:57:27 +08:00
|
|
|
// then removes the raw "positions" and the sibling "_pdf_positions" internal
|
|
|
|
|
// field. _pdf_positions is the parser-emitted position matrix consumed by
|
|
|
|
|
// the chunker's image-crop pass (pdfcrop_cgo.go); after the chunker it is
|
|
|
|
|
// dead weight with no index column, so it is pruned unconditionally —
|
|
|
|
|
// independent of "positions" and not gated on the early-return below.
|
2026-07-15 14:53:16 +08:00
|
|
|
//
|
|
|
|
|
// Two source types reach this point:
|
|
|
|
|
// - []float64 — flat array of 5-tuples [page,left,right,top,bottom,…] from
|
|
|
|
|
// parsers that emit positions directly as a flat float64 slice.
|
|
|
|
|
// - [][]float64 — the production path: positions flow through ChunkDoc
|
|
|
|
|
// (json.RawMessage → decodeStructuredValue) which produces a slice of
|
|
|
|
|
// 5-element groups.
|
|
|
|
|
//
|
|
|
|
|
// Both are flattened into a single []float64 for AddPositions, which groups
|
|
|
|
|
// by 5 internally. Unexpected types are logged and discarded.
|
2026-07-08 19:08:11 +08:00
|
|
|
func processChunkPositions(ck map[string]any) {
|
feat: parser pages range and parse type validation for dataset/document (#17293)
## Summary
Adds page-range parsing support to the Go-native pipeline path and
introduces strict `parse_type` validation for both dataset and document
update endpoints.
## What changed
### Pages range parsing
- **`internal/utility/pdf_pages.go`** — `NormalizePDFPages`: normalizes
raw page ranges (list of `[from,to]` 1-indexed inclusive ranges) into
sorted, merged, deduplicated `[][]int`. Invalid ranges are dropped.
- **`internal/ingestion/pipeline/pdf_pages.go`** —
`NormalizeParserConfigPages`: walks any parser_config map and normalizes
`"pages"` values under every component → filetype setup, so the
persisted config always carries clean, merged ranges.
- **`internal/deepdoc/parser/pdf/parser.go`** — integrates
`resolvePagesToProcess` to filter parsed PDF pages by the configured
ranges.
- Pipeline integration (parser pages):
`internal/parser/parser/pdf_parser_common.go`, `chunk_process.go`, plus
associated e2e and unit tests.
### Parse type validation (shared logic)
- **`internal/service/parser_mode.go`** (new) — `ValidateParseTypeMode`:
shared function that validates `parse_type` (1=BuiltIn/parser_id,
2=Pipeline/pipeline_id) and ensures the corresponding field is present.
Used by both dataset and document update endpoints.
- **`internal/service/dataset/crud.go`** / `update.go` — replaces inline
`isPipelineMode`/`isBuiltinMode` computation with the shared
`service.ValidateParseTypeMode`.
- **`internal/service/document/document_dataset_update.go`** — adds
strict `parse_type` validation in `validateDatasetDocumentUpdate`,
simplifies the reparse logic to a two-way switch (isBuiltin/isPipeline)
now that parse_type is always valid.
- **`internal/service/document/document.go`** — adds `ParseType` field
to `UpdateDatasetDocumentRequest`.
- **`internal/service/document/document_dataset_update.go`** —
`updateDocumentParserConfig` fallback path when DSL loading fails.
- **`internal/service/parser_mode_test.go`** (new) — test coverage for
nil, invalid, and missing-field scenarios.
### Frontend
- **`web/src/interfaces/request/document.ts`** — adds `parseType` to
`IChangeParserRequestBody`.
- **`web/src/hooks/use-document-request.ts`** —
`useSetDocumentPipelineParser` sends `parse_type` in the PATCH payload.
- **`web/src/pages/dataset/dataset/use-change-document-parser.ts`** —
Go/Python branching for the document parser config dialog.
-
**`web/src/components/document-pipeline-dialog/use-document-pipeline-form.ts`**
— `buildSubmitData` returns `parseType` (bugfix: was dropped from the
return value).
### Test changes
- **Removed**: 2 tests that verified the old "mutually exclusive" error
(replaced by `ValidateParseTypeMode` coverage).
- **Modified**: 6 tests across document and dataset packages to include
`ParseType` in request structs.
- **Added**: new e2e tests for pages parsing (`pages_e2e_test.go`,
`pdf_parser_pages_e2e_test.go`) and unit tests for `NormalizePDFPages`,
`NormalizeParserConfigPages`, `resolvePagesToProcess`.
## Backward compatibility
- The `parse_type` field is **required** when `parser_id` or
`pipeline_id` is sent. This changes the contract for both dataset and
document PATCH endpoints, but aligns the Go backend with the existing
frontend behavior (the frontend already sends `parse_type`). Callers
that omit `parse_type` when updating parser/pipeline selections will
receive a clear error message.
- Existing callers that only update fields like `name`, `enabled`, or
`meta_fields` are unaffected.
- Test updates ensure all known call sites are compliant.
2026-07-23 19:57:27 +08:00
|
|
|
delete(ck, "_pdf_positions")
|
2026-07-08 19:08:11 +08:00
|
|
|
poss, exists := ck["positions"]
|
|
|
|
|
if !exists {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-15 14:53:16 +08:00
|
|
|
switch v := poss.(type) {
|
|
|
|
|
case []float64:
|
|
|
|
|
AddPositions(ck, v)
|
|
|
|
|
case [][]float64:
|
|
|
|
|
flat := make([]float64, 0, len(v)*5)
|
|
|
|
|
for _, group := range v {
|
|
|
|
|
flat = append(flat, group...)
|
|
|
|
|
}
|
|
|
|
|
AddPositions(ck, flat)
|
|
|
|
|
default:
|
|
|
|
|
common.Warn(fmt.Sprintf("chunk positions unexpected type %T; discarding", poss))
|
2026-07-08 19:08:11 +08:00
|
|
|
}
|
|
|
|
|
delete(ck, "positions")
|
2026-07-09 16:24:39 +08:00
|
|
|
}
|
2026-07-17 14:40:09 +08:00
|
|
|
|
|
|
|
|
// AggregateTableDocMetadata collects unique per-column values across all chunks
|
|
|
|
|
// for columns with role "metadata" or "both", merges them into document metadata.
|
|
|
|
|
// Mirrors Python: rag/utils/table_es_metadata.py:aggregate_table_doc_metadata
|
|
|
|
|
func AggregateTableDocMetadata(chunks []map[string]any, parserConfig map[string]interface{}) map[string]any {
|
|
|
|
|
mode, roles, tableColumnNames := resolveTableColumnConfig(parserConfig)
|
|
|
|
|
if mode == "" {
|
|
|
|
|
mode = "auto"
|
|
|
|
|
}
|
|
|
|
|
if mode != "auto" && mode != "manual" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if roles == nil {
|
|
|
|
|
roles = map[string]interface{}{}
|
|
|
|
|
}
|
|
|
|
|
var metaCols []string
|
|
|
|
|
if len(tableColumnNames) > 0 {
|
|
|
|
|
for _, n := range tableColumnNames {
|
|
|
|
|
col, _ := n.(string)
|
|
|
|
|
if col == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
role, _ := roles[col].(string)
|
|
|
|
|
if role == "" {
|
|
|
|
|
role = "both"
|
|
|
|
|
}
|
|
|
|
|
if role == "metadata" || role == "both" {
|
|
|
|
|
metaCols = append(metaCols, col)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
for col, v := range roles {
|
|
|
|
|
role, _ := v.(string)
|
|
|
|
|
if role == "metadata" || role == "both" {
|
|
|
|
|
metaCols = append(metaCols, col)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(metaCols) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
acc := make(map[string]map[string]struct{}, len(metaCols))
|
|
|
|
|
for _, col := range metaCols {
|
|
|
|
|
acc[col] = make(map[string]struct{})
|
|
|
|
|
}
|
|
|
|
|
for _, ck := range chunks {
|
|
|
|
|
cd, _ := ck["chunk_data"].(map[string]interface{})
|
|
|
|
|
if cd == nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for _, col := range metaCols {
|
|
|
|
|
val, ok := cd[col]
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
s, _ := val.(string)
|
|
|
|
|
if s == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
acc[col][s] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
out := make(map[string]any, len(acc))
|
|
|
|
|
for col, vals := range acc {
|
|
|
|
|
if len(vals) == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
deduped := make([]string, 0, len(vals))
|
|
|
|
|
for v := range vals {
|
|
|
|
|
deduped = append(deduped, v)
|
|
|
|
|
}
|
|
|
|
|
out[col] = deduped
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// resolveTableColumnConfig reads table column settings from parser_config.
|
|
|
|
|
// Tries root-level flat keys first; falls back to resolving from a Parser
|
|
|
|
|
// component entry's spreadsheet config in a component-ID-keyed parser_config.
|
|
|
|
|
func resolveTableColumnConfig(parserConfig map[string]interface{}) (mode string, roles map[string]interface{}, names []interface{}) {
|
|
|
|
|
mode, _ = parserConfig["table_column_mode"].(string)
|
|
|
|
|
roles, _ = parserConfig["table_column_roles"].(map[string]interface{})
|
|
|
|
|
rawNames, _ := parserConfig["table_column_names"].([]interface{})
|
|
|
|
|
if mode != "" || roles != nil || len(rawNames) > 0 {
|
|
|
|
|
return mode, roles, rawNames
|
|
|
|
|
}
|
|
|
|
|
for cid, raw := range parserConfig {
|
|
|
|
|
if !strings.HasPrefix(cid, "Parser:") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
comp, _ := raw.(map[string]interface{})
|
|
|
|
|
if comp == nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
ss, _ := comp["spreadsheet"].(map[string]interface{})
|
|
|
|
|
if ss == nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if v, ok := ss["column_mode"].(string); ok {
|
|
|
|
|
mode = v
|
|
|
|
|
}
|
|
|
|
|
if v, ok := ss["column_roles"].(map[string]interface{}); ok {
|
|
|
|
|
roles = v
|
|
|
|
|
}
|
|
|
|
|
if v, ok := ss["column_names"].([]interface{}); ok {
|
|
|
|
|
rawNames = v
|
|
|
|
|
}
|
|
|
|
|
return mode, roles, rawNames
|
|
|
|
|
}
|
|
|
|
|
return "", nil, nil
|
|
|
|
|
}
|