From e043d61a994b11df0e2913630a3ec1fe4317b6b9 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 23 Jul 2026 20:22:05 +0000 Subject: [PATCH] Register D&D scene description pipeline --- .../dnd_scene_descriptions_contract_test.go | 111 ++++++++++++++++++ internal/modules/dnd/register/chains.go | 33 ++++++ internal/modules/dnd/register/merge.go | 19 +++ internal/modules/dnd/register/modules.go | 15 +++ .../modules/dnd/register/register_test.go | 73 +++++++++++- internal/modules/dnd/register/validators.go | 14 +++ 6 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 internal/cli/dnd_scene_descriptions_contract_test.go diff --git a/internal/cli/dnd_scene_descriptions_contract_test.go b/internal/cli/dnd_scene_descriptions_contract_test.go new file mode 100644 index 0000000..b0511a0 --- /dev/null +++ b/internal/cli/dnd_scene_descriptions_contract_test.go @@ -0,0 +1,111 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/config" + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + scenecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions" + sceneextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions" + scenenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions" +) + +func TestProductionSceneDescriptionWorkflow(t *testing.T) { + components := productionTestComponents(t) + cfg := config.Default() + cfg.Pipelines["scene-descriptions"] = pipeline.PipelineProfile{ + ID: "scene-descriptions", + Input: pipeline.Binding("seriatim"), + Chunk: pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}}, + Output: pipeline.Binding("json"), + Artifacts: map[string]pipeline.ArtifactLaneProfile{ + "scene-descriptions": { + Extract: pipeline.Binding(sceneextract.Key), + Normalize: pipeline.Binding(scenenormalize.Key), + }, + }, + } + effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "scene-descriptions", Catalog: catalogFromRegistries(components.registries)}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + lane := effective.ResolvedPipeline.Steps[0].ArtifactLanes[0] + if lane.ArtifactKind != dnd.SceneDescriptionListKind || lane.Extract.Module != sceneextract.Key || lane.Merge.Module != pipeline.DefaultMergeModule || lane.Normalize.Module != scenenormalize.Key { + t.Fatalf("resolved lane = %#v, want production scene-description composition", lane) + } + if len(lane.ExtractReferences.Bindings) != 0 || len(lane.NormalizeReferences.Bindings) != 0 { + t.Fatalf("resolved references = %#v / %#v, want no generated or required references", lane.ExtractReferences, lane.NormalizeReferences) + } + + prepared, err := pipeline.Prepare(effective.ResolvedPipeline, components.registries, pipeline.ModuleDependencies{LLM: sceneDescriptionLLM{}}) + if err != nil { + t.Fatalf("Prepare() error = %v", err) + } + output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{ + Prepared: prepared, + RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"), + ChunkCacheMode: pipeline.ChunkCacheBypass, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if output.Manifest.ValidationStatus != "approved" || len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 { + t.Fatalf("run output = %#v, want one approved normalized artifact", output) + } + normalizedOutput := output.NormalizeOutputs[0] + if normalizedOutput.NormalizerKey != scenenormalize.Key || normalizedOutput.Artifact.Kind != dnd.SceneDescriptionListKind || normalizedOutput.Artifact.Schema.ID != scenecodec.SchemaID || normalizedOutput.Artifact.Schema.Name != scenecodec.SchemaName || normalizedOutput.Artifact.Schema.Version != scenecodec.SchemaVersion { + t.Fatalf("normalized output = %#v, want registered durable scene-description schema", normalizedOutput) + } + + var value dnd.SceneDescriptionList + if err := json.Unmarshal(normalizedOutput.Artifact.Content, &value); err != nil { + t.Fatalf("decode normalized artifact: %v", err) + } + want := dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{ + {ID: "chunk-000001", SourceRef: source.SourceRef{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}, Kind: dnd.SceneKindNarrative, Title: "Aria casts Cure Wounds", Summary: "Aria casts Cure Wounds."}, + {ID: "chunk-000002", SourceRef: source.SourceRef{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}, Kind: dnd.SceneKindCombat, Title: "Bandit mage casts Shield", Summary: "The bandit mage casts Shield."}, + }} + if !reflect.DeepEqual(value, want) { + t.Fatalf("normalized scene descriptions = %#v, want %#v", value, want) + } + durable := decodeAssembledOutput[dnd.SceneDescriptionList](t, output.OutputFiles, "lanes/scene-descriptions.json") + if !reflect.DeepEqual(durable, want) { + t.Fatalf("durable output payload = %#v, want %#v", durable, want) + } + if len(output.Warnings) != 0 { + t.Fatalf("warnings = %#v, want grounded descriptions without warnings", output.Warnings) + } +} + +type sceneDescriptionLLM struct{} + +func (sceneDescriptionLLM) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) { + if err := ctx.Err(); err != nil { + return contracts.StructuredCompletionResponse{}, err + } + if req.PromptID != sceneextract.PromptID { + return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID) + } + transcript := string(req.Inputs["transcript"].Content) + var content string + switch { + case strings.Contains(transcript, "Cure Wounds"): + content = `{"kind":"narrative","title":" Aria casts Cure Wounds ","summary":" Aria casts Cure Wounds. "}` + case strings.Contains(transcript, "Shield"): + content = `{"kind":"combat","title":"Bandit mage casts Shield","summary":"The bandit mage casts Shield."}` + default: + return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected transcript material %q", transcript) + } + if err := json.Unmarshal([]byte(content), out); err != nil { + return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate structured response: %w", err) + } + return contracts.StructuredCompletionResponse{Content: []byte(content), Provider: "test", Model: "deterministic", ProfileID: req.ProfileID}, nil +} diff --git a/internal/modules/dnd/register/chains.go b/internal/modules/dnd/register/chains.go index fbbbe2d..dcf33b4 100644 --- a/internal/modules/dnd/register/chains.go +++ b/internal/modules/dnd/register/chains.go @@ -5,10 +5,12 @@ import ( combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" + scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions" spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" + scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions" spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" combatinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/invariants" combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape" @@ -23,6 +25,10 @@ import ( 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" + sceneinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/invariants" + sceneshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/shape" + scenerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/source_refs" + scenerelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/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" @@ -144,5 +150,32 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error { }, }) }}, + {name: "scene descriptions validator chain", register: func() error { + return registry.Register(pipeline.ValidatorChainMapping{ + Stage: pipeline.StageExtract, + Module: scenedescriptionextract.Key, + Validators: []pipeline.ModuleBinding{ + pipeline.Binding(validjson.Key), + pipeline.Binding(sceneshape.Key), + pipeline.Binding(scenerefs.Key), + pipeline.Binding(validjsonschema.Key), + pipeline.Binding(scenerelatedness.Key), + }, + }) + }}, + {name: "scene descriptions normalize validator chain", register: func() error { + return registry.Register(pipeline.ValidatorChainMapping{ + Stage: pipeline.StageNormalize, + Module: scenedescriptionnormalize.Key, + Validators: []pipeline.ModuleBinding{ + pipeline.Binding(validjson.Key), + pipeline.Binding(sceneshape.Key), + pipeline.Binding(sceneinvariants.Key), + pipeline.Binding(scenerefs.Key), + pipeline.Binding(validjsonschema.Key), + pipeline.Binding(scenerelatedness.Key), + }, + }) + }}, }) } diff --git a/internal/modules/dnd/register/merge.go b/internal/modules/dnd/register/merge.go index 3b5d781..3b98777 100644 --- a/internal/modules/dnd/register/merge.go +++ b/internal/modules/dnd/register/merge.go @@ -78,6 +78,25 @@ func appendNPCInteractionLists(values []dnd.NPCInteractionList) (dnd.NPCInteract return combined, nil } +func appendSceneDescriptionLists(values []dnd.SceneDescriptionList) (dnd.SceneDescriptionList, error) { + count := 0 + present := false + for _, value := range values { + if value.Scenes != nil { + present = true + } + count += len(value.Scenes) + } + if !present { + return dnd.SceneDescriptionList{}, nil + } + combined := dnd.SceneDescriptionList{Scenes: make([]dnd.SceneDescription, 0, count)} + for _, value := range values { + combined.Scenes = append(combined.Scenes, value.Scenes...) + } + return combined, nil +} + func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn { clone := value if value.SourceRefs != nil { diff --git a/internal/modules/dnd/register/modules.go b/internal/modules/dnd/register/modules.go index 2d2366c..6808f30 100644 --- a/internal/modules/dnd/register/modules.go +++ b/internal/modules/dnd/register/modules.go @@ -8,14 +8,17 @@ import ( combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns" interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions" npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" + scenedescriptioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions" spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells" combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" + scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions" spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" + scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions" spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop" @@ -28,11 +31,15 @@ func registerModules(registries pipeline.Registries) error { {name: "npcs codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }}, {name: "combat turns codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, combatcodec.New()) }}, {name: "npc interactions codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, interactioncodec.New()) }}, + {name: "scene descriptions codec", register: func() error { + return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, scenedescriptioncodec.New()) + }}, {name: "scenes chunker", register: func() error { return scenes.Register(registries.Chunkers) }}, {name: "spells extractor", register: func() error { return spellextract.Register(registries.Extractors) }}, {name: "npcs extractor", register: func() error { return npcextract.Register(registries.Extractors) }}, {name: "combat turns extractor", register: func() error { return combatextract.Register(registries.Extractors) }}, {name: "npc interactions extractor", register: func() error { return interactionextract.Register(registries.Extractors) }}, + {name: "scene descriptions extractor", register: func() error { return scenedescriptionextract.Register(registries.Extractors) }}, {name: "spell-list appendorder merger", register: func() error { return appendorder.RegisterTyped(registries.Mergers, dnd.SpellListKind, appendSpellLists) }}, @@ -45,10 +52,14 @@ func registerModules(registries pipeline.Registries) error { {name: "npc-interaction-list appendorder merger", register: func() error { return appendorder.RegisterTyped(registries.Mergers, dnd.NPCInteractionListKind, appendNPCInteractionLists) }}, + {name: "scene-description-list appendorder merger", register: func() error { + return appendorder.RegisterTyped(registries.Mergers, dnd.SceneDescriptionListKind, appendSceneDescriptionLists) + }}, {name: "spells normalizer", register: func() error { return spellnormalize.Register(registries.Normalizers) }}, {name: "npcs normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }}, {name: "combat turns normalizer", register: func() error { return combatnormalize.Register(registries.Normalizers) }}, {name: "npc interactions normalizer", register: func() error { return interactionnormalize.Register(registries.Normalizers) }}, + {name: "scene descriptions normalizer", register: func() error { return scenedescriptionnormalize.Register(registries.Normalizers) }}, {name: "spell-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind) }}, @@ -61,6 +72,9 @@ func registerModules(registries pipeline.Registries) error { {name: "npc-interaction-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.NPCInteractionList](registries.Normalizers, dnd.NPCInteractionListKind) }}, + {name: "scene-description-list noop normalizer", register: func() error { + return noop.RegisterTyped[dnd.SceneDescriptionList](registries.Normalizers, dnd.SceneDescriptionListKind) + }}, }) } @@ -71,5 +85,6 @@ func registerPromptAssets(assets *llm.AssetRegistry) error { {name: "npcs prompt assets", register: func() error { return npcextract.RegisterPromptAssets(assets) }}, {name: "combat turns prompt assets", register: func() error { return combatextract.RegisterPromptAssets(assets) }}, {name: "npc interactions prompt assets", register: func() error { return interactionextract.RegisterPromptAssets(assets) }}, + {name: "scene descriptions prompt assets", register: func() error { return scenedescriptionextract.RegisterPromptAssets(assets) }}, }) } diff --git a/internal/modules/dnd/register/register_test.go b/internal/modules/dnd/register/register_test.go index 0f10069..2665a3c 100644 --- a/internal/modules/dnd/register/register_test.go +++ b/internal/modules/dnd/register/register_test.go @@ -15,10 +15,12 @@ import ( combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" + scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" + scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions" spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" ) @@ -29,14 +31,15 @@ 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", npcextract.Key, combatextract.Key, interactionextract.Key}) - assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, interactionnormalize.Key, pipeline.DefaultNormalizeModule}) - assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind}) - assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind}) - assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind}) + assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, interactionextract.Key, scenedescriptionextract.Key}) + assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, interactionnormalize.Key, scenedescriptionnormalize.Key, pipeline.DefaultNormalizeModule}) + assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind}) + assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind}) + assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(interactionnormalize.Key), []contracts.ArtifactKind{dnd.NPCInteractionListKind}) + assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind}) assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{ "extract/dnd/npcs/shape", "extract/dnd/npcs/source_refs", @@ -55,6 +58,10 @@ func TestRegisterAddsDNDFamily(t *testing.T) { "extract/dnd/npc-interactions/source_refs", "extract/dnd/npc-interactions/source_relatedness", "normalize/dnd/npc-interactions/invariants", + "extract/dnd/scene-descriptions/shape", + "extract/dnd/scene-descriptions/source_refs", + "extract/dnd/scene-descriptions/source_relatedness", + "normalize/dnd/scene-descriptions/invariants", "generic/always_accept", "generic/always_reject", }) @@ -137,6 +144,27 @@ func TestRegisterAddsDNDFamily(t *testing.T) { if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, interactionnormalize.Key); !reflect.DeepEqual(got, interactionNormalizeChain) { t.Fatalf("NPC interaction normalize validator chain = %#v, want %#v", got, interactionNormalizeChain) } + sceneExtractChain := []pipeline.ModuleBinding{ + pipeline.Binding("generic/valid_json"), + pipeline.Binding("extract/dnd/scene-descriptions/shape"), + pipeline.Binding("extract/dnd/scene-descriptions/source_refs"), + pipeline.Binding("generic/valid_json_schema"), + pipeline.Binding("extract/dnd/scene-descriptions/source_relatedness"), + } + if got := registries.ValidatorChains.Validators(pipeline.StageExtract, scenedescriptionextract.Key); !reflect.DeepEqual(got, sceneExtractChain) { + t.Fatalf("scene description extract validator chain = %#v, want %#v", got, sceneExtractChain) + } + sceneNormalizeChain := []pipeline.ModuleBinding{ + pipeline.Binding("generic/valid_json"), + pipeline.Binding("extract/dnd/scene-descriptions/shape"), + pipeline.Binding("normalize/dnd/scene-descriptions/invariants"), + pipeline.Binding("extract/dnd/scene-descriptions/source_refs"), + pipeline.Binding("generic/valid_json_schema"), + pipeline.Binding("extract/dnd/scene-descriptions/source_relatedness"), + } + if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, scenedescriptionnormalize.Key); !reflect.DeepEqual(got, sceneNormalizeChain) { + t.Fatalf("scene description normalize validator chain = %#v, want %#v", got, sceneNormalizeChain) + } if got := registries.ValidatorChains.Validators(pipeline.StageMerge, npcextract.Key); got != nil { t.Fatalf("NPC merge validator chain = %#v, want absent", got) } @@ -172,6 +200,13 @@ func TestRegisterAddsDNDFamily(t *testing.T) { "dnd.npc_interactions/instructions.md", "dnd.npc_interactions/sharedassets/common-dnd-npcs.md", "dnd.npc_interactions/task.md", + "dnd.scene_descriptions/dnd.scene_descriptions.yaml", + "dnd.scene_descriptions/instructions.md", + "dnd.scene_descriptions/sharedassets/common-dnd-identity.md", + "dnd.scene_descriptions/sharedassets/common-dnd-references.md", + "dnd.scene_descriptions/sharedassets/common-dnd-system.md", + "dnd.scene_descriptions/sharedassets/common-dnd-transcript.md", + "dnd.scene_descriptions/task.md", }) assertAssetNamesContain(t, assets.SchemaFS, []string{ "dnd_scenes.v1.json", @@ -179,6 +214,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) { "dnd_npcs_llm.v1.json", "dnd_combat_turns_llm.v1.json", "dnd_npc_interactions_llm.v1.json", + "dnd_scene_descriptions_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) @@ -206,6 +242,14 @@ func TestRegisterAddsDNDFamily(t *testing.T) { if !extractOK || interactionExtractSpec.ArtifactKind != dnd.NPCInteractionListKind || !normalizeOK || interactionNormalizeSpec.ArtifactKind != dnd.NPCInteractionListKind || interactionNormalizeSpec.Stage != pipeline.StageNormalize { t.Fatalf("NPC interaction specs = %#v / %#v, present = %t / %t", interactionExtractSpec, interactionNormalizeSpec, extractOK, normalizeOK) } + sceneExtractSpec, sceneExtractOK := registries.Extractors.Spec(scenedescriptionextract.Key) + sceneNormalizeSpec, sceneNormalizeOK := registries.Normalizers.Spec(scenedescriptionnormalize.Key) + if !sceneExtractOK || sceneExtractSpec.ArtifactKind != dnd.SceneDescriptionListKind || !sceneNormalizeOK || sceneNormalizeSpec.ArtifactKind != dnd.SceneDescriptionListKind || sceneNormalizeSpec.Stage != pipeline.StageNormalize { + t.Fatalf("scene description specs = %#v / %#v, present = %t / %t", sceneExtractSpec, sceneNormalizeSpec, sceneExtractOK, sceneNormalizeOK) + } + if len(sceneExtractSpec.ReferenceSlots) != 3 || len(sceneNormalizeSpec.ReferenceSlots) != 0 { + t.Fatalf("scene description reference slots = %#v / %#v, want extractor campaign slots only", sceneExtractSpec.ReferenceSlots, sceneNormalizeSpec.ReferenceSlots) + } extractRegistrySlot := referenceSlot(interactionExtractSpec.ReferenceSlots, "npcs") normalizeRegistrySlot := referenceSlot(interactionNormalizeSpec.ReferenceSlots, "npcs") if !extractRegistrySlot.Required || !reflect.DeepEqual(extractRegistrySlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.NPCListKind}) || !reflect.DeepEqual(extractRegistrySlot, normalizeRegistrySlot) { @@ -270,6 +314,25 @@ func TestAppendNPCInteractionListsPreservesOrderPresenceAndOwnership(t *testing. } } +func TestAppendSceneDescriptionListsPreservesOrderAndArrayPresence(t *testing.T) { + input := []dnd.SceneDescriptionList{ + {}, + {Scenes: []dnd.SceneDescription{}}, + {Scenes: []dnd.SceneDescription{{ID: "first"}}}, + {Scenes: []dnd.SceneDescription{{ID: "second"}}}, + } + got, err := appendSceneDescriptionLists(input) + if err != nil || !reflect.DeepEqual(got.Scenes, []dnd.SceneDescription{{ID: "first"}, {ID: "second"}}) { + t.Fatalf("appendSceneDescriptionLists() = %#v, error = %v", got, err) + } + for _, values := range [][]dnd.SceneDescriptionList{nil, []dnd.SceneDescriptionList{{}, {}}} { + result, err := appendSceneDescriptionLists(values) + if err != nil || result.Scenes != nil { + t.Fatalf("nil-only merge = %#v, %v; want nil scenes", result, err) + } + } +} + func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) { refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} input := []dnd.CombatTurnList{ diff --git a/internal/modules/dnd/register/validators.go b/internal/modules/dnd/register/validators.go index a0d9cd6..2afc8fc 100644 --- a/internal/modules/dnd/register/validators.go +++ b/internal/modules/dnd/register/validators.go @@ -16,6 +16,10 @@ import ( 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" + sceneinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/invariants" + sceneshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/shape" + scenerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/source_refs" + scenerelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/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" @@ -43,6 +47,10 @@ func registerValidators(registries pipeline.Registries) error { {name: "npc interaction source references validator", register: func() error { return interactionrefs.Register(registries.Validators) }}, {name: "npc interaction source relatedness validator", register: func() error { return interactionrelatedness.Register(registries.Validators) }}, {name: "npc interaction normalized invariants validator", register: func() error { return interactioninvariants.Register(registries.Validators) }}, + {name: "scene description shape validator", register: func() error { return sceneshape.Register(registries.Validators) }}, + {name: "scene description source references validator", register: func() error { return scenerefs.Register(registries.Validators) }}, + {name: "scene description source relatedness validator", register: func() error { return scenerelatedness.Register(registries.Validators) }}, + {name: "scene description normalized invariants validator", register: func() error { return sceneinvariants.Register(registries.Validators) }}, {name: "spell-list always accept validator", register: func() error { return alwaysaccept.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind) }}, @@ -67,5 +75,11 @@ func registerValidators(registries pipeline.Registries) error { {name: "npc-interaction-list always reject validator", register: func() error { return alwaysreject.RegisterTyped[dnd.NPCInteractionList](registries.Validators, dnd.NPCInteractionListKind) }}, + {name: "scene-description-list always accept validator", register: func() error { + return alwaysaccept.RegisterTyped[dnd.SceneDescriptionList](registries.Validators, dnd.SceneDescriptionListKind) + }}, + {name: "scene-description-list always reject validator", register: func() error { + return alwaysreject.RegisterTyped[dnd.SceneDescriptionList](registries.Validators, dnd.SceneDescriptionListKind) + }}, }) }