package integration_test import ( "context" "encoding/json" "fmt" "os" "reflect" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/config" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" dndregister "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/register" genericregister "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/register" "gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript" seriatimregister "gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/register" ) func TestRunnerProcessesSeriatimInputWithProductionDNDNPCPipeline(t *testing.T) { raw := readNPCFixture(t) doc, err := transcript.New().Parse(context.Background(), contracts.ParseRequest{Raw: raw}) if err != nil { t.Fatalf("Parse() error = %v, want nil", err) } registries := productionNPCRegistries(t) configValue := loadNPCPipelineConfig(t) effective, err := configValue.Resolve(config.ResolveInput{PipelineID: "dnd-npcs-fixture", Catalog: moduleCatalog(registries)}) if err != nil { t.Fatalf("Resolve() error = %v, want nil", err) } client := &fakeNPCProductionLLMClient{response: npcProductionResponse{ NPCs: []npcProductionRecord{ { Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, Description: "The first named NPC encountered.", Relationships: []npcProductionRelationship{ {Target: "Hooded Guard", Relationship: "works with"}, }, SourceRefs: []npcProductionSourceRef{{StartUnitID: 1, EndUnitID: 1}}, }, { Name: "The Greencloak", Aliases: []string{"Mira"}, Description: "A later description that must not replace the first.", Relationships: []npcProductionRelationship{ {Target: "Hooded Guard", Relationship: "trusts"}, }, SourceRefs: []npcProductionSourceRef{{StartUnitID: 2, EndUnitID: 2}}, }, { Name: "Hooded Guard", Aliases: []string{}, Description: "An unnamed but distinguishable sentry.", Relationships: []npcProductionRelationship{ {Target: "The Greencloak", Relationship: "reports to"}, }, SourceRefs: []npcProductionSourceRef{{StartUnitID: 3, EndUnitID: 3}}, }, }}, } output, err := runPreparedPipeline(t, registries, effective.ResolvedPipeline, client, pipeline.RunInput{RawInput: raw}) if err != nil { t.Fatalf("Run() error = %v, want nil", err) } if len(output.NormalizeOutputs) != 1 { t.Fatalf("normalize outputs = %d, want one NPC output; rejected=%#v", len(output.NormalizeOutputs), output.Rejected) } serialized := output.NormalizeOutputs[0] if serialized.LaneID != "npcs" || serialized.NormalizerKey != npcs.Key || serialized.Artifact.Schema.ID != npccodec.SchemaID || serialized.Artifact.Schema.Version != npccodec.SchemaVersion { t.Fatalf("serialized output = %#v, want durable NPC lane schema", serialized) } value, err := npccodec.New().Decode(serialized.Artifact.Content) if err != nil { t.Fatalf("Decode(output) error = %v, want durable NPC payload", err) } if len(value.NPCs) != 2 { t.Fatalf("NPC output = %#v, want repeated name consolidated and group/PC omitted", value.NPCs) } first, second := value.NPCs[0], value.NPCs[1] if first.Name != "Mira Thorn" || first.Description != "The first named NPC encountered." || !reflect.DeepEqual(first.Aliases, []string{"The Greencloak", "Mira"}) { t.Fatalf("first NPC = %#v, want consolidated Mira identity", first) } if first.ID != identity.DeriveID(first.Name) || second.Name != "Hooded Guard" || second.ID != identity.DeriveID(second.Name) { t.Fatalf("NPC IDs = %q/%q, want derived IDs", first.ID, second.ID) } if first.Relationships[0].Target != "Hooded Guard" || second.Relationships[0].Target != "Mira Thorn" { t.Fatalf("relationship targets = %q/%q, want canonical target rewrite", first.Relationships[0].Target, second.Relationships[0].Target) } for _, npc := range value.NPCs { for _, ref := range npc.SourceRefs { if ref.SourceID != doc.ID { t.Fatalf("NPC source ref = %#v, want source document %q", ref, doc.ID) } } } if !hasNPCWarning(output.Warnings, "duplicate_npc_collapsed") || !hasNPCWarning(output.Warnings, "relationship_target_canonicalized") { t.Fatalf("warnings = %#v, want consolidation and target warnings", output.Warnings) } if output.Manifest.ValidationStatus != "approved" || len(output.Manifest.ArtifactLanes) != 1 { t.Fatalf("manifest = %#v, want approved NPC lane", output.Manifest) } lane := output.Manifest.ArtifactLanes[0] if lane.ID != "npcs" || lane.Extractor != npcs.Key || lane.Merger != pipeline.DefaultMergeModule || lane.Normalizer != npcs.Key { t.Fatalf("manifest lane = %#v, want NPC production composition", lane) } normalizerMetadata, ok := lane.Metadata["normalizer"].(map[string]any) if !ok || normalizerMetadata["identity_policy"] != identity.Policy || normalizerMetadata["normalization_policy"] != "dnd.npcs.normalize.v1" { t.Fatalf("normalizer metadata = %#v, want identity and normalization policies", lane.Metadata) } var npcOutputFile *contracts.OutputFile for index := range output.OutputFiles { if output.OutputFiles[index].Name == "lanes/npcs.json" { npcOutputFile = &output.OutputFiles[index] break } } if npcOutputFile == nil || npcOutputFile.ContentType != npccodec.MediaType { t.Fatalf("output files = %#v, want JSON NPC lane file", output.OutputFiles) } if len(client.requests) != 1 || client.requests[0].PromptID != npcs.PromptID { t.Fatalf("LLM requests = %#v, want one NPC prompt request", client.requests) } } type npcProductionResponse struct { NPCs []npcProductionRecord `json:"npcs"` } type npcProductionRecord struct { Name string `json:"name"` Aliases []string `json:"aliases"` Description string `json:"description"` Relationships []npcProductionRelationship `json:"relationships"` SourceRefs []npcProductionSourceRef `json:"source_refs"` } type npcProductionRelationship struct { Target string `json:"target"` Relationship string `json:"relationship"` } type npcProductionSourceRef struct { StartUnitID int `json:"start_unit_id"` EndUnitID int `json:"end_unit_id"` } type fakeNPCProductionLLMClient struct { response npcProductionResponse requests []contracts.StructuredCompletionRequest } func (client *fakeNPCProductionLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) { client.requests = append(client.requests, req) content, err := json.Marshal(client.response) if err != nil { return contracts.StructuredCompletionResponse{}, err } if err := json.Unmarshal(content, out); err != nil { return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate NPC structured target: %w", err) } return contracts.StructuredCompletionResponse{Content: content}, nil } func productionNPCRegistries(t *testing.T) pipeline.Registries { t.Helper() registries := pipeline.Registries{ Inputs: pipeline.NewInputAdapterRegistry(), Chunkers: pipeline.NewChunkerRegistry(), ArtifactCodecs: pipeline.NewArtifactCodecRegistry(), Extractors: pipeline.NewExtractorRegistry(), Mergers: pipeline.NewMergerRegistry(), Normalizers: pipeline.NewNormalizerRegistry(), Validators: pipeline.NewValidatorRegistry(), ValidatorChains: pipeline.NewValidatorChainRegistry(), Outputs: pipeline.NewOutputEncoderRegistry(), } assets := llm.NewAssetRegistry() for _, registration := range []struct { name string fn func(pipeline.Registries, *llm.AssetRegistry) error }{ {name: "generic", fn: genericregister.Register}, {name: "seriatim", fn: seriatimregister.Register}, {name: "dnd", fn: dndregister.Register}, } { if err := registration.fn(registries, assets); err != nil { t.Fatalf("register %s modules: %v", registration.name, err) } } return registries } func moduleCatalog(registries pipeline.Registries) pipeline.ModuleCatalog { return pipeline.ModuleCatalog{ Inputs: registries.Inputs, Chunkers: registries.Chunkers, ArtifactCodecs: registries.ArtifactCodecs, Extractors: registries.Extractors, Mergers: registries.Mergers, Normalizers: registries.Normalizers, Validators: registries.Validators, ValidatorChains: registries.ValidatorChains, Outputs: registries.Outputs, } } func loadNPCPipelineConfig(t *testing.T) config.Config { t.Helper() data, err := os.ReadFile("testdata/dnd_npcs_pipeline.yml") if err != nil { t.Fatalf("ReadFile(dnd_npcs_pipeline.yml) error = %v", err) } fileConfig, err := config.ParseFileConfigYAML(data) if err != nil { t.Fatalf("ParseFileConfigYAML() error = %v", err) } cfg := config.Default() if err := cfg.ApplyFileConfig(fileConfig); err != nil { t.Fatalf("ApplyFileConfig() error = %v", err) } return cfg } func readNPCFixture(t *testing.T) []byte { t.Helper() raw, err := os.ReadFile("testdata/seriatim_npc_session.json") if err != nil { t.Fatalf("ReadFile(seriatim_npc_session.json) error = %v", err) } return raw } func hasNPCWarning(warnings []contracts.Warning, reason string) bool { for _, warning := range warnings { if warning.ReasonCode == reason && strings.HasPrefix(warning.Scope, "npcs[") { return true } } return false }