Ctrl K
ComunidadGuías…

internal/ingestion/pipeline/pipeline_params_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package pipeline

import (
	"encoding/json"
	"reflect"
	"strings"
	"testing"

	"ragflow/internal/entity"
)

func buildTestParserConfig(t *testing.T, dsl []byte, overrides map[string]any) entity.JSONMap {
	t.Helper()
	config, err := BuildParserConfig(dsl, overrides)
	if err != nil {
		t.Fatal(err)
	}
	return config
}

// generalDSL returns a minimal DSL resembling the "general" template's component
// structure, with one Parser and one Chunker component.
func generalDSL(t *testing.T) []byte {
	t.Helper()
	dsl := map[string]any{
		"components": map[string]any{
			"Parser:HipSignsRhyme": map[string]any{
				"obj": map[string]any{
					"component_name": "Parser",
					"params": map[string]any{
						"outputs": map[string]any{},
						"pdf":     map[string]any{"parse_method": "DeepDOC", "lang": "en"},
						"docx":    map[string]any{"lang": "en"},
					},
				},
			},
			"GeneralChunker:LegalReadersDecide": map[string]any{
				"obj": map[string]any{
					"component_name": "GeneralChunker",
					"params": map[string]any{
						"outputs":       map[string]any{},
						"chunk_size":    float64(512),
						"chunk_overlap": float64(128),
					},
				},
			},
		},
	}
	raw, err := json.Marshal(dsl)
	if err != nil {
		t.Fatalf("marshal dsl fixture: %v", err)
	}
	return raw
}

func TestBuildParserConfigNormalizesGeneralLegacyDelimiter(t *testing.T) {
	dsl := map[string]any{
		"components": map[string]any{
			"GeneralChunker:Legacy": map[string]any{
				"obj": map[string]any{
					"component_name": "GeneralChunker",
					"params": map[string]any{
						"delimiters": []any{"\n"},
					},
				},
			},
		},
	}
	dslJSON, err := json.Marshal(dsl)
	if err != nil {
		t.Fatalf("marshal dsl: %v", err)
	}
	result := buildTestParserConfig(t, dslJSON, map[string]any{
		"GeneralChunker:Legacy": map[string]any{
			"delimiter": "\n!?;。;!?",
		},
	})
	params, ok := result["GeneralChunker:Legacy"].(map[string]any)
	if !ok {
		t.Fatalf("normalized component params = %#v", result)
	}
	want := []string{"\n", "!", "?", ";", "。", ";", "!", "?"}
	if !reflect.DeepEqual(params["delimiters"], want) {
		t.Fatalf("delimiters = %#v, want %#v", params["delimiters"], want)
	}
	if _, ok := params["delimiter"]; ok {
		t.Fatal("legacy delimiter key survived normalization")
	}
}

func TestCleanComponentParamsNormalizesDelimiterWhenDSLDeclaresOnlyLegacyKey(t *testing.T) {
	dsl := map[string]any{
		"components": map[string]any{
			"GeneralChunker:Custom": map[string]any{
				"obj": map[string]any{
					"component_name": "GeneralChunker",
					"params": map[string]any{
						"delimiter": "\n",
					},
				},
			},
		},
	}
	dslJSON, err := json.Marshal(dsl)
	if err != nil {
		t.Fatalf("marshal DSL: %v", err)
	}
	schemas, err := ExtractAllComponentParams(dslJSON)
	if err != nil {
		t.Fatal(err)
	}
	result := cleanComponentParams(schemas, map[string]any{
		"GeneralChunker:Custom": map[string]any{
			"delimiter": "\n!?",
		},
	})
	params, ok := result["GeneralChunker:Custom"].(map[string]any)
	if !ok {
		t.Fatalf("normalized component params = %#v", result)
	}
	want := []string{"\n", "!", "?"}
	if !reflect.DeepEqual(params["delimiters"], want) {
		t.Fatalf("delimiters = %#v, want %#v", params["delimiters"], want)
	}
	if _, ok := params["delimiter"]; ok {
		t.Fatal("legacy delimiter key survived normalization")
	}
}

func TestBuildParserConfig_DropsLegacyFlatFields(t *testing.T) {
	dslJSON := generalDSL(t)
	raw := map[string]any{
		"chunk_token_num":    256,
		"image_context_size": 10,
	}
	result := buildTestParserConfig(t, dslJSON, raw)
	for key := range raw {
		if _, exists := result[key]; exists {
			t.Errorf("flat field %q survived config building: %v", key, result)
		}
	}
}

func TestBuildParserConfig_DropsUnknownParamKey(t *testing.T) {
	dslJSON := generalDSL(t)
	raw := map[string]any{
		"Parser:HipSignsRhyme": map[string]any{
			"no_such_param": 1,
			"pdf":           map[string]any{"parse_method": "deepdoc"},
		},
	}
	result := buildTestParserConfig(t, dslJSON, raw)
	params := result["Parser:HipSignsRhyme"].(map[string]any)
	if _, ok := params["no_such_param"]; ok {
		t.Error("expected unknown param key to be dropped")
	}
	if _, ok := params["pdf"]; !ok {
		t.Error("expected known param key 'pdf' to be kept")
	}
}

