From fb043325e13b8ed3decd76a2cd294b2ef02dc520 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 21 Jul 2026 02:50:37 +0000 Subject: [PATCH] Add production NPC pipeline composition --- docs/config.md | 1 + examples/dnd-npcs.config.yml | 21 ++ internal/cli/dnd_npc_contract_test.go | 132 ++++++++++ internal/cli/production_contract_test.go | 1 + .../modules/dnd/normalize/npcs/normalizer.go | 3 +- .../dnd/normalize/npcs/normalizer_test.go | 15 ++ internal/modules/dnd/register/register.go | 71 +++++ .../modules/dnd/register/register_test.go | 73 +++++- .../integration/dnd_npcs_runner_test.go | 242 ++++++++++++++++++ .../testdata/dnd_npcs_pipeline.yml | 20 ++ .../testdata/seriatim_npc_session.json | 43 ++++ 11 files changed, 618 insertions(+), 4 deletions(-) create mode 100644 examples/dnd-npcs.config.yml create mode 100644 internal/cli/dnd_npc_contract_test.go create mode 100644 internal/modules/integration/dnd_npcs_runner_test.go create mode 100644 internal/modules/integration/testdata/dnd_npcs_pipeline.yml create mode 100644 internal/modules/integration/testdata/seriatim_npc_session.json diff --git a/docs/config.md b/docs/config.md index 7093bd6..27378ff 100644 --- a/docs/config.md +++ b/docs/config.md @@ -21,6 +21,7 @@ The explicit-path option is defined in the [CLI reference](cli.md). - [Minimal D&D spell configuration](../examples/dnd-spells.config.yml) - [Production-oriented D&D spell configuration](../examples/dnd-spells-production.config.yml) +- [D&D NPC configuration](../examples/dnd-npcs.config.yml) Both are complete version 3 files. The fragments below illustrate individual fields and are not alternate complete configurations. diff --git a/examples/dnd-npcs.config.yml b/examples/dnd-npcs.config.yml new file mode 100644 index 0000000..279b14e --- /dev/null +++ b/examples/dnd-npcs.config.yml @@ -0,0 +1,21 @@ +version: 3 +output: + directory: ./notarius-output +cache: + chunk_plans: + mode: bypass + checkpoints: + enabled: false + directory: "" +debug: + directory: ./notarius-debug +pipelines: + dnd-session: + input: seriatim + chunk: generic + artifacts: + npcs: + extract: + module: dnd/npcs + retries: 2 + normalize: dnd/npcs diff --git a/internal/cli/dnd_npc_contract_test.go b/internal/cli/dnd_npc_contract_test.go new file mode 100644 index 0000000..ec7defb --- /dev/null +++ b/internal/cli/dnd_npc_contract_test.go @@ -0,0 +1,132 @@ +package cli + +import ( + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/config" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" + npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" +) + +func TestProductionNPCConfigurationResolvesTypedLane(t *testing.T) { + components := productionTestComponents(t) + catalog := catalogFromRegistries(components.registries) + configPath := repositoryPath("examples", "dnd-npcs.config.yml") + cfg := loadMaintainedExample(t, configPath) + effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-session", Catalog: catalog}) + if err != nil { + t.Fatalf("Resolve() error = %v, want nil", err) + } + if effective.ResolvedPipeline.Chunk.Module != pipeline.DefaultChunkModule { + t.Fatalf("chunk module = %q, want %q", effective.ResolvedPipeline.Chunk.Module, pipeline.DefaultChunkModule) + } + if len(effective.ResolvedPipeline.ArtifactLanes) != 1 { + t.Fatalf("artifact lanes = %#v, want one NPC lane", effective.ResolvedPipeline.ArtifactLanes) + } + lane := effective.ResolvedPipeline.ArtifactLanes[0] + if lane.ID != "npcs" || lane.ArtifactKind != dnd.NPCListKind || lane.Extract.Module != npcextract.Key || lane.Extract.Retries != 2 || lane.Merge.Module != pipeline.DefaultMergeModule || lane.Normalize.Module != npcnormalize.Key { + t.Fatalf("resolved NPC lane = %#v, want typed production composition", lane) + } + if len(lane.ExtractReferences.Bindings) != 0 || len(lane.NormalizeReferences.Bindings) != 0 { + t.Fatalf("unbound NPC references = %#v / %#v, want none", lane.ExtractReferences, lane.NormalizeReferences) + } + + extractSpec, ok := catalog.Extractors.Spec(npcextract.Key) + if !ok || !reflect.DeepEqual(extractSpec.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(extractSpec.Provides, []string{"dnd.npcs"}) { + t.Fatalf("NPC extractor spec = %#v, want source and artifact capabilities", extractSpec) + } + mergeSpec, ok := catalog.Mergers.SpecForArtifact(pipeline.DefaultMergeModule, dnd.NPCListKind) + if !ok || !reflect.DeepEqual(mergeSpec.Provides, []string{"merged"}) { + t.Fatalf("NPC merger spec = %#v, want merged capability", mergeSpec) + } + normalizeSpec, ok := catalog.Normalizers.SpecForArtifact(npcnormalize.Key, dnd.NPCListKind) + if !ok || !reflect.DeepEqual(normalizeSpec.Requires, []string{"merged"}) || !reflect.DeepEqual(normalizeSpec.Provides, []string{"normalized"}) { + t.Fatalf("NPC normalizer spec = %#v, want merged/normalized capabilities", normalizeSpec) + } + + wantExtractChain := []pipeline.ModuleBinding{ + pipeline.Binding("generic/valid_json"), + pipeline.Binding("generic/valid_json_schema"), + pipeline.Binding("extract/dnd/npcs/shape"), + pipeline.Binding("extract/dnd/npcs/source_refs"), + pipeline.Binding("extract/dnd/npcs/source_relatedness"), + } + wantNormalizeChain := []pipeline.ModuleBinding{ + pipeline.Binding("generic/valid_json"), + pipeline.Binding("generic/valid_json_schema"), + pipeline.Binding("extract/dnd/npcs/shape"), + pipeline.Binding("normalize/dnd/npcs/identity"), + pipeline.Binding("extract/dnd/npcs/source_refs"), + pipeline.Binding("extract/dnd/npcs/source_relatedness"), + } + if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, npcextract.Key); !reflect.DeepEqual(got, wantExtractChain) { + t.Fatalf("NPC extract chain = %#v, want %#v", got, wantExtractChain) + } + if got := validatorChain(effective.ResolvedPipeline, pipeline.StageNormalize, npcnormalize.Key); !reflect.DeepEqual(got, wantNormalizeChain) { + t.Fatalf("NPC normalize chain = %#v, want %#v", got, wantNormalizeChain) + } + if got := validatorChain(effective.ResolvedPipeline, pipeline.StageMerge, pipeline.DefaultMergeModule); len(got) != 0 { + t.Fatalf("NPC merge chain = %#v, want empty", got) + } +} + +func TestProductionNPCConfigurationValidatesOptionsReferencesAndPlacement(t *testing.T) { + components := productionTestComponents(t) + configPath := repositoryPath("examples", "dnd-npcs.config.yml") + resolve := func(mutate func(*pipeline.PipelineProfile)) error { + cfg := loadMaintainedExample(t, configPath) + profile := cfg.Pipelines["dnd-session"] + mutate(&profile) + cfg.Pipelines["dnd-session"] = profile + _, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-session", Catalog: catalogFromRegistries(components.registries)}) + return err + } + + if err := resolve(func(profile *pipeline.PipelineProfile) { + lane := profile.Artifacts["npcs"] + lane.Extract.Options = map[string]any{"unexpected": true} + profile.Artifacts["npcs"] = lane + }); err == nil || !strings.Contains(err.Error(), "unknown option") { + t.Fatalf("unknown extractor option error = %v, want strict option rejection", err) + } + if err := resolve(func(profile *pipeline.PipelineProfile) { + lane := profile.Artifacts["npcs"] + lane.Normalize.Options = map[string]any{"unexpected": true} + profile.Artifacts["npcs"] = lane + }); err == nil || !strings.Contains(err.Error(), "unknown option") { + t.Fatalf("unknown normalizer option error = %v, want strict option rejection", err) + } + if err := resolve(func(profile *pipeline.PipelineProfile) { + profile.References = map[string]string{ + "players": "players.txt", + "party": "party.txt", + "glossary": "glossary.txt", + } + }); err != nil { + t.Fatalf("optional NPC references error = %v, want resolution success", err) + } + if err := resolve(func(profile *pipeline.PipelineProfile) { + lane := profile.Artifacts["npcs"] + lane.Validators = []pipeline.ModuleBinding{pipeline.Binding("normalize/dnd/npcs/identity")} + profile.Artifacts["npcs"] = lane + }); err == nil || !strings.Contains(err.Error(), "artifact lane level") { + t.Fatalf("lane-level validator error = %v, want invalid placement rejection", err) + } +} + +func validatorChain(resolved pipeline.ResolvedPipeline, stage pipeline.ModuleStage, module string) []pipeline.ModuleBinding { + for _, chain := range resolved.ValidatorChains { + if chain.Stage == stage && chain.ModuleKey == module { + bindings := make([]pipeline.ModuleBinding, len(chain.Validators)) + for index, validator := range chain.Validators { + bindings[index] = validator.Binding + } + return bindings + } + } + return nil +} diff --git a/internal/cli/production_contract_test.go b/internal/cli/production_contract_test.go index 70a4110..523c908 100644 --- a/internal/cli/production_contract_test.go +++ b/internal/cli/production_contract_test.go @@ -463,6 +463,7 @@ func maintainedExampleFiles(t *testing.T) []maintainedExample { return []maintainedExample{ {name: "minimal", path: repositoryPath("examples", "dnd-spells.config.yml")}, {name: "production", path: repositoryPath("examples", "dnd-spells-production.config.yml")}, + {name: "npcs", path: repositoryPath("examples", "dnd-npcs.config.yml")}, } } diff --git a/internal/modules/dnd/normalize/npcs/normalizer.go b/internal/modules/dnd/normalize/npcs/normalizer.go index b16d0f7..ab24a33 100644 --- a/internal/modules/dnd/normalize/npcs/normalizer.go +++ b/internal/modules/dnd/normalize/npcs/normalizer.go @@ -172,7 +172,8 @@ func normalizeRecord(input dnd.NPC) (dnd.NPC, bool, bool) { func cloneNPC(input dnd.NPC) dnd.NPC { output := input if input.Aliases != nil { - output.Aliases = append([]string(nil), input.Aliases...) + output.Aliases = make([]string, len(input.Aliases)) + copy(output.Aliases, input.Aliases) } if input.Relationships != nil { output.Relationships = make([]dnd.NPCRelationship, len(input.Relationships)) diff --git a/internal/modules/dnd/normalize/npcs/normalizer_test.go b/internal/modules/dnd/normalize/npcs/normalizer_test.go index 34ac5c0..8618c7b 100644 --- a/internal/modules/dnd/normalize/npcs/normalizer_test.go +++ b/internal/modules/dnd/normalize/npcs/normalizer_test.go @@ -250,6 +250,21 @@ func TestNormalizeHandlesNilAndCanceledCalls(t *testing.T) { } } +func TestNormalizePreservesPresentEmptyAliases(t *testing.T) { + result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(dnd.NPCList{NPCs: []dnd.NPC{{ + Name: "Hooded Guard", + Aliases: []string{}, + Description: "A distinguishable sentry.", + SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, + }}})) + if err != nil { + t.Fatalf("Normalize() error = %v, want nil", err) + } + if result.Value.NPCs[0].Aliases == nil || len(result.Value.NPCs[0].Aliases) != 0 { + t.Fatalf("aliases = %#v, want present empty array", result.Value.NPCs[0].Aliases) + } +} + func normalizeRequest(value dnd.NPCList) contracts.TypedNormalizeRequest[dnd.NPCList] { return contracts.TypedNormalizeRequest[dnd.NPCList]{ MergeOutput: contracts.MergeArtifact[dnd.NPCList]{Value: value}, diff --git a/internal/modules/dnd/register/register.go b/internal/modules/dnd/register/register.go index 656318c..70b67eb 100644 --- a/internal/modules/dnd/register/register.go +++ b/internal/modules/dnd/register/register.go @@ -8,9 +8,16 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes" + npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells" + npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" + npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" + npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity" + npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape" + npcsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_refs" + npcrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_relatedness" spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/catalog" spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape" spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs" @@ -34,25 +41,43 @@ func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error { register func() error }{ {name: "spells codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, codec) }}, + {name: "npcs codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }}, {name: "scenes chunker", register: func() error { return scenes.Register(registries.Chunkers) }}, {name: "spells extractor", register: func() error { return spells.Register(registries.Extractors) }}, + {name: "npcs extractor", register: func() error { return npcextract.Register(registries.Extractors) }}, {name: "spell-list appendorder merger", register: func() error { return appendorder.RegisterTyped(registries.Mergers, dnd.SpellListKind, appendSpellLists) }}, + {name: "npc-list appendorder merger", register: func() error { + return appendorder.RegisterTyped(registries.Mergers, dnd.NPCListKind, appendNPCLists) + }}, {name: "spells normalizer", register: func() error { return spellnormalize.Register(registries.Normalizers) }}, + {name: "npcs normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }}, {name: "spell-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind) }}, + {name: "npc-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.NPCList](registries.Normalizers, dnd.NPCListKind) }}, {name: "spell shape validator", register: func() error { return spellshape.Register(registries.Validators) }}, {name: "spell catalog validator", register: func() error { return spellcatalog.Register(registries.Validators) }}, {name: "spell source references validator", register: func() error { return spellsourcerefs.Register(registries.Validators) }}, {name: "spell source relatedness validator", register: func() error { return spellrelatedness.Register(registries.Validators) }}, + {name: "npc shape validator", register: func() error { return npcshape.Register(registries.Validators) }}, + {name: "npc identity validator", register: func() error { return npcidentity.Register(registries.Validators) }}, + {name: "npc source references validator", register: func() error { return npcsourcerefs.Register(registries.Validators) }}, + {name: "npc source relatedness validator", register: func() error { return npcrelatedness.Register(registries.Validators) }}, {name: "spell-list always accept validator", register: func() error { return alwaysaccept.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind) }}, {name: "spell-list always reject validator", register: func() error { return alwaysreject.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind) }}, + {name: "npc-list always accept validator", register: func() error { + return alwaysaccept.RegisterTyped[dnd.NPCList](registries.Validators, dnd.NPCListKind) + }}, + {name: "npc-list always reject validator", register: func() error { + return alwaysreject.RegisterTyped[dnd.NPCList](registries.Validators, dnd.NPCListKind) + }}, {name: "scenes prompt assets", register: func() error { return scenes.RegisterPromptAssets(assets) }}, {name: "spells prompt assets", register: func() error { return spells.RegisterPromptAssets(assets) }}, + {name: "npcs prompt assets", register: func() error { return npcextract.RegisterPromptAssets(assets) }}, } for _, registration := range registrations { if err := registration.register(); err != nil { @@ -87,6 +112,33 @@ func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error { }); err != nil { return fmt.Errorf("register dnd spells normalize validator chain: %w", err) } + if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{ + Stage: pipeline.StageExtract, + Module: npcextract.Key, + Validators: []pipeline.ModuleBinding{ + pipeline.Binding(validjson.Key), + pipeline.Binding(validjsonschema.Key), + pipeline.Binding(npcshape.Key), + pipeline.Binding(npcsourcerefs.Key), + pipeline.Binding(npcrelatedness.Key), + }, + }); err != nil { + return fmt.Errorf("register dnd npcs validator chain: %w", err) + } + if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{ + Stage: pipeline.StageNormalize, + Module: npcnormalize.Key, + Validators: []pipeline.ModuleBinding{ + pipeline.Binding(validjson.Key), + pipeline.Binding(validjsonschema.Key), + pipeline.Binding(npcshape.Key), + pipeline.Binding(npcidentity.Key), + pipeline.Binding(npcsourcerefs.Key), + pipeline.Binding(npcrelatedness.Key), + }, + }); err != nil { + return fmt.Errorf("register dnd npcs normalize validator chain: %w", err) + } return nil } @@ -102,6 +154,25 @@ func appendSpellLists(values []dnd.SpellList) (dnd.SpellList, error) { return combined, nil } +func appendNPCLists(values []dnd.NPCList) (dnd.NPCList, error) { + count := 0 + present := false + for _, value := range values { + if value.NPCs != nil { + present = true + } + count += len(value.NPCs) + } + if !present { + return dnd.NPCList{}, nil + } + combined := dnd.NPCList{NPCs: make([]dnd.NPC, 0, count)} + for _, value := range values { + combined.NPCs = append(combined.NPCs, value.NPCs...) + } + return combined, nil +} + func validateRegistries(registries pipeline.Registries, assets *llm.AssetRegistry) error { switch { case registries.Chunkers == nil: diff --git a/internal/modules/dnd/register/register_test.go b/internal/modules/dnd/register/register_test.go index 12b9c9f..aaa27ef 100644 --- a/internal/modules/dnd/register/register_test.go +++ b/internal/modules/dnd/register/register_test.go @@ -11,7 +11,9 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" + npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" ) @@ -22,10 +24,17 @@ func TestRegisterAddsDNDFamily(t *testing.T) { t.Fatalf("Register() error = %v, want nil", err) } assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"}) - assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells"}) - assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, pipeline.DefaultNormalizeModule}) - assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind}) + assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key}) + assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, pipeline.DefaultNormalizeModule}) + assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind}) + assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind}) + assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind}) + assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCListKind}) assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{ + "extract/dnd/npcs/shape", + "extract/dnd/npcs/source_refs", + "extract/dnd/npcs/source_relatedness", + "normalize/dnd/npcs/identity", "extract/dnd/spells/catalog", "extract/dnd/spells/shape", "extract/dnd/spells/source_refs", @@ -47,6 +56,30 @@ func TestRegisterAddsDNDFamily(t *testing.T) { if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, spellnormalize.Key); !reflect.DeepEqual(got, wantChain) { t.Fatalf("spell normalize validator chain = %#v, want %#v", got, wantChain) } + npcExtractChain := []pipeline.ModuleBinding{ + pipeline.Binding("generic/valid_json"), + pipeline.Binding("generic/valid_json_schema"), + pipeline.Binding("extract/dnd/npcs/shape"), + pipeline.Binding("extract/dnd/npcs/source_refs"), + pipeline.Binding("extract/dnd/npcs/source_relatedness"), + } + if got := registries.ValidatorChains.Validators(pipeline.StageExtract, npcextract.Key); !reflect.DeepEqual(got, npcExtractChain) { + t.Fatalf("NPC extract validator chain = %#v, want %#v", got, npcExtractChain) + } + npcNormalizeChain := []pipeline.ModuleBinding{ + pipeline.Binding("generic/valid_json"), + pipeline.Binding("generic/valid_json_schema"), + pipeline.Binding("extract/dnd/npcs/shape"), + pipeline.Binding("normalize/dnd/npcs/identity"), + pipeline.Binding("extract/dnd/npcs/source_refs"), + pipeline.Binding("extract/dnd/npcs/source_relatedness"), + } + if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, npcnormalize.Key); !reflect.DeepEqual(got, npcNormalizeChain) { + t.Fatalf("NPC normalize validator chain = %#v, want %#v", got, npcNormalizeChain) + } + if got := registries.ValidatorChains.Validators(pipeline.StageMerge, npcextract.Key); got != nil { + t.Fatalf("NPC merge validator chain = %#v, want absent", got) + } assertAssetNamesContain(t, assets.PromptFS, []string{ "dnd.scenes/dnd.scenes.yaml", "dnd.scenes/instructions.md", @@ -60,10 +93,17 @@ func TestRegisterAddsDNDFamily(t *testing.T) { "dnd.spells/sharedassets/common-dnd-system.md", "dnd.spells/sharedassets/common-dnd-transcript.md", "dnd.spells/task.md", + "dnd.npcs/dnd.npcs.yaml", + "dnd.npcs/instructions.md", + "dnd.npcs/sharedassets/common-dnd-references.md", + "dnd.npcs/sharedassets/common-dnd-system.md", + "dnd.npcs/sharedassets/common-dnd-transcript.md", + "dnd.npcs/task.md", }) assertAssetNamesContain(t, assets.SchemaFS, []string{ "dnd_scenes.v1.json", "dnd_spells_llm.v1.json", + "dnd_npcs_llm.v1.json", }) if spec, ok := registries.Chunkers.Spec("dnd/scenes"); !ok || spec.Key != "dnd/scenes" { t.Fatalf("scene chunker spec = %#v, present = %t; want family-owned spec", spec, ok) @@ -74,6 +114,33 @@ func TestRegisterAddsDNDFamily(t *testing.T) { if spec, ok := registries.Normalizers.Spec(spellnormalize.Key); !ok || spec.ArtifactKind != dnd.SpellListKind || spec.Stage != pipeline.StageNormalize { t.Fatalf("spell normalizer spec = %#v, present = %t; want dnd spell-list artifact", spec, ok) } + if spec, ok := registries.Extractors.Spec(npcextract.Key); !ok || spec.ArtifactKind != dnd.NPCListKind { + t.Fatalf("NPC extractor spec = %#v, present = %t; want dnd NPC-list artifact", spec, ok) + } + if spec, ok := registries.Normalizers.Spec(npcnormalize.Key); !ok || spec.ArtifactKind != dnd.NPCListKind || spec.Stage != pipeline.StageNormalize { + t.Fatalf("NPC normalizer spec = %#v, present = %t; want dnd NPC-list artifact", spec, ok) + } +} + +func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) { + tests := []struct { + name string + in []dnd.NPCList + want dnd.NPCList + }{ + {name: "no values", in: nil, want: dnd.NPCList{}}, + {name: "nil values", in: []dnd.NPCList{{}, {}}, want: dnd.NPCList{}}, + {name: "present empty", in: []dnd.NPCList{{NPCs: []dnd.NPC{}}}, want: dnd.NPCList{NPCs: []dnd.NPC{}}}, + {name: "ordered values", in: []dnd.NPCList{{NPCs: []dnd.NPC{{Name: "first"}}}, {NPCs: []dnd.NPC{{Name: "second"}}}}, want: dnd.NPCList{NPCs: []dnd.NPC{{Name: "first"}, {Name: "second"}}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := appendNPCLists(tt.in) + if err != nil || !reflect.DeepEqual(got, tt.want) { + t.Fatalf("appendNPCLists() = %#v, error = %v, want %#v", got, err, tt.want) + } + }) + } } func TestRegisterRejectsMissingDNDDependenciesBeforeMutation(t *testing.T) { diff --git a/internal/modules/integration/dnd_npcs_runner_test.go b/internal/modules/integration/dnd_npcs_runner_test.go new file mode 100644 index 0000000..0611d99 --- /dev/null +++ b/internal/modules/integration/dnd_npcs_runner_test.go @@ -0,0 +1,242 @@ +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 +} diff --git a/internal/modules/integration/testdata/dnd_npcs_pipeline.yml b/internal/modules/integration/testdata/dnd_npcs_pipeline.yml new file mode 100644 index 0000000..d595a75 --- /dev/null +++ b/internal/modules/integration/testdata/dnd_npcs_pipeline.yml @@ -0,0 +1,20 @@ +version: 3 +output: + directory: ./notarius-output +cache: + chunk_plans: + mode: bypass + checkpoints: {} +debug: + directory: ./notarius-debug +pipelines: + dnd-npcs-fixture: + input: seriatim + chunk: generic + artifacts: + npcs: + extract: + module: dnd/npcs + retries: 2 + normalize: dnd/npcs + output: json diff --git a/internal/modules/integration/testdata/seriatim_npc_session.json b/internal/modules/integration/testdata/seriatim_npc_session.json new file mode 100644 index 0000000..05f74d6 --- /dev/null +++ b/internal/modules/integration/testdata/seriatim_npc_session.json @@ -0,0 +1,43 @@ +{ + "metadata": { + "id": "npc-session", + "title": "Synthetic D&D NPC session" + }, + "segments": [ + { + "id": 1, + "start": 0, + "end": 4, + "speaker": "Aria", + "text": "Aria watches Mira Thorn, the Greencloak, enter the ruined hall." + }, + { + "id": 2, + "start": 4, + "end": 8, + "speaker": "DM", + "text": "Mira Thorn asks the party to follow the old road." + }, + { + "id": 3, + "start": 8, + "end": 12, + "speaker": "DM", + "text": "A hooded guard opens the side gate and waits in silence." + }, + { + "id": 4, + "start": 12, + "end": 16, + "speaker": "DM", + "text": "Three identical guards surround the interchangeable group." + }, + { + "id": 5, + "start": 16, + "end": 20, + "speaker": "Aria", + "text": "Aria keeps watch while the named NPCs leave the hall." + } + ] +}