From ba1d112d1f4e9bdd6c7b046ecec4aa43b13ca4de Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 3 Aug 2026 21:10:21 +0000 Subject: [PATCH] Add D&D enemy event pipeline example --- examples/dnd-complete.config.yml | 26 ++ .../cli/dnd_enemy_events_contract_test.go | 275 ++++++++++++++++++ internal/cli/example_contract_test.go | 16 +- internal/cli/production_contract_test.go | 28 +- .../dnd/extract/enemyevents/grounding.go | 3 + 5 files changed, 341 insertions(+), 7 deletions(-) create mode 100644 internal/cli/dnd_enemy_events_contract_test.go diff --git a/examples/dnd-complete.config.yml b/examples/dnd-complete.config.yml index b9c7766..49cc8a1 100644 --- a/examples/dnd-complete.config.yml +++ b/examples/dnd-complete.config.yml @@ -40,6 +40,7 @@ pipelines: - spells - combat-turns - npc-interactions + - enemy-events steps: # Establish session-wide reference artifacts alongside independent item events. - id: describe-session @@ -101,3 +102,28 @@ pipelines: retries: 2 merge: appendorder normalize: dnd/npc-interactions + - id: track-enemies + references: + npcs: + artifact: + step: describe-session + lane: npcs + scene_descriptions: + artifact: + step: describe-session + lane: scene-descriptions + combat_turns: + artifact: + step: extract-events + lane: combat-turns + npc_interactions: + artifact: + step: extract-events + lane: npc-interactions + artifacts: + enemy-events: + extract: + module: dnd/enemy-events + retries: 2 + merge: appendorder + normalize: dnd/enemy-events diff --git a/internal/cli/dnd_enemy_events_contract_test.go b/internal/cli/dnd_enemy_events_contract_test.go new file mode 100644 index 0000000..c08912d --- /dev/null +++ b/internal/cli/dnd_enemy_events_contract_test.go @@ -0,0 +1,275 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" + + "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" + "gitea.maximumdirect.net/eric/notarius/internal/core/config" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/evidencecontext" + "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" + combat "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" + enemyevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents" + itemevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents" + npcinteractions "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions" + npcs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" + scenedescriptions "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" + enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents" + npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" +) + +func TestProductionEnemyEventConfigurationResolvesGeneratedHandoffs(t *testing.T) { + components := productionTestComponents(t) + cfg := loadMaintainedExample(t, repositoryPath("examples", "dnd-complete.config.yml")) + effective, err := cfg.Resolve(resolveInputForMaintainedExample(components, "dnd-session")) + if err != nil { + t.Fatalf("Resolve() error = %v, want nil", err) + } + materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{ + ConfigPath: repositoryPath("examples", "dnd-complete.config.yml"), + WorkingDir: repositoryPath("examples"), + }) + if err != nil { + t.Fatalf("MaterializeReferences() error = %v, want nil", err) + } + lane := referenceContractLane(t, materialized, "enemy-events") + if lane.ArtifactKind != dnd.EnemyEventListKind || lane.Extract.Module != enemyevents.Key || lane.Extract.Retries != 2 || lane.Merge.Module != pipeline.DefaultMergeModule || lane.Normalize.Module != enemyeventnormalize.Key { + t.Fatalf("enemy event lane = %#v, want typed production composition", lane) + } + for slot, want := range map[string]struct{ step, lane string }{ + "npcs": {step: "describe-session", lane: "npcs"}, + "scene_descriptions": {step: "describe-session", lane: "scene-descriptions"}, + "combat_turns": {step: "extract-events", lane: "combat-turns"}, + "npc_interactions": {step: "extract-events", lane: "npc-interactions"}, + } { + binding, found := generatedReferenceBinding(lane.ExtractReferences.Bindings, slot) + if !found || binding.Artifact.Step != want.step || binding.Artifact.Lane != want.lane { + t.Fatalf("enemy event %s reference = %#v, want generated %s/%s artifact", slot, binding, want.step, want.lane) + } + } + if binding, found := generatedReferenceBinding(lane.NormalizeReferences.Bindings, "npcs"); !found || binding.Artifact.Step != "describe-session" || binding.Artifact.Lane != "npcs" { + t.Fatalf("enemy event normalizer NPC reference = %#v, want generated NPC artifact", binding) + } + + catalog := catalogFromRegistries(components.registries) + extractSpec, ok := catalog.Extractors.Spec(enemyevents.Key) + if !ok || !reflect.DeepEqual(extractSpec.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(extractSpec.Provides, []string{"dnd.enemy_events"}) { + t.Fatalf("enemy event extractor spec = %#v, want source and artifact capabilities", extractSpec) + } + normalizeSpec, ok := catalog.Normalizers.SpecForArtifact(enemyeventnormalize.Key, dnd.EnemyEventListKind) + if !ok || !reflect.DeepEqual(normalizeSpec.Requires, []string{"merged"}) || !reflect.DeepEqual(normalizeSpec.Provides, []string{"normalized"}) { + t.Fatalf("enemy event normalizer spec = %#v, want merged/normalized capabilities", normalizeSpec) + } + for _, slot := range []string{"npcs", "scene_descriptions", "combat_turns", "npc_interactions"} { + if !hasReferenceSlot(extractSpec.ReferenceSlots, slot) { + t.Fatalf("enemy event extractor slots = %#v, want %q", extractSpec.ReferenceSlots, slot) + } + } + if !hasReferenceSlot(normalizeSpec.ReferenceSlots, "npcs") { + t.Fatalf("enemy event normalizer slots = %#v, want NPC registry", normalizeSpec.ReferenceSlots) + } + + profile := cfg.Pipelines["dnd-session"] + profile.Steps[2].References["npcs"] = pipeline.GeneratedReference("track-enemies", "enemy-events") + cfg.Pipelines["dnd-session"] = profile + if _, err := cfg.Resolve(resolveInputForMaintainedExample(components, "dnd-session")); err == nil || !strings.Contains(err.Error(), "earlier step") { + t.Fatalf("Resolve() error = %v, want future generated-reference rejection", err) + } +} + +func TestMaintainedCompleteExampleProducesEnemyEventsThroughGeneratedHandoffs(t *testing.T) { + t.Chdir(repositoryPath()) + outputRoot := filepath.Join(t.TempDir(), "output") + configPath := completeExampleConfigWithTemporaryCache(t) + client := &enemyEventLLMClient{} + options := productionCLIOptions(t) + options.Now = func() time.Time { return time.Unix(1700000000, 0).UTC() } + options.RunIDGenerator = func(time.Time) (string, error) { return productionRunID, nil } + options.UserCacheDir = func() (string, error) { return "", errors.New("user cache must not be used") } + options.LLMClientFactory = func(context.Context, config.Config, string, LLMRuntimeOverrides) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) { + return client, nil, nil + } + + var stdout, stderr strings.Builder + code := RunWithOptions([]string{ + "run", "dnd-session", + "--config", configPath, + "--input", repositoryPath("examples", "dnd-complete-transcript.json"), + "--chunk_cache", "bypass", "--output-dir", outputRoot, "--session-id", "enemy-event-session", + }, &stdout, &stderr, options) + if code != 0 { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + + runRoot := filepath.Join(outputRoot, productionRunID) + index := readProductionJSON[exampleOutputIndex](t, filepath.Join(runRoot, "index.json")) + var enemyOutput exampleOutputIndexEntry + for _, entry := range index.OutputFiles { + if entry.LaneID == "enemy-events" { + enemyOutput = entry + break + } + } + if enemyOutput.File != "lanes/enemy-events.json" || enemyOutput.SchemaID != "notarius.dnd.enemy_events" || enemyOutput.SchemaVersion != "v1" { + t.Fatalf("enemy event output = %#v, want typed enemy-event JSON", enemyOutput) + } + value := readProductionJSON[dnd.EnemyEventList](t, filepath.Join(runRoot, enemyOutput.File)) + if len(value.Events) != 1 || value.Events[0].Name != "Kesh" || value.Events[0].Kind != dnd.EnemyEventKindFled || len(value.Events[0].SourceRefs) != 1 || value.Events[0].SourceRefs[0].SourceID != "session-ravenfall" || value.Events[0].SourceRefs[0].StartUnitID != 10 { + t.Fatalf("enemy event artifact = %#v, want source-linked Kesh fleeing event", value) + } + + evidence := readProductionJSON[evidencecontext.Document](t, filepath.Join(runRoot, "evidence-context.json")) + if !containsString(evidence.SelectedLanes, "enemy-events") || !evidenceHasLane(evidence, "enemy-events") { + t.Fatalf("evidence context = %#v, want direct enemy-event evidence", evidence) + } + + requests := client.requestsFor(enemyevents.PromptID) + if len(requests) != 1 { + t.Fatalf("enemy event requests = %#v, want only the combat scene request", requests) + } + request := requests[0] + if request.SessionID != "enemy-event-session" { + t.Fatalf("enemy event session = %q, want shared session", request.SessionID) + } + for slot, required := range map[string]string{ + "npcs": "Kesh", + "combat_turns": "Kesh", + "npc_interactions": "Kesh", + } { + input, ok := request.Inputs[slot] + if !ok || !strings.Contains(string(input.Content), required) || strings.Contains(string(input.Content), "source_refs") || strings.Contains(string(input.Content), "start_unit_id") { + t.Fatalf("enemy event %s prompt input = %q, want compact source-free grounding", slot, input.Content) + } + } +} + +func completeExampleConfigWithTemporaryCache(t *testing.T) string { + t.Helper() + content, err := os.ReadFile(repositoryPath("examples", "dnd-complete.config.yml")) + if err != nil { + t.Fatal(err) + } + cacheRoot := t.TempDir() + updated := strings.Replace(string(content), "directory: ./notarius-cache/chunk-plans", fmt.Sprintf("directory: %q", filepath.Join(cacheRoot, "chunk-plans")), 1) + updated = strings.Replace(updated, "directory: ./notarius-cache/checkpoints", fmt.Sprintf("directory: %q", filepath.Join(cacheRoot, "checkpoints")), 1) + for relative, absolute := range map[string]string{ + "./dnd-party.txt": repositoryPath("examples", "dnd-party.txt"), + "./dnd-glossary.txt": repositoryPath("examples", "dnd-glossary.txt"), + "./dnd-spell-catalog.json": repositoryPath("examples", "dnd-spell-catalog.json"), + } { + updated = strings.ReplaceAll(updated, relative, fmt.Sprintf("%q", absolute)) + } + path := filepath.Join(t.TempDir(), "dnd-complete.config.yml") + if err := os.WriteFile(path, []byte(updated), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +type enemyEventLLMClient struct { + mu sync.Mutex + requests []contracts.StructuredCompletionRequest +} + +func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, request contracts.StructuredCompletionRequest, output any) (contracts.StructuredCompletionResponse, error) { + if err := ctx.Err(); err != nil { + return contracts.StructuredCompletionResponse{}, err + } + combatScene := strings.Contains(string(request.Inputs["transcript"].Content), "Roll initiative") + var content []byte + switch request.PromptID { + case scenes.PromptID: + content = []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":6},{"start_unit_id":7,"end_unit_id":11}]}`) + case npcs.PromptID: + if combatScene { + content = []byte(`{"npcs":[{"name":"Kesh","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`) + } else { + content = []byte(`{"npcs":[]}`) + } + case npcnormalize.PromptID: + content = []byte(`{"duplicate_groups":[]}`) + case scenedescriptions.PromptID: + kind, title := "narrative", "Arrival" + if combatScene { + kind, title = "combat", "Raiders attack" + } + content = []byte(fmt.Sprintf(`{"kind":%q,"title":%q,"summary":"session scene"}`, kind, title)) + case spells.PromptID: + content = []byte(`{"spell_casts":[]}`) + case itemevents.PromptID: + content = []byte(`{"events":[]}`) + case combat.PromptID: + content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`) + case npcinteractions.PromptID: + if combatScene { + content = []byte(`{"interactions":[{"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`) + } else { + content = []byte(`{"interactions":[]}`) + } + case enemyevents.PromptID: + content = []byte(`{"events":[{"name":"Kesh","kind":"fled","source_refs":[{"start_unit_id":10,"end_unit_id":10}]}]}`) + default: + return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", request.PromptID) + } + if err := json.Unmarshal(content, output); err != nil { + return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate fake structured target: %w", err) + } + client.mu.Lock() + client.requests = append(client.requests, request) + client.mu.Unlock() + return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "deterministic", ProfileID: request.ProfileID}, nil +} + +func (client *enemyEventLLMClient) requestsFor(promptID string) []contracts.StructuredCompletionRequest { + client.mu.Lock() + defer client.mu.Unlock() + var requests []contracts.StructuredCompletionRequest + for _, request := range client.requests { + if request.PromptID == promptID { + requests = append(requests, request) + } + } + return requests +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func evidenceHasLane(value evidencecontext.Document, laneID string) bool { + for _, context := range value.Contexts { + for _, reference := range context.EvidenceRefs { + if reference.LaneID == laneID { + return true + } + } + } + return false +} + +func generatedReferenceBinding(bindings []pipeline.ReferenceBinding, slotName string) (pipeline.ReferenceBinding, bool) { + for _, binding := range bindings { + if binding.SlotName == slotName && binding.Artifact != nil { + return binding, true + } + } + return pipeline.ReferenceBinding{}, false +} diff --git a/internal/cli/example_contract_test.go b/internal/cli/example_contract_test.go index 57711e0..cdca615 100644 --- a/internal/cli/example_contract_test.go +++ b/internal/cli/example_contract_test.go @@ -54,8 +54,8 @@ func TestMaintainedExamplesLoadResolveAndList(t *testing.T) { t.Fatalf("materialize maintained example references for %q: %v", pipelineID, err) } if example.name == "complete" { - if got := exampleStepLaneIDs(materialized); strings.Join(got, "|") != "describe-session:item-events,npcs,scene-descriptions|extract-events:combat-turns,npc-interactions,spells" { - t.Fatalf("complete example steps and lanes = %v, want every D&D extractor in the documented two-step composition", got) + if got := exampleStepLaneIDs(materialized); strings.Join(got, "|") != "describe-session:item-events,npcs,scene-descriptions|extract-events:combat-turns,npc-interactions,spells|track-enemies:enemy-events" { + t.Fatalf("complete example steps and lanes = %v, want the documented D&D extractor composition", got) } spellLane := referenceContractLane(t, materialized, "spells") if len(spellLane.ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 || @@ -71,6 +71,18 @@ func TestMaintainedExamplesLoadResolveAndList(t *testing.T) { t.Fatalf("item event lane unexpectedly depends on generated scene descriptions: %#v", itemEventLane) } } + enemyEventLane := referenceContractLane(t, materialized, "enemy-events") + for slot, want := range map[string]struct{ step, lane string }{ + "npcs": {step: "describe-session", lane: "npcs"}, + "scene_descriptions": {step: "describe-session", lane: "scene-descriptions"}, + "combat_turns": {step: "extract-events", lane: "combat-turns"}, + "npc_interactions": {step: "extract-events", lane: "npc-interactions"}, + } { + binding, found := generatedReferenceBinding(enemyEventLane.ExtractReferences.Bindings, slot) + if !found || binding.Artifact.Step != want.step || binding.Artifact.Lane != want.lane { + t.Fatalf("enemy event %s reference = %#v, want generated %s/%s artifact", slot, binding, want.step, want.lane) + } + } } } var stdout, stderr strings.Builder diff --git a/internal/cli/production_contract_test.go b/internal/cli/production_contract_test.go index d790937..4efb310 100644 --- a/internal/cli/production_contract_test.go +++ b/internal/cli/production_contract_test.go @@ -29,12 +29,15 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes" combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns" + enemyeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/enemyevents" itemeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemevents" spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells" combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" + enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents" itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" + enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents" itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents" spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop" @@ -47,9 +50,9 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { assertProductionContains(t, "inputs", registries.Inputs.RegisteredKeys(), []string{"seriatim"}) assertProductionContains(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes", "generic"}) - assertProductionContains(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", "dnd/npcs", combatextract.Key, itemeventextract.Key}) + assertProductionContains(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", "dnd/npcs", combatextract.Key, itemeventextract.Key, enemyeventextract.Key}) assertProductionContains(t, "mergers", registries.Mergers.RegisteredKeys(), []string{"appendorder"}) - assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop", spellnormalize.Key, "dnd/npcs", combatnormalize.Key, itemeventnormalize.Key}) + assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop", spellnormalize.Key, "dnd/npcs", combatnormalize.Key, itemeventnormalize.Key, enemyeventnormalize.Key}) assertProductionContains(t, "outputs", registries.Outputs.RegisteredKeys(), []string{"json"}) assertProductionContains(t, "validators", registries.Validators.RegisteredKeys(), []string{ "extract/dnd/spells/catalog", @@ -64,17 +67,22 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { "extract/dnd/item-events/source_refs", "extract/dnd/item-events/source_relatedness", "normalize/dnd/item-events/invariants", + "extract/dnd/enemy-events/shape", + "extract/dnd/enemy-events/source_refs", + "extract/dnd/enemy-events/source_relatedness", + "normalize/dnd/enemy-events/invariants", "generic/always_accept", "generic/always_reject", "generic/valid_json", "generic/valid_json_schema", }) - assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind}) - assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind}) - assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind}) + assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind}) + assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind}) + assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind}) assertProductionContains(t, "spell normalizer variants", registries.Normalizers.RegisteredArtifactKinds(spellnormalize.Key), []contracts.ArtifactKind{dnd.SpellListKind}) assertProductionContains(t, "combat normalizer variants", registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind}) assertProductionContains(t, "item event normalizer variants", registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemEventListKind}) + assertProductionContains(t, "enemy event normalizer variants", registries.Normalizers.RegisteredArtifactKinds(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind}) wantChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), @@ -162,6 +170,10 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { "dnd.item_events/sharedassets/common-dnd-system.md", "dnd.item_events/sharedassets/common-dnd-transcript.md", "dnd.item_events/task.md", + "dnd.enemy_events/dnd.enemy_events.yaml", + "dnd.enemy_events/grounding.md", + "dnd.enemy_events/instructions.md", + "dnd.enemy_events/task.md", } assertProductionContains(t, "production prompt assets", assetNames, requiredAssets) @@ -180,6 +192,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { {stage: pipeline.StageExtract, key: "dnd/item-events", want: contracts.ExecutionClassLLMBacked}, {stage: pipeline.StageExtract, key: "dnd/npc-interactions", want: contracts.ExecutionClassLLMBacked}, {stage: pipeline.StageExtract, key: "dnd/scene-descriptions", want: contracts.ExecutionClassLLMBacked}, + {stage: pipeline.StageExtract, key: enemyeventextract.Key, want: contracts.ExecutionClassLLMBacked}, {stage: pipeline.StageMerge, key: "appendorder", want: contracts.ExecutionClassDeterministic}, {stage: pipeline.StageNormalize, key: "noop", want: contracts.ExecutionClassDeterministic}, {stage: pipeline.StageNormalize, key: "dnd/spells", want: contracts.ExecutionClassDeterministic}, @@ -188,6 +201,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { {stage: pipeline.StageNormalize, key: "dnd/item-events", want: contracts.ExecutionClassDeterministic}, {stage: pipeline.StageNormalize, key: "dnd/npc-interactions", want: contracts.ExecutionClassDeterministic}, {stage: pipeline.StageNormalize, key: "dnd/scene-descriptions", want: contracts.ExecutionClassDeterministic}, + {stage: pipeline.StageNormalize, key: enemyeventnormalize.Key, want: contracts.ExecutionClassDeterministic}, {stage: pipeline.StageOutput, key: "json", want: contracts.ExecutionClassDeterministic}, } { got, ok := catalog.ExecutionClass(test.stage, test.key) @@ -211,6 +225,10 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { if !ok || itemEventCodecSpec.Kind != dnd.ItemEventListKind || itemEventCodecSpec.Schema.ID != itemeventcodec.SchemaID { t.Fatalf("item event codec spec = %#v, ok=%t, want typed D&D item-event codec", itemEventCodecSpec, ok) } + enemyEventCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.EnemyEventListKind) + if !ok || enemyEventCodecSpec.Kind != dnd.EnemyEventListKind || enemyEventCodecSpec.Schema.ID != enemyeventcodec.SchemaID { + t.Fatalf("enemy event codec spec = %#v, ok=%t, want typed D&D enemy-event codec", enemyEventCodecSpec, ok) + } if got := catalog.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) { t.Fatalf("catalog validator chain = %#v, want %#v", got, wantChain) } diff --git a/internal/modules/dnd/extract/enemyevents/grounding.go b/internal/modules/dnd/extract/enemyevents/grounding.go index a8213c2..247da7c 100644 --- a/internal/modules/dnd/extract/enemyevents/grounding.go +++ b/internal/modules/dnd/extract/enemyevents/grounding.go @@ -242,6 +242,9 @@ func referenceItem(references contracts.ReferenceSet, slotName, expectedMediaTyp if !ok { return contracts.ReferenceItem{}, false, nil } + if len(slot.Items) == 0 { + return contracts.ReferenceItem{}, false, nil + } if len(slot.Items) != 1 { return contracts.ReferenceItem{}, false, fmt.Errorf("reference slot %q must contain exactly one item", slotName) }