func TestBuildParserConfig_DropsRetiredParserFamilySuffix(t *testing.T) {
	dslJSON := generalDSL(t)
	raw := map[string]any{
		"Parser:HipSignsRhyme": map[string]any{
			"pdf": map[string]any{
				"parse_method": "deepdoc",
				"suffix":       []any{"pdf"},
			},
			"docx": map[string]any{"suffix": []any{"docx"}},
		},
	}
	result := buildTestParserConfig(t, dslJSON, raw)
	params := result["Parser:HipSignsRhyme"].(map[string]any)

	pdf, ok := params["pdf"].(map[string]any)
	if !ok {
		t.Fatalf("expected pdf family, got %T", params["pdf"])
	}
	if _, found := pdf["suffix"]; found {
		t.Errorf("pdf suffix must not survive: %#v", pdf)
	}
	if pdf["parse_method"] != "deepdoc" {
		t.Errorf("pdf parse_method = %v, want deepdoc", pdf["parse_method"])
	}

	docx, ok := params["docx"].(map[string]any)
	if !ok {
		t.Fatalf("expected docx family, got %T", params["docx"])
	}
	if _, found := docx["suffix"]; found {
		t.Errorf("docx suffix must not survive: %#v", docx)
	}

	// The cleanup must not mutate the caller's request map.
	originalPDF := raw["Parser:HipSignsRhyme"].(map[string]any)["pdf"].(map[string]any)
	if _, found := originalPDF["suffix"]; !found {
		t.Error("BuildParserConfig mutated the caller's parser config")
	}
}

// --- BuildParserConfig ---

func TestBuildParserConfig_ShallowMerge_NestedParam(t *testing.T) {
	// A component with a nested-map param "chunk" that has sub-keys.
	dsl := map[string]any{
		"components": map[string]any{
			"GeneralChunker:xyz": map[string]any{
				"obj": map[string]any{
					"component_name": "GeneralChunker",
					"params": map[string]any{
						"outputs": map[string]any{},
						"chunk": map[string]any{
							"size":    float64(512),
							"overlap": float64(128),
						},
					},
				},
			},
		},
	}
	dslJSON, err := json.Marshal(dsl)
	if err != nil {
		t.Fatalf("marshal dsl: %v", err)
	}

	// User only overrides one sub-key of "chunk".
	overrides := map[string]any{
		"GeneralChunker:xyz": map[string]any{
			"chunk": map[string]any{"size": float64(1024)},
		},
	}

	result := buildTestParserConfig(t, dslJSON, overrides)
	chunker, ok := result["GeneralChunker:xyz"].(map[string]any)
	if !ok {
		t.Fatal("expected GeneralChunker:xyz in result")
	}
	chunk, ok := chunker["chunk"].(map[string]any)
	if !ok {
		t.Fatal("expected chunk key in result")
	}
	// After shallow merge: size is overridden, overlap is GONE.
	if chunk["size"] != float64(1024) {
		t.Errorf("expected size=1024 from override, got %v", chunk["size"])
	}
	if _, ok := chunk["overlap"]; ok {
		t.Error("shallow merge: overlap from defaults should NOT be preserved when chunk is fully replaced")
	}
}

// A canvas saved by the Python frontend — and parser_config rows derived
// from it — nest the Parser's per-family setups under a "setups" key. Both
// the DSL defaults and the incoming overrides must be flattened so the
// stored parser_config carries file families at the top level.
func TestBuildParserConfig_FlattensNestedParserSetups(t *testing.T) {
	dsl := map[string]any{
		"components": map[string]any{
			"Parser:HipSignsRhyme": map[string]any{
				"obj": map[string]any{
					"component_name": "Parser",
					"params": map[string]any{
						"outputs": map[string]any{"html": map[string]any{"type": "string"}},
						"setups": map[string]any{
							"pdf":         map[string]any{"parse_method": "deepdoc", "lang": "Chinese"},
							"spreadsheet": map[string]any{"parse_method": "deepdoc"},
						},
					},
				},
			},
		},
	}
	dslJSON, err := json.Marshal(dsl)
	if err != nil {
		t.Fatalf("marshal dsl: %v", err)
	}

	// Legacy nested override, as stored in an upgraded document's parser_config.
	overrides := map[string]any{
		"Parser:HipSignsRhyme": map[string]any{
			"setups": map[string]any{
				"pdf": map[string]any{"parse_method": "vision"},
			},
		},
	}

	result := buildTestParserConfig(t, dslJSON, overrides)
	parser, ok := result["Parser:HipSignsRhyme"].(map[string]any)
	if !ok {
		t.Fatal("expected Parser:HipSignsRhyme in result")
	}
	if _, ok := parser["setups"]; ok {
		t.Error("nested \"setups\" key must be flattened away")
	}
	pdf, ok := parser["pdf"].(map[string]any)
	if !ok {
		t.Fatal("expected flattened pdf family in result")
	}
	if pdf["parse_method"] != "vision" {
		t.Errorf("pdf parse_method = %v, want vision from override", pdf["parse_method"])
	}
	// BuildParserConfig shallow-merges per top-level key: the override's pdf
	// family replaces the defaults' pdf family outright (the semantics locked
	// by TestBuildParserConfig_ShallowMerge_NestedParam).
	if _, ok := pdf["lang"]; ok {
		t.Error("pdf lang should not survive: the override family replaces the default family")
	}
	// Families the override does not touch keep their DSL defaults.
	spreadsheet, ok := parser["spreadsheet"].(map[string]any)
	if !ok {
		t.Fatal("spreadsheet family from DSL defaults missing")
	}
	if spreadsheet["parse_method"] != "deepdoc" {
		t.Errorf("spreadsheet parse_method = %v, want deepdoc from DSL defaults", spreadsheet["parse_method"])
	}
}

// The runtime passes Doc.ParserConfig as override_params, where top-level
// keys from the override replace the DSL-baked ones. A legacy nested Parser
// entry must be lifted BEFORE that merge — otherwise the DSL's flat families
// win over the user's override once FlattenLegacyParserSetups applies its
// top-level-wins rule inside NewParserComponent.
func TestNormalizeParserConfigSetups(t *testing.T) {
	raw := map[string]any{
		"Parser:HipSignsRhyme": map[string]any{
			"setups": map[string]any{
				"pdf": map[string]any{"parse_method": "vision"},
			},
		},
		"Extractor:AutoExtractDefault": map[string]any{"llm_id": "llm-a"},
		"metadata":                     map[string]any{"enabled": true},
	}

	out := NormalizeParserConfigSetups(raw)

	parser, ok := out["Parser:HipSignsRhyme"].(map[string]any)
	if !ok {
		t.Fatal("expected Parser:HipSignsRhyme in result")
	}
	if _, ok := parser["setups"]; ok {
		t.Error("nested \"setups\" key must be flattened away")
	}
	pdf, ok := parser["pdf"].(map[string]any)
	if !ok {
		t.Fatalf("expected flattened pdf family, got %T", parser["pdf"])
	}
	if pdf["parse_method"] != "vision" {
		t.Errorf("pdf parse_method = %v, want vision", pdf["parse_method"])
	}

	// Non-Parser entries and dataset-level keys pass through untouched.
	if got := out["Extractor:AutoExtractDefault"].(map[string]any)["llm_id"]; got != "llm-a" {
		t.Errorf("extractor llm_id = %v, want llm-a (must not be touched)", got)
	}
	if _, ok := out["metadata"].(map[string]any)["enabled"]; !ok {
		t.Error("dataset-level metadata entry lost")
	}

	// Flat entries and nil maps are idempotent and nil-safe.
	flat := NormalizeParserConfigSetups(map[string]any{
		"Parser:Flat": map[string]any{"pdf": map[string]any{"parse_method": "deepdoc"}},
	})
	if _, ok := flat["Parser:Flat"].(map[string]any)["pdf"]; !ok {
		t.Error("flat Parser entry must pass through unchanged")
	}
	if got := NormalizeParserConfigSetups(nil); got != nil {
		t.Errorf("nil input must return nil, got %v", got)
	}
}

// An empty nested "setups" map must still be lifted away: leaving the key in
// place would create a bogus "setups" file family downstream.
func TestNormalizeParserConfigSetups_EmptyNestedMap(t *testing.T) {
	out := NormalizeParserConfigSetups(map[string]any{
		"Parser:HipSignsRhyme": map[string]any{
			"setups": map[string]any{},
		},
	})
	parser, ok := out["Parser:HipSignsRhyme"].(map[string]any)
	if !ok {
		t.Fatal("expected Parser:HipSignsRhyme in result")
	}
	if _, ok := parser["setups"]; ok {
		t.Error("empty nested \"setups\" key must be removed")
	}
}

func TestBuildParserConfig_ScalarOverridePreservesOtherDefaults(t *testing.T) {
	dslJSON := generalDSL(t)
	overrides := map[string]any{
		"GeneralChunker:LegalReadersDecide": map[string]any{
			"chunk_size": float64(1024),
		},
	}
	result := buildTestParserConfig(t, dslJSON, overrides)
	chunker, ok := result["GeneralChunker:LegalReadersDecide"].(map[string]any)
	if !ok {
		t.Fatal("expected GeneralChunker:LegalReadersDecide in result")
	}
	if chunker["chunk_size"] != float64(1024) {
		t.Errorf("expected chunk_size=1024, got %v", chunker["chunk_size"])
	}
	// chunk_overlap should be preserved from DSL defaults since it wasn't overridden.
	if chunker["chunk_overlap"] != float64(128) {
		t.Errorf("expected chunk_overlap=128 preserved from defaults, got %v", chunker["chunk_overlap"])
	}
}

func TestBuildParserConfig_UnknownCPNIDNotPresentInResult(t *testing.T) {
	dslJSON := generalDSL(t)
	overrides := map[string]any{
		"Parser:Unknown": map[string]any{"chunk_size": float64(256)},
	}
	result := buildTestParserConfig(t, dslJSON, overrides)
	// Unknown cpnID should be dropped by BuildParserConfig; the result should
	// still contain the DSL-defined components with their defaults.
	if _, ok := result["Parser:Unknown"]; ok {
		t.Error("expected unknown cpnID to be absent from result")
	}
	if _, ok := result["Parser:HipSignsRhyme"]; !ok {
		t.Error("expected valid component from DSL to be present")
	}
}

func TestBuildParserConfigRejectsInvalidDSL(t *testing.T) {
	for _, dsl := range []string{"not json", `{}`, `{"components":{"Extractor:first":{},"Extractor:second":{}}}`} {
		result, err := BuildParserConfig([]byte(dsl), map[string]any{"key": "val"})
		if err == nil || result != nil {
			t.Fatalf("invalid DSL must return an error without config: config=%#v err=%v", result, err)
		}
	}
}

func TestBuildParserConfig_AllComponentsPresent(t *testing.T) {
	dslJSON := generalDSL(t)
	result := buildTestParserConfig(t, dslJSON, nil)
	// Both components from the DSL fixture should be present.
	if _, ok := result["Parser:HipSignsRhyme"]; !ok {
		t.Error("expected Parser:HipSignsRhyme")
	}
	if _, ok := result["GeneralChunker:LegalReadersDecide"]; !ok {
		t.Error("expected GeneralChunker:LegalReadersDecide")
	}
}

func TestBuildParserConfig_BuiltinExtractorKeepsTagFileID(t *testing.T) {
	registry, err := DefaultRegistry()
	if err != nil {
		t.Fatalf("DefaultRegistry: %v", err)
	}
	checked := 0
	for _, ref := range registry.Refs() {
		tpl, ok := registry.Get(ref)
		if !ok {
			t.Fatalf("registry.Get(%q) failed", ref)
		}
		dslJSON, err := json.Marshal(tpl.DSL)
		if err != nil {
			t.Fatalf("marshal DSL %q: %v", ref, err)
		}
		schemas, err := ExtractAllComponentParams(dslJSON)
		if err != nil {
			t.Fatalf("ExtractAllComponentParams %q: %v", ref, err)
		}
		for _, s := range schemas {
			if s.ComponentName != "Extractor" {
				continue
			}
			checked++
			overrides := map[string]any{
				s.CpnID: map[string]any{
					"tags": map[string]any{"top_n": 5, "tag_file_id": "file-123"},
				},
			}
			result := buildTestParserConfig(t, dslJSON, overrides)
			params, ok := result[s.CpnID].(map[string]any)
			if !ok {
				t.Fatalf("template %q: expected component %q in result", ref, s.CpnID)
			}
			var tagFileID any
			if tags, ok := params["tags"].(map[string]any); ok {
				tagFileID = tags["tag_file_id"]
			}
			if tagFileID != "file-123" {
				t.Errorf("template %q: expected tag_file_id to survive BuildParserConfig, got %v", ref, tagFileID)
			}
		}
	}
	if checked == 0 {
		t.Fatal("expected at least one builtin template with an Extractor component")
	}
}

// --- ComponentParamsDefaults ---

func TestComponentParamsDefaults_Basic(t *testing.T) {
	dslJSON := generalDSL(t)
	result, err := ComponentParamsDefaults(dslJSON)
	if err != nil {
		t.Fatalf("ComponentParamsDefaults: %v", err)
	}
	if len(result) == 0 {
		t.Fatal("expected non-empty result")
	}
	// outputs should be stripped.
	parser := result["Parser:HipSignsRhyme"]
	if _, ok := parser["outputs"]; ok {
		t.Error("expected outputs to be stripped")
	}
	if _, ok := parser["pdf"]; !ok {
		t.Error("expected pdf to be present")
	}
	chunker := result["GeneralChunker:LegalReadersDecide"]
	if chunker["chunk_size"] != float64(512) {
		t.Errorf("expected chunk_size=512, got %v", chunker["chunk_size"])
	}
}

func TestComponentParamsDefaults_InvalidJSON(t *testing.T) {
	_, err := ComponentParamsDefaults([]byte("not json"))
	if err == nil {
		t.Error("expected error for invalid JSON")
	}
}

func TestComponentParamsDefaults_ResultIsMutable(t *testing.T) {
	// Verify the returned map is a deep copy, not a reference to shared state.
	dslJSON := generalDSL(t)
	result, err := ComponentParamsDefaults(dslJSON)
	if err != nil {
		t.Fatalf("ComponentParamsDefaults: %v", err)
	}
	// Mutate the result.
	parser := result["Parser:HipSignsRhyme"]
	delete(parser, "pdf")
	// Re-read: the second call should return a fresh copy unaffected by the mutation.
	result2, _ := ComponentParamsDefaults(dslJSON)
	parser2 := result2["Parser:HipSignsRhyme"]
	if _, ok := parser2["pdf"]; !ok {
		t.Error("expected result to be independent copy (pdf preserved)")
	}
}

// ensure entity.JSONMap is used (import used).
var _ entity.JSONMap

func TestBuildParserConfig_BuiltinExtractorKeepsBuiltInMetadata(t *testing.T) {
	registry, err := DefaultRegistry()
	if err != nil {
		t.Fatalf("DefaultRegistry: %v", err)
	}
	checked := 0
	for _, ref := range registry.Refs() {
		tpl, ok := registry.Get(ref)
		if !ok {
			t.Fatalf("registry.Get(%q) failed", ref)
		}
		dslJSON, err := json.Marshal(tpl.DSL)
		if err != nil {
			t.Fatalf("marshal DSL %q: %v", ref, err)
		}
		schemas, err := ExtractAllComponentParams(dslJSON)
		if err != nil {
			t.Fatalf("ExtractAllComponentParams %q: %v", ref, err)
		}
		for _, s := range schemas {
			if s.ComponentName != "Extractor" {
				continue
			}
			checked++
			overrides := map[string]any{
				s.CpnID: map[string]any{
					"metadata": map[string]any{
						"enabled": true,
						"built_in_metadata": []any{
							map[string]any{"key": "update_time", "type": "time"},
						},
					},
				},
			}
			result := buildTestParserConfig(t, dslJSON, overrides)
			params, ok := result[s.CpnID].(map[string]any)
			if !ok {
				t.Fatalf("template %q: expected component %q in result", ref, s.CpnID)
			}
			if _, ok := params["metadata"]; !ok {
				t.Errorf("template %q: metadata dropped by BuildParserConfig", ref)
			}
		}
	}
	if checked == 0 {
		t.Fatal("expected at least one builtin template with an Extractor component")
	}
}

func TestApplyParentChildChunkerConfig(t *testing.T) {
	config := entity.JSONMap{
		"GeneralChunker:one": map[string]interface{}{"chunk_token_num": 256, "children_delimiters": []string{"wrong"}, "parent_child": map[string]interface{}{"use_parent_child": true, "children_delimiter": "|"}},
		"TokenChunker:two":   map[string]interface{}{"chunk_token_num": 128, "children_delimiters": []string{"wrong"}, "parent_child": map[string]interface{}{"use_parent_child": true, "children_delimiter": "|"}},
		"Extractor:three":    map[string]interface{}{"llm_id": "llm-1"},
	}

	ApplyParentChildChunkerConfig(config, map[string]interface{}{})

	for _, componentID := range []string{"GeneralChunker:one", "TokenChunker:two"} {
		params := config[componentID].(map[string]interface{})
		got, ok := params["children_delimiters"].([]string)
		if !ok || len(got) != 1 || got[0] != "|" {
			t.Fatalf("%s children_delimiters = %#v, want [|]", componentID, params["children_delimiters"])
		}
	}
	if _, ok := config["Extractor:three"].(map[string]interface{})["children_delimiters"]; ok {
		t.Fatal("non-chunker component was modified")
	}
}

func TestBuildParserConfig_KeepsModularExtractorParams(t *testing.T) {
	// Builtin template with empty Extractor params: {}
	dsl := map[string]any{
		"components": map[string]any{
			"Extractor:AutoExtractDefault": map[string]any{
				"obj": map[string]any{
					"component_name": "Extractor",
					"params":         map[string]any{},
				},
			},
		},
	}
	dslJSON, err := json.Marshal(dsl)
	if err != nil {
		t.Fatalf("marshal dsl: %v", err)
	}

	overrides := map[string]any{
		"Extractor:AutoExtractDefault": map[string]any{
			"summary": map[string]any{
				"enabled":       true,
				"system_prompt": "summary prompt",
			},
			"metadata": map[string]any{
				"enabled": true,
				"metadata": []any{
					map[string]any{"key": "category", "type": "string"},
				},
			},
			"keywords": map[string]any{
				"top_n": 5,
			},
			"llm_id":         "gpt-4",
			"temperature":    0.7,
			"enable_summary": 1, // legacy flat param should be dropped
		},
	}

	result := buildTestParserConfig(t, dslJSON, overrides)
	ext, ok := result["Extractor:AutoExtractDefault"].(map[string]any)
	if !ok {
		t.Fatalf("expected Extractor:AutoExtractDefault in result, got: %v", result)
	}

	if sum, ok := ext["summary"].(map[string]any); !ok || sum["enabled"] != true {
		t.Errorf("expected summary.enabled == true, got: %v", ext["summary"])
	}
	if meta, ok := ext["metadata"].(map[string]any); !ok || meta["enabled"] != true {
		t.Errorf("expected metadata.enabled == true, got: %v", ext["metadata"])
	}
	if kw, ok := ext["keywords"].(map[string]any); !ok || kw["top_n"] != 5 {
		t.Errorf("expected keywords.top_n == 5, got: %v", ext["keywords"])
	}
	if ext["llm_id"] != "gpt-4" {
		t.Errorf("expected llm_id == gpt-4, got: %v", ext["llm_id"])
	}
	if _, ok := ext["enable_summary"]; ok {
		t.Errorf("expected legacy flat field enable_summary to be dropped, got: %v", ext["enable_summary"])
	}
}

func TestBuildParserConfig_ExtractorBuiltInMetadataRetained(t *testing.T) {
	dslJSON := []byte(`{
		"components": {
			"Extractor:AutoExtractDefault": {
				"obj": {
					"component_name": "Extractor",
					"params": {
						"outputs": {},
						"metadata": {}
					}
				}
			}
		}
	}`)

	rawConfig := map[string]any{
		"Extractor:AutoExtractDefault": map[string]any{
			"metadata": map[string]any{
				"enabled": true,
				"metadata": []map[string]any{
					{"key": "custom_field", "type": "string"},
				},
				"built_in_metadata": []map[string]any{
					{"key": "doc_name", "type": "string"},
				},
			},
		},
	}

	result := buildTestParserConfig(t, dslJSON, rawConfig)
	ext, ok := result["Extractor:AutoExtractDefault"].(map[string]any)
	if !ok {
		t.Fatalf("expected Extractor:AutoExtractDefault in result, got: %v", result)
	}

	meta, ok := ext["metadata"].(map[string]any)
	if !ok {
		t.Fatalf("expected metadata object, got: %#v", ext["metadata"])
	}
	if meta["enabled"] != true {
		t.Errorf("expected enabled == true, got: %#v", meta["enabled"])
	}
	mFields, ok := meta["metadata"].([]map[string]any)
	if !ok || len(mFields) != 1 {
		t.Errorf("expected 1 metadata field, got: %#v", meta["metadata"])
	}
	bFields, ok := meta["built_in_metadata"].([]map[string]any)
	if !ok || len(bFields) != 1 {
		t.Errorf("expected 1 built_in_metadata field, got: %#v", meta["built_in_metadata"])
	}
}

func TestNormalizeExtractorParams_TableDriven(t *testing.T) {
	tests := []struct {
		name     string
		input    map[string]any
		validate func(t *testing.T, out map[string]any)
	}{
		{
			name:  "nil input returns empty map",
			input: nil,
			validate: func(t *testing.T, out map[string]any) {
				if len(out) != 0 {
					t.Errorf("expected empty map for nil input, got %#v", out)
				}
			},
		},
		{
			name: "modular keywords preserved",
			input: map[string]any{
				"keywords": map[string]any{
					"top_n":         5,
					"system_prompt": "custom kw prompt",
				},
			},
			validate: func(t *testing.T, out map[string]any) {
				kw, ok := out["keywords"].(map[string]any)
				if !ok || kw["top_n"] != 5 || kw["system_prompt"] != "custom kw prompt" {
					t.Errorf("keywords mismatch: %#v", out["keywords"])
				}
			},
		},
		{
			name: "modular questions preserved",
			input: map[string]any{
				"questions": map[string]any{
					"top_n":         3,
					"system_prompt": "custom q prompt",
				},
			},
			validate: func(t *testing.T, out map[string]any) {
				q, ok := out["questions"].(map[string]any)
				if !ok || q["top_n"] != 3 || q["system_prompt"] != "custom q prompt" {
					t.Errorf("questions mismatch: %#v", out["questions"])
				}
			},
		},
		{
			name: "modular tags preserved",
			input: map[string]any{
				"tags": map[string]any{
					"top_n":       7,
					"tag_file_id": "file-123",
				},
			},
			validate: func(t *testing.T, out map[string]any) {
				tag, ok := out["tags"].(map[string]any)
				if !ok || tag["top_n"] != 7 || tag["tag_file_id"] != "file-123" {
					t.Errorf("tags mismatch: %#v", out["tags"])
				}
			},
		},
		{
			name: "modular summary preserved",
			input: map[string]any{
				"summary": map[string]any{
					"enabled":       true,
					"system_prompt": "summary prompt",
				},
			},
			validate: func(t *testing.T, out map[string]any) {
				sum, ok := out["summary"].(map[string]any)
				if !ok || sum["enabled"] != true || sum["system_prompt"] != "summary prompt" {
					t.Errorf("summary mismatch: %#v", out["summary"])
				}
			},
		},
		{
			name: "modular metadata preserved",
			input: map[string]any{
				"metadata": map[string]any{
					"enabled": true,
					"metadata": []map[string]any{
						{"key": "cat", "type": "string"},
					},
					"built_in_metadata": []map[string]any{
						{"key": "file_name", "type": "string"},
					},
				},
			},
			validate: func(t *testing.T, out map[string]any) {
				meta, ok := out["metadata"].(map[string]any)
				if !ok || meta["enabled"] != true {
					t.Fatalf("expected enabled == true, got %#v", out["metadata"])
				}
				mList, ok := meta["metadata"].([]map[string]any)
				if !ok || len(mList) != 1 {
					t.Errorf("metadata slice mismatch: %#v", meta["metadata"])
				}
				bList, ok := meta["built_in_metadata"].([]map[string]any)
				if !ok || len(bList) != 1 {
					t.Errorf("built_in_metadata slice mismatch: %#v", meta["built_in_metadata"])
				}
			},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			out := NormalizeExtractorParams(tc.input)
			tc.validate(t, out)
		})
	}
}

// compilerDSL mirrors the compiler pipeline template's component structure.
func compilerDSL(t *testing.T) []byte {
	t.Helper()
	dsl := map[string]any{
		"components": map[string]any{
			"Compiler:NewBoxesLove": map[string]any{
				"obj": map[string]any{
					"component_name": "Compiler",
					"params": map[string]any{
						"outputs":                       map[string]any{},
						"compilation_template_group_id": "",
						"llm_id":                        "",
						"plan":                          false,
					},
				},
			},
		},
	}
	raw, err := json.Marshal(dsl)
	if err != nil {
		t.Fatalf("marshal dsl fixture: %v", err)
	}
	return raw
}

func TestBuildParserConfig_CompilerKeepsLLMRuntimeParams(t *testing.T) {
	dslJSON := compilerDSL(t)
	raw := map[string]any{
		"Compiler:NewBoxesLove": map[string]any{
			"compilation_template_group_id": "group-1",
			"llm_id":                        "model-1",
			"temperature":                   0.7,
			"temperatureEnabled":            true,
			"top_p":                         0.9,
			"topPEnabled":                   true,
			"presence_penalty":              0.2,
			"presencePenaltyEnabled":        true,
			"frequency_penalty":             0.3,
			"frequencyPenaltyEnabled":       true,
			"max_tokens":                    1024.0,
			"maxTokensEnabled":              true,
			"parameter":                     "Custom",
			"thinking":                      "enabled",
			"mode":                          "structure",
		},
	}
	result := buildTestParserConfig(t, dslJSON, raw)
	params, ok := result["Compiler:NewBoxesLove"].(map[string]any)
	if !ok {
		t.Fatalf("expected Compiler params to survive, got %#v", result)
	}
	for _, key := range []string{
		"compilation_template_group_id", "llm_id",
		"temperature", "temperatureEnabled",
		"top_p", "topPEnabled",
		"presence_penalty", "presencePenaltyEnabled",
		"frequency_penalty", "frequencyPenaltyEnabled",
		"max_tokens", "maxTokensEnabled",
		"parameter", "thinking",
	} {
		if _, ok := params[key]; !ok {
			t.Errorf("expected LLM runtime param %q to be kept, got %#v", key, params)
		}
	}
	if _, ok := params["mode"]; ok {
		t.Error("expected unknown param key 'mode' to be dropped")
	}
	for _, key := range []string{"keywords", "questions", "tags", "summary", "metadata"} {
		if _, ok := params[key]; ok {
			t.Errorf("expected no extractor group %q on Compiler params", key)
		}
	}
}

func TestBuildParserConfig_CompilerRuntimeParamsSurvive(t *testing.T) {
	dslJSON := compilerDSL(t)
	raw := map[string]any{
		"Compiler:NewBoxesLove": map[string]any{
			"compilation_template_group_id": "group-1",
			"llm_id":                        "model-1",
			"temperature":                   0.7,
			"temperatureEnabled":            true,
			"thinking":                      "disabled",
		},
	}
	result := buildTestParserConfig(t, dslJSON, raw)
	params, ok := result["Compiler:NewBoxesLove"].(map[string]any)
	if !ok {
		t.Fatalf("expected Compiler entry in built parser_config, got %#v", result)
	}
	if params["compilation_template_group_id"] != "group-1" || params["llm_id"] != "model-1" {
		t.Errorf("compiler overrides mismatch: %#v", params)
	}
	if params["temperature"] != 0.7 || params["temperatureEnabled"] != true {
		t.Errorf("expected LLM runtime overrides to survive, got %#v", params)
	}
	if params["thinking"] != "disabled" {
		t.Errorf("expected thinking override to survive, got %#v", params)
	}
	if plan, ok := params["plan"].(bool); !ok || plan {
		t.Errorf("expected DSL default plan=false to be baked in, got %#v", params["plan"])
	}
}

// --- componentParamSchemaKeys (schema-derived component param whitelist) ---

// titleFamilyDSL mirrors a canvas snapshot saved before chunk_token_cap /
// root_chunk_as_heading existed: the title-chunker node bakes only the older
// params.
func titleFamilyDSL(t *testing.T) []byte {
	t.Helper()
	dsl := map[string]any{
		"components": map[string]any{
			"TitleChunker:StaleSnapshot": map[string]any{
				"obj": map[string]any{
					"component_name": "TitleChunker",
					"params": map[string]any{
						"outputs":   map[string]any{},
						"method":    "hierarchy",
						"hierarchy": float64(3),
						"levels":    []any{[]any{"^#[^#]"}},
					},
				},
			},
		},
	}
	raw, err := json.Marshal(dsl)
	if err != nil {
		t.Fatalf("marshal dsl fixture: %v", err)
	}
	return raw
}

func TestBuildParserConfig_TitleFamilyKeepsSchemaParamsWithoutBakedKeys(t *testing.T) {
	dslJSON := titleFamilyDSL(t)
	raw := map[string]any{
		"TitleChunker:StaleSnapshot": map[string]any{
			"chunk_token_cap":       256,
			"root_chunk_as_heading": true,
		},
	}
	result := buildTestParserConfig(t, dslJSON, raw)
	params, ok := result["TitleChunker:StaleSnapshot"].(map[string]any)
	if !ok {
		t.Fatalf("expected TitleChunker params to survive, got %#v", result)
	}
	if _, ok := params["chunk_token_cap"]; !ok {
		t.Errorf("chunk_token_cap dropped although the component schema declares it: %#v", params)
	}
	if _, ok := params["root_chunk_as_heading"]; !ok {
		t.Errorf("root_chunk_as_heading dropped although the component schema declares it: %#v", params)
	}
}

// TestBuildParserConfig_KeepsTitleChunkerPerMethodKeys locks the persistence
// contract for the title-chunker family: the frontend stores each method's
// rules, hierarchy value and token cap under its own key so the dataset
// config keeps Hierarchy and Group independent; BuildParserConfig must not
// drop them.
func TestBuildParserConfig_KeepsTitleChunkerPerMethodKeys(t *testing.T) {
	dslJSON := titleFamilyDSL(t)
	overrides := map[string]any{
		"TitleChunker:StaleSnapshot": map[string]any{
			"method":                 "group",
			"levels":                 []any{[]any{"第[0-9]+章"}},
			"hierarchy":              0,
			"hierarchyRules":         []any{map[string]any{"levels": []any{map[string]any{"expression": "^#[^#]"}}}},
			"groupRules":             []any{map[string]any{"levels": []any{map[string]any{"expression": "第[0-9]+章"}}}},
			"hierarchyHierarchy":     "3",
			"hierarchyGroup":         "2",
			"hierarchyChunkTokenCap": 1024,
			"groupChunkTokenCap":     2048,
		},
	}
	params, ok := buildTestParserConfig(t, dslJSON, overrides)["TitleChunker:StaleSnapshot"].(map[string]any)
	if !ok {
		t.Fatalf("expected TitleChunker params to survive, got %#v", params)
	}
	for _, key := range []string{
		"hierarchyRules", "groupRules", "hierarchyHierarchy", "hierarchyGroup",
		"hierarchyChunkTokenCap", "groupChunkTokenCap",
	} {
		if _, ok := params[key]; !ok {
			t.Errorf("per-method persistence key %q dropped by BuildParserConfig: %#v", key, params)
		}
	}
}

// TestBuildParserConfig_TitleFamilyParamsSurviveBuiltinTemplates locks the
// dataset/document save path for the title-chunker family: both params must
// survive BuildParserConfig for every builtin template that carries a
// TitleChunker or ManualChunker node, none of which bake the two keys in their
// DSL params.
func TestBuildParserConfig_TitleFamilyParamsSurviveBuiltinTemplates(t *testing.T) {
	registry, err := DefaultRegistry()
	if err != nil {
		t.Fatalf("DefaultRegistry: %v", err)
	}
	tested := 0
	for _, ref := range registry.Refs() {
		tpl, ok := registry.Get(ref)
		if !ok {
			continue
		}
		dslJSON, err := json.Marshal(tpl.DSL)
		if err != nil {
			t.Fatalf("marshal DSL %q: %v", ref, err)
		}
		schemas, err := ExtractAllComponentParams(dslJSON)
		if err != nil {
			t.Fatalf("ExtractAllComponentParams %q: %v", ref, err)
		}
		for _, s := range schemas {
			if s.ComponentName != "TitleChunker" && s.ComponentName != "ManualChunker" {
				continue
			}
			tested++
			overrides := map[string]interface{}{
				s.CpnID: map[string]any{
					"chunk_token_cap":       256,
					"root_chunk_as_heading": true,
				},
			}
			params, ok := buildTestParserConfig(t, dslJSON, overrides)[s.CpnID].(map[string]any)
			if !ok {
				t.Fatalf("template %q: component %q missing from built parser_config", ref, s.CpnID)
			}
			if !reflect.DeepEqual(params["chunk_token_cap"], 256) {
				t.Errorf("template %q %s: chunk_token_cap = %#v, want 256", ref, s.CpnID, params["chunk_token_cap"])
			}
			if !reflect.DeepEqual(params["root_chunk_as_heading"], true) {
				t.Errorf("template %q %s: root_chunk_as_heading = %#v, want true", ref, s.CpnID, params["root_chunk_as_heading"])
			}
		}
	}
	if tested == 0 {
		t.Fatal("expected at least one builtin template with a TitleChunker/ManualChunker component")
	}
}

// operatorFormParamKeys mirrors the params the dataset/document pipeline
// operator forms submit (transformFormConfigToApi in
// web/src/utils/pipeline-operator.ts). Every key listed for a component must be
// accepted by BuildParserConfig for that component in every builtin
// template; otherwise a user edit is silently dropped.
var operatorFormParamKeys = map[string][]string{
	"TitleChunker": {
		"method", "levels", "hierarchy", "include_heading_content",
		"root_chunk_as_heading", "chunk_token_cap", "table_context_size", "image_context_size",
		// Per-method persistence keys: each method's rules, hierarchy value
		// and token cap are stored under their own key so the dataset config
		// keeps them independent of the active method (the runtime reads only
		// levels/hierarchy/chunk_token_cap).
		"hierarchyRules", "groupRules", "hierarchyHierarchy", "hierarchyGroup",
		"hierarchyChunkTokenCap", "groupChunkTokenCap",
	},
	"ManualChunker": {
		"method", "levels", "hierarchy", "root_chunk_as_heading",
		"table_context_size", "image_context_size",
		"hierarchyRules", "groupRules", "hierarchyHierarchy", "hierarchyGroup",
	},
	"TokenChunker": {
		"chunk_token_size", "overlapped_percent", "delimiters", "delimiter_mode",
		"children_delimiters", "enable_children", "table_context_size", "image_context_size",
	},
	"GeneralChunker": {
		"chunk_token_size", "overlapped_percent", "delimiters", "children_delimiters",
		"enable_children", "table_context_size", "image_context_size",
	},
	"Tokenizer": {"search_method", "filename_embd_weight", "fields"},
	"Extractor": {"llm_id", "keywords", "questions", "tags", "summary", "metadata"},
}

func TestBuildParserConfig_AcceptsOperatorFormParams(t *testing.T) {
	registry, err := DefaultRegistry()
	if err != nil {
		t.Fatalf("DefaultRegistry: %v", err)
	}
	checked := 0
	for _, ref := range registry.Refs() {
		tpl, ok := registry.Get(ref)
		if !ok {
			continue
		}
		dslJSON, err := json.Marshal(tpl.DSL)
		if err != nil {
			t.Fatalf("marshal DSL %q: %v", ref, err)
		}
		schemas, err := ExtractAllComponentParams(dslJSON)
		if err != nil {
			t.Fatalf("ExtractAllComponentParams %q: %v", ref, err)
		}
		for _, s := range schemas {
			expected, ok := operatorFormParamKeys[s.ComponentName]
			if !ok {
				continue
			}
			checked++
			params := make(map[string]any, len(expected))
			for _, key := range expected {
				params[key] = "probe"
			}
			cleaned := buildTestParserConfig(t, dslJSON, map[string]interface{}{s.CpnID: params})
			got, ok := cleaned[s.CpnID].(map[string]any)
			if !ok {
				t.Fatalf("template %q: component %q missing from cleaned result", ref, s.CpnID)
			}
			for _, key := range expected {
				if _, ok := got[key]; !ok {
					t.Errorf("template %q component %s: operator-form param %q is silently dropped", ref, s.CpnID, key)
				}
			}
		}
	}
	if checked == 0 {
		t.Fatal("expected at least one builtin template component matched by operatorFormParamKeys")
	}
}

// componentParamWhitelistExemptions lists the builtin template component names
// that intentionally have no entry in componentParamSchemaKeys, with the reason.
// The inventory test below fails on any component that is neither derived nor
// exempt, so a renamed or newly added component cannot silently lose its
// whitelist coverage.
var componentParamWhitelistExemptions = map[string]string{
	"File":           "no user-editable params",
	"Parser":         "DSL file families plus the global vision parameter whitelist",
	"GeneralChunker": "param struct lives in the chunker package without json tags; template keys + the delimiters exception cover it",
	"QAChunker":      "no operator form; param struct is not in the schema package",
	"OneChunker":     "no user-editable params",
	"PageChunker":    "no user-editable params",
	"TableChunker":   "no user-editable params",
	"Extractor":      "covered by the cpnID-prefixed dynamic whitelist",
	"Compiler":       "covered by the cpnID-prefixed dynamic whitelist",
}

// TestBuiltinTemplateComponentsHaveWhitelistCoverage pins the component-name
// inventory of the builtin templates: every name must either derive its param
// whitelist from a schema (componentParamSchemaKeys) or be exempted above. It
// guards the operator-form test against becoming a no-op: that test matches by
// component name, so a rename would otherwise silently skip it.
func TestBuiltinTemplateComponentsHaveWhitelistCoverage(t *testing.T) {
	registry, err := DefaultRegistry()
	if err != nil {
		t.Fatalf("DefaultRegistry: %v", err)
	}
	seen := make(map[string]string)
	for _, ref := range registry.Refs() {
		tpl, ok := registry.Get(ref)
		if !ok {
			continue
		}
		dslJSON, err := json.Marshal(tpl.DSL)
		if err != nil {
			t.Fatalf("marshal DSL %q: %v", ref, err)
		}
		schemas, err := ExtractAllComponentParams(dslJSON)
		if err != nil {
			t.Fatalf("ExtractAllComponentParams %q: %v", ref, err)
		}
		for _, s := range schemas {
			seen[s.ComponentName] = ref
		}
	}
	if len(seen) == 0 {
		t.Fatal("expected builtin templates to contain components")
	}
	for name, ref := range seen {
		if _, ok := componentParamSchemaKeys[strings.ToLower(name)]; ok {
			continue
		}
		if _, ok := componentParamWhitelistExemptions[name]; ok {
			continue
		}
		t.Errorf("component %q (template %q) is neither schema-derived nor exempt: add it to componentParamSchemaKeys or exempt it with a reason", name, ref)
	}
}

// TestBuildParserConfig_TitleFamilyStillDropsUnknownKeys keeps the union
// honest: a key the component schema does not declare is still dropped.
func TestBuildParserConfig_TitleFamilyStillDropsUnknownKeys(t *testing.T) {
	dslJSON := titleFamilyDSL(t)
	raw := map[string]any{
		"TitleChunker:StaleSnapshot": map[string]any{
			"chunk_token_cap_typo": 256,
			"method":               "hierarchy",
		},
	}
	result := buildTestParserConfig(t, dslJSON, raw)
	params, ok := result["TitleChunker:StaleSnapshot"].(map[string]any)
	if !ok {
		t.Fatalf("expected TitleChunker params to survive, got %#v", result)
	}
	if _, ok := params["chunk_token_cap_typo"]; ok {
		t.Errorf("unknown key must still be dropped, got %#v", params)
	}
	if _, ok := params["method"]; !ok {
		t.Errorf("declared key must survive, got %#v", params)
	}
}