internal/ingestion/pipeline/agent_template_extractor_contract_test.go
Rama main · réplica de lectura del repositorio original.
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)
}