Ctrl K
ComunidadGuías…

internal/ingestion/pipeline/agent_template_extractor_contract_test.go

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

Archivo original ↗Preguntar a KiBot
package pipeline

import (
	"encoding/json"
	"os"
	"path/filepath"
	"reflect"
	"testing"
)

// TestAgentTemplateExtractorsUseModularParams guards against #20716: the
// Extractor component only reads the modular sub-configs
// (keywords/questions/tags/summary/metadata) declared by schema.ExtractorParam.
// A template still baking the legacy flat fields (field_name/prompts/
// sys_prompt/tenant_llm_id) ships an Extractor whose prompt configuration is
// silently ignored at runtime.
func TestAgentTemplateExtractorsUseModularParams(t *testing.T) {
	// The agent templates carry a canvas form (data.form) that mirrors the DSL
	// params; the ingestion_pipeline_* templates keep their params only in the
	// DSL component, so only the former must have a matching form.
	dirs := []struct {
		path        string
		requireForm bool
	}{
		{filepath.Join("..", "..", "agent", "templates"), true},
		{filepath.Join("template"), false},
	}
	legacyKeys := []string{"field_name", "prompts", "sys_prompt", "tenant_llm_id"}
	modularKeys := []string{"keywords", "questions", "tags", "summary", "metadata"}

	// The agent templates preset an active sub-task; the ingestion_pipeline_*
	// templates ship every sub-task disabled and let the operator enable them
	// on the canvas, so only the former must be active out of the box.
	mustBeActive := map[string]bool{
		"advanced_ingestion_pipeline.json": true,
		"chunk_summary.json":               true,
		"title_chunker.json":               true,
	}

	// subTaskActive mirrors the runtime gates in internal/ingestion/component:
	// summary/metadata run when enabled is true; keywords/questions/tags follow
	// resolveExtractToggle — an explicit "enabled" key is authoritative, and only
	// a legacy group without it derives its state from top_n > 0.
	subTaskActive := func(name string, v any) bool {
		m, ok := v.(map[string]any)
		if !ok {
			return false
		}
		switch name {
		case "keywords", "questions", "tags":
			if enabled, ok := m["enabled"]; ok {
				if b, ok := enabled.(bool); ok {
					return b
				}
				n, ok := enabled.(float64)
				return ok && n == 1
			}
			n, ok := m["top_n"].(float64)
			return ok && n > 0
		default:
			e, ok := m["enabled"].(bool)
			return ok && e
		}
	}

	seen := 0
	for _, dir := range dirs {
		files, err := filepath.Glob(filepath.Join(dir.path, "*.json"))
		if err != nil {
			t.Fatalf("glob %s: %v", dir.path, err)
		}
		for _, f := range files {
			raw, err := os.ReadFile(f)
			if err != nil {
				t.Fatalf("read %s: %v", f, err)
			}
			var doc map[string]any
			if err := json.Unmarshal(raw, &doc); err != nil {
				t.Fatalf("unmarshal %s: %v", f, err)
			}
			if doc["canvas_type"] != "Ingestion Pipeline" {
				continue
			}
			base := filepath.Base(f)
			components, _ := doc["dsl"].(map[string]any)["components"].(map[string]any)

			// Canvas nodes mirror the component params in data.form for the
			// agent templates; collect them by id so the two copies can be
			// compared.
			nodeForms := map[string]map[string]any{}
			if nodes, ok := doc["dsl"].(map[string]any)["graph"].(map[string]any)["nodes"].([]any); ok {
				for _, n := range nodes {
					node, _ := n.(map[string]any)
					id, _ := node["id"].(string)
					form, _ := node["data"].(map[string]any)["form"].(map[string]any)
					if id != "" && form != nil {
						nodeForms[id] = form
					}
				}
			}
			for cpnID, comp := range components {
				obj, _ := comp.(map[string]any)["obj"].(map[string]any)
				if obj["component_name"] != "Extractor" {
					continue
				}
				seen++
				params, _ := obj["params"].(map[string]any)

				if form, ok := nodeForms[cpnID]; ok {
					if !reflect.DeepEqual(form, params) {
						t.Errorf("%s: Extractor %s params and canvas data.form diverge; both must be updated together", f, cpnID)
					}
				} else if dir.requireForm {
					t.Errorf("%s: Extractor %s has no matching canvas data.form node to compare against params", f, cpnID)
				}
				for _, k := range legacyKeys {
					if _, found := params[k]; found {
						t.Errorf("%s: Extractor %s still uses legacy param %q; migrate it to the modular format", f, cpnID, k)
					}
				}

				hasModular := false
				activeCount := 0
				for _, k := range modularKeys {
					v, ok := params[k]
					if !ok || v == nil {
						continue
					}
					hasModular = true
					if subTaskActive(k, v) {
						activeCount++
					}
				}
				if !hasModular {
					t.Errorf("%s: Extractor %s configures no modular sub-task (keywords/questions/tags/summary/metadata)", f, cpnID)
				}
				if mustBeActive[base] && activeCount == 0 {
					t.Errorf("%s: Extractor %s has every modular sub-task disabled; the template's purpose requires at least one active", f, cpnID)
				}

				// The params must also survive BuildParserConfig: no
				// dropped keys and any active sub-task left active with its
				// prompt intact.
				dslRaw, _ := json.Marshal(doc["dsl"])
				cleaned := buildTestParserConfig(t, dslRaw, map[string]any{cpnID: params})
				got, _ := cleaned[cpnID].(map[string]any)
				if got == nil {
					t.Errorf("%s: Extractor %s dropped by BuildParserConfig", f, cpnID)
					continue
				}
				for k := range params {
					if _, kept := got[k]; !kept {
						t.Errorf("%s: Extractor %s param %q dropped by BuildParserConfig", f, cpnID, k)
					}
				}
				for _, k := range modularKeys {
					v, ok := params[k]
					if !ok || !subTaskActive(k, v) {
						continue
					}
					if !subTaskActive(k, got[k]) {
						t.Errorf("%s: Extractor %s sub-task %q no longer active after BuildParserConfig: %#v", f, cpnID, k, got[k])
						continue
					}
					if k == "summary" {
						cleanedSummary, _ := got[k].(map[string]any)
						prompt, _ := cleanedSummary["system_prompt"].(string)
						if prompt == "" {
							t.Errorf("%s: Extractor %s summary.system_prompt empty after BuildParserConfig", f, cpnID)
						}
					}
				}
			}
		}
	}
	if seen == 0 {
		t.Fatal("no Ingestion Pipeline templates with an Extractor found; test paths stale?")
	}
	t.Logf("checked %d Extractor components across builtin ingestion templates", seen)
}