From 299c11026745b86c9d543a973d66888542bf56a6 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 5 Aug 2026 17:38:51 +0000 Subject: [PATCH] Tighten D&D prompt contracts and tests --- .../npc-interactions/prompts/instructions.md | 6 +- .../dnd/npcs/normalize/prompts/candidates.md | 3 +- .../prompts/instructions.md | 3 +- internal/framework/llm/asset_registry_test.go | 12 ++-- internal/framework/promptfs/prompt_fs_test.go | 28 ++++---- .../extract/combatturns/prompt_assets_test.go | 34 ---------- .../extract/enemyevents/prompt_assets_test.go | 44 ------------ .../extract/itemevents/prompt_assets_test.go | 27 -------- .../locationoccurrences/prompt_assets_test.go | 36 +--------- .../extract/locations/prompt_assets_test.go | 32 +-------- .../npcinteractions/prompt_assets_test.go | 34 ---------- .../dnd/extract/npcs/prompt_assets_test.go | 27 -------- .../scenedescriptions/prompt_assets_test.go | 27 -------- .../dnd/extract/spells/prompt_assets_test.go | 29 -------- .../modules/dnd/register/prompt_cache_test.go | 67 +++++++++++++------ 15 files changed, 78 insertions(+), 331 deletions(-) diff --git a/assets/dnd/npc-interactions/prompts/instructions.md b/assets/dnd/npc-interactions/prompts/instructions.md index a64db94..56cea7a 100644 --- a/assets/dnd/npc-interactions/prompts/instructions.md +++ b/assets/dnd/npc-interactions/prompts/instructions.md @@ -5,9 +5,9 @@ Use only names from the supplied NPC registry. Do not summarize, infer relationships, sentiment, factions, motives, aliases, or persistent state. Do not identify player characters, anonymous groups, or -invented NPCs. Keep occurrences within this transcript chunk. Split records -when an NPC's interaction kind changes, when combat alignment changes, or when -an NPC is first mentioned and later becomes present. +invented NPCs. Split records when an NPC's interaction kind changes, when +combat alignment changes, or when an NPC is first mentioned and later becomes +present. Use exactly one kind per occurrence: diff --git a/assets/dnd/npcs/normalize/prompts/candidates.md b/assets/dnd/npcs/normalize/prompts/candidates.md index 7ba9fa8..87d35ce 100644 --- a/assets/dnd/npcs/normalize/prompts/candidates.md +++ b/assets/dnd/npcs/normalize/prompts/candidates.md @@ -1,4 +1,3 @@ -The supplied NPC candidates are below. Use only these display names in the -response. +NPC candidates for identity comparison: {{ input "candidates" }} diff --git a/assets/dnd/scene-descriptions/prompts/instructions.md b/assets/dnd/scene-descriptions/prompts/instructions.md index f105809..e019bce 100644 --- a/assets/dnd/scene-descriptions/prompts/instructions.md +++ b/assets/dnd/scene-descriptions/prompts/instructions.md @@ -42,5 +42,4 @@ must not invent a proper noun. The summary must briefly state the main activity and material transition or outcome established within the accepted chunk. Do not add analysis, inferred motives, hidden state, future consequences, relationship claims, or facts from -outside the chunk. Campaign references may disambiguate names but never add -events or lore. +outside the chunk. diff --git a/internal/framework/llm/asset_registry_test.go b/internal/framework/llm/asset_registry_test.go index 0c59a24..bbe45de 100644 --- a/internal/framework/llm/asset_registry_test.go +++ b/internal/framework/llm/asset_registry_test.go @@ -185,11 +185,13 @@ func TestAssetRegistryFallbackProfileDigestTracksContentWithoutLeakingIt(t *test func TestAssetRegistryCombinesNamespacedPromptSources(t *testing.T) { registry := NewAssetRegistry() mustRegisterPromptFS(t, registry, fstest.MapFS{ - "dnd.spells/prompt.yaml": {Data: []byte(validPromptYAML("schema.json"))}, + "dnd.spells/dnd.spells.yaml": {Data: []byte(validPromptYAML("schema.json"))}, + "dnd.spells/task.md": {Data: []byte("spell task")}, "dnd.spells/instructions.md": {Data: []byte("spell instructions")}, }, ".") mustRegisterPromptFS(t, registry, fstest.MapFS{ - "dnd.scenes/prompt.yaml": {Data: []byte(validPromptYAML("schema.json"))}, + "dnd.scenes/dnd.scenes.yaml": {Data: []byte(validPromptYAML("schema.json"))}, + "dnd.scenes/task.md": {Data: []byte("scene task")}, "dnd.scenes/instructions.md": {Data: []byte("scene instructions")}, }, ".") @@ -198,9 +200,11 @@ func TestAssetRegistryCombinesNamespacedPromptSources(t *testing.T) { t.Fatalf("PromptFS() error = %v, want nil", err) } for _, name := range []string{ - "dnd.spells/prompt.yaml", + "dnd.spells/dnd.spells.yaml", + "dnd.spells/task.md", "dnd.spells/instructions.md", - "dnd.scenes/prompt.yaml", + "dnd.scenes/dnd.scenes.yaml", + "dnd.scenes/task.md", "dnd.scenes/instructions.md", } { if _, err := fsys.Open(name); err != nil { diff --git a/internal/framework/promptfs/prompt_fs_test.go b/internal/framework/promptfs/prompt_fs_test.go index a894daf..38577a9 100644 --- a/internal/framework/promptfs/prompt_fs_test.go +++ b/internal/framework/promptfs/prompt_fs_test.go @@ -14,11 +14,11 @@ func TestModulePromptFSCombinesModuleAndSharedPrompts(t *testing.T) { "nested/transcript.md": {Data: []byte("transcript")}, } fsys, err := ModulePromptFS("module.test", fstest.MapFS{ - "assets/prompts/prompt.yaml": {Data: []byte("id: module.test")}, - "assets/prompts/instructions.md": {Data: []byte("instructions")}, + "assets/prompts/module.test.yaml": {Data: []byte("id: module.test")}, + "assets/prompts/task.md": {Data: []byte("task")}, }, []ModulePromptFile{ - {Name: "prompt.yaml", Path: "assets/prompts/prompt.yaml"}, - {Name: "instructions.md", Path: "assets/prompts/instructions.md"}, + {Name: "module.test.yaml", Path: "assets/prompts/module.test.yaml"}, + {Name: "task.md", Path: "assets/prompts/task.md"}, }, SharedPromptFile{Name: "system.md", FS: sharedFS, Path: "shared/system.md"}, SharedPromptFile{Name: "reference.md", FS: sharedFS, Path: "shared/reference.md"}, @@ -29,8 +29,8 @@ func TestModulePromptFSCombinesModuleAndSharedPrompts(t *testing.T) { } tests := map[string]string{ - "assets/prompts/module.test/prompt.yaml": "id: module.test", - "assets/prompts/module.test/instructions.md": "instructions", + "assets/prompts/module.test/module.test.yaml": "id: module.test", + "assets/prompts/module.test/task.md": "task", "assets/prompts/module.test/sharedassets/system.md": "system", "assets/prompts/module.test/sharedassets/reference.md": "reference", "assets/prompts/module.test/sharedassets/transcript.md": "transcript", @@ -63,18 +63,18 @@ func TestModulePromptFSCombinesModuleAndSharedPrompts(t *testing.T) { func TestModulePromptFSRejectsMissingModuleFile(t *testing.T) { _, err := ModulePromptFS("module.test", fstest.MapFS{}, []ModulePromptFile{ - {Name: "instructions.md", Path: "assets/prompts/instructions.md"}, + {Name: "task.md", Path: "assets/prompts/task.md"}, }) - if err == nil || !strings.Contains(err.Error(), "read module prompt asset assets/prompts/instructions.md") { + if err == nil || !strings.Contains(err.Error(), "read module prompt asset assets/prompts/task.md") { t.Fatalf("ModulePromptFS() error = %v, want missing module asset context", err) } } func TestModulePromptFSRejectsMissingSharedFile(t *testing.T) { _, err := ModulePromptFS("module.test", fstest.MapFS{ - "assets/prompts/instructions.md": {Data: []byte("instructions")}, + "assets/prompts/task.md": {Data: []byte("task")}, }, []ModulePromptFile{ - {Name: "instructions.md", Path: "assets/prompts/instructions.md"}, + {Name: "task.md", Path: "assets/prompts/task.md"}, }, SharedPromptFile{Name: "system.md", FS: fstest.MapFS{}, Path: "shared/system.md"}) if err == nil || !strings.Contains(err.Error(), "read shared prompt asset shared/system.md") { t.Fatalf("ModulePromptFS() error = %v, want missing shared asset context", err) @@ -83,9 +83,9 @@ func TestModulePromptFSRejectsMissingSharedFile(t *testing.T) { func TestModulePromptFSRejectsNestedVirtualFileName(t *testing.T) { _, err := ModulePromptFS("module.test", fstest.MapFS{ - "assets/prompts/instructions.md": {Data: []byte("instructions")}, + "assets/prompts/task.md": {Data: []byte("task")}, }, []ModulePromptFile{ - {Name: "nested/instructions.md", Path: "assets/prompts/instructions.md"}, + {Name: "nested/task.md", Path: "assets/prompts/task.md"}, }) if err == nil || !strings.Contains(err.Error(), "must not contain path separators") { t.Fatalf("ModulePromptFS() error = %v, want nested file name error", err) @@ -94,9 +94,9 @@ func TestModulePromptFSRejectsNestedVirtualFileName(t *testing.T) { func TestModulePromptFSRejectsNestedSharedFileName(t *testing.T) { _, err := ModulePromptFS("module.test", fstest.MapFS{ - "assets/prompts/instructions.md": {Data: []byte("instructions")}, + "assets/prompts/task.md": {Data: []byte("task")}, }, []ModulePromptFile{ - {Name: "instructions.md", Path: "assets/prompts/instructions.md"}, + {Name: "task.md", Path: "assets/prompts/task.md"}, }, SharedPromptFile{Name: "nested/system.md", FS: fstest.MapFS{}, Path: "shared/system.md"}) if err == nil || !strings.Contains(err.Error(), "shared prompt file name") || !strings.Contains(err.Error(), "must not contain path separators") { t.Fatalf("ModulePromptFS() error = %v, want nested shared file name error", err) diff --git a/internal/modules/dnd/extract/combatturns/prompt_assets_test.go b/internal/modules/dnd/extract/combatturns/prompt_assets_test.go index 737085b..c4283da 100644 --- a/internal/modules/dnd/extract/combatturns/prompt_assets_test.go +++ b/internal/modules/dnd/extract/combatturns/prompt_assets_test.go @@ -62,38 +62,4 @@ func TestPromptAssetsPrepareRequiredInputs(t *testing.T) { if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_combat_turns_llm.v1.json" { t.Fatalf("prepared prompt = %#v, want combat prompt identity and schema", prepared) } - referenceIndex := -1 - transcriptIndex := -1 - npcIndex := -1 - for index, message := range prepared.Messages { - if strings.Contains(message.Content, "combat-player") { - referenceIndex = index - } - if strings.Contains(message.Content, "combat-transcript") { - transcriptIndex = index - } - if strings.Contains(message.Content, "combat-npc") { - npcIndex = index - } - } - instructionsIndex := len(prepared.Messages) - 1 - if referenceIndex < 0 || transcriptIndex <= referenceIndex || npcIndex <= transcriptIndex || instructionsIndex <= npcIndex { - t.Fatalf("message order = references %d, transcript %d, NPCs %d, instructions %d; want that order", referenceIndex, transcriptIndex, npcIndex, instructionsIndex) - } - for _, index := range []int{referenceIndex, transcriptIndex, instructionsIndex} { - if cache := prepared.Messages[index].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral { - t.Fatalf("message %d cache control = %#v, want ephemeral", index, cache) - } - } - for index, message := range prepared.Messages { - if index != referenceIndex && strings.Contains(message.Content, "combat-player") { - t.Errorf("message %d unexpectedly rendered campaign-reference input", index) - } - if index != transcriptIndex && strings.Contains(message.Content, "combat-transcript") { - t.Errorf("message %d unexpectedly rendered transcript input", index) - } - if index != npcIndex && strings.Contains(message.Content, "combat-npc") { - t.Errorf("message %d unexpectedly rendered NPC grounding input", index) - } - } } diff --git a/internal/modules/dnd/extract/enemyevents/prompt_assets_test.go b/internal/modules/dnd/extract/enemyevents/prompt_assets_test.go index f3f82e8..632603f 100644 --- a/internal/modules/dnd/extract/enemyevents/prompt_assets_test.go +++ b/internal/modules/dnd/extract/enemyevents/prompt_assets_test.go @@ -28,33 +28,6 @@ func TestRegisterPromptAssetsAndPrepareEnemyEventPrompt(t *testing.T) { if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_enemy_events_llm.v1.json" || prepared.SelectedProfileID != "dnd-extraction" { t.Fatalf("prepared prompt = %#v", prepared) } - referenceIndex := renderedMessageIndex(t, prepared.Messages, "enemy-player") - transcriptIndex := renderedMessageIndex(t, prepared.Messages, "enemy-transcript") - if transcriptIndex <= referenceIndex { - t.Fatalf("transcript message index = %d, want after references index %d", transcriptIndex, referenceIndex) - } - groundingIndices := make([]int, 0, 3) - for _, sentinel := range []string{"enemy-npc", "enemy-turn", "enemy-opponent"} { - if index := renderedMessageIndex(t, prepared.Messages, sentinel); index <= transcriptIndex { - t.Fatalf("message containing %q has index %d, want after transcript index %d", sentinel, index, transcriptIndex) - } else { - groundingIndices = append(groundingIndices, index) - } - } - instructionIndex := len(prepared.Messages) - 1 - for _, index := range groundingIndices { - if instructionIndex <= index { - t.Fatalf("instruction index = %d, want after grounding index %d", instructionIndex, index) - } - } - if instructionIndex != len(prepared.Messages)-1 { - t.Fatalf("instruction message index = %d, want final message", instructionIndex) - } - for _, index := range []int{referenceIndex, transcriptIndex, instructionIndex} { - if cache := prepared.Messages[index].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral { - t.Fatalf("message %d cache control = %#v, want ephemeral", index, cache) - } - } } func TestEnemyEventPromptRequiresGroundingInputs(t *testing.T) { @@ -115,20 +88,3 @@ func enemyEventPromptInputs() map[string]promptkit.ArtifactRef { "npc_interactions": promptkit.Inline(`{"npc_interactions":[{"name":"enemy-opponent","kind":"combat_opponent"}]}`), } } - -func renderedMessageIndex(t *testing.T, messages []promptkit.RenderedMessage, sentinel string) int { - t.Helper() - index := -1 - occurrences := 0 - for messageIndex, message := range messages { - count := strings.Count(message.Content, sentinel) - if count > 0 { - index = messageIndex - occurrences += count - } - } - if occurrences != 1 { - t.Fatalf("message sentinel %q rendered %d times, want exactly once", sentinel, occurrences) - } - return index -} diff --git a/internal/modules/dnd/extract/itemevents/prompt_assets_test.go b/internal/modules/dnd/extract/itemevents/prompt_assets_test.go index 5e0858e..7cf7c7d 100644 --- a/internal/modules/dnd/extract/itemevents/prompt_assets_test.go +++ b/internal/modules/dnd/extract/itemevents/prompt_assets_test.go @@ -42,33 +42,6 @@ func TestPromptAssetsPrepareItemEventPrompt(t *testing.T) { if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_item_events_llm.v1.json" { t.Fatalf("prepared prompt = %#v", prepared) } - referenceIndex := -1 - transcriptIndex := -1 - for index, message := range prepared.Messages { - if strings.Contains(message.Content, "item-event-player") { - referenceIndex = index - } - if strings.Contains(message.Content, "item-event-transcript") { - transcriptIndex = index - } - } - instructionsIndex := len(prepared.Messages) - 1 - if referenceIndex < 0 || transcriptIndex <= referenceIndex || transcriptIndex >= instructionsIndex { - t.Fatalf("message order = references %d, transcript %d, instructions %d; want that order", referenceIndex, transcriptIndex, instructionsIndex) - } - for _, index := range []int{referenceIndex, transcriptIndex, instructionsIndex} { - if cache := prepared.Messages[index].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral { - t.Fatalf("message %d cache control = %#v, want ephemeral", index, cache) - } - } - for index, message := range prepared.Messages { - if index != referenceIndex && strings.Contains(message.Content, "item-event-player") { - t.Errorf("message %d unexpectedly rendered campaign-reference input", index) - } - if index != transcriptIndex && strings.Contains(message.Content, "item-event-transcript") { - t.Errorf("message %d unexpectedly rendered transcript input", index) - } - } } func TestPromptAssetsDoNotLeakIntoMetadata(t *testing.T) { diff --git a/internal/modules/dnd/extract/locationoccurrences/prompt_assets_test.go b/internal/modules/dnd/extract/locationoccurrences/prompt_assets_test.go index 4c57d1d..73beab5 100644 --- a/internal/modules/dnd/extract/locationoccurrences/prompt_assets_test.go +++ b/internal/modules/dnd/extract/locationoccurrences/prompt_assets_test.go @@ -40,46 +40,12 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) { t.Fatalf("prepared prompt = %#v", prepared) } var registryMessage string - referenceIndex := -1 - transcriptIndex := -1 - registryIndex := -1 - evidenceIndex := -1 - for index, message := range prepared.Messages { - if strings.Contains(message.Content, "location-occurrence-player") { - referenceIndex = index - } - if strings.Contains(message.Content, "location-occurrence-transcript") { - transcriptIndex = index - } + for _, message := range prepared.Messages { if strings.Contains(message.Content, "normalized location registry") { registryMessage = message.Content - registryIndex = index - } - if strings.Contains(message.Content, "Transcript units are the only evidence") { - evidenceIndex = index } } if !strings.Contains(registryMessage, "location:sha256:test") || !strings.Contains(registryMessage, "location-occurrence-registry") || strings.Contains(registryMessage, "source_refs") { t.Fatalf("rendered prompt did not preserve source-free registry grounding: %s", registryMessage) } - instructionsIndex := len(prepared.Messages) - 1 - if referenceIndex < 0 || transcriptIndex <= referenceIndex || evidenceIndex <= transcriptIndex || registryIndex <= evidenceIndex || instructionsIndex <= registryIndex { - t.Fatalf("message order = references %d, transcript %d, evidence %d, registry %d, instructions %d; want that order", referenceIndex, transcriptIndex, evidenceIndex, registryIndex, instructionsIndex) - } - for _, index := range []int{referenceIndex, transcriptIndex, instructionsIndex} { - if cache := prepared.Messages[index].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral { - t.Fatalf("message %d cache control = %#v, want ephemeral", index, cache) - } - } - for index, message := range prepared.Messages { - if index != referenceIndex && strings.Contains(message.Content, "location-occurrence-player") { - t.Errorf("message %d unexpectedly rendered campaign-reference input", index) - } - if index != transcriptIndex && strings.Contains(message.Content, "location-occurrence-transcript") { - t.Errorf("message %d unexpectedly rendered transcript input", index) - } - if index != registryIndex && strings.Contains(message.Content, "location-occurrence-registry") { - t.Errorf("message %d unexpectedly rendered registry grounding input", index) - } - } } diff --git a/internal/modules/dnd/extract/locations/prompt_assets_test.go b/internal/modules/dnd/extract/locations/prompt_assets_test.go index 64697c8..5b9f629 100644 --- a/internal/modules/dnd/extract/locations/prompt_assets_test.go +++ b/internal/modules/dnd/extract/locations/prompt_assets_test.go @@ -2,7 +2,6 @@ package locations import ( "context" - "strings" "testing" "time" @@ -30,34 +29,7 @@ func TestRegisterPromptAssetsPreparesLocationPrompt(t *testing.T) { if err != nil { t.Fatalf("Prepare() error = %v", err) } - if prepared.OutputContract.SchemaPath != "dnd_locations_llm.v1.json" { - t.Fatalf("output contract = %#v", prepared.OutputContract) - } - referenceIndex := -1 - transcriptIndex := -1 - for index, message := range prepared.Messages { - if strings.Contains(message.Content, "location-player") { - referenceIndex = index - } - if strings.Contains(message.Content, "location-transcript") { - transcriptIndex = index - } - } - instructionsIndex := len(prepared.Messages) - 1 - if referenceIndex < 0 || transcriptIndex <= referenceIndex || transcriptIndex >= instructionsIndex { - t.Fatalf("message order = references %d, transcript %d, instructions %d; want that order", referenceIndex, transcriptIndex, instructionsIndex) - } - for _, index := range []int{referenceIndex, transcriptIndex, instructionsIndex} { - if prepared.Messages[index].CacheControl == nil || prepared.Messages[index].CacheControl.Type != promptkit.CacheControlEphemeral { - t.Fatalf("message %d cache control = %#v, want ephemeral", index, prepared.Messages[index].CacheControl) - } - } - for index, message := range prepared.Messages { - if index != referenceIndex && strings.Contains(message.Content, "location-player") { - t.Errorf("message %d unexpectedly rendered campaign-reference input", index) - } - if index != transcriptIndex && strings.Contains(message.Content, "location-transcript") { - t.Errorf("message %d unexpectedly rendered transcript input", index) - } + if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_locations_llm.v1.json" { + t.Fatalf("prepared prompt = %#v, want location prompt identity and schema wiring", prepared) } } diff --git a/internal/modules/dnd/extract/npcinteractions/prompt_assets_test.go b/internal/modules/dnd/extract/npcinteractions/prompt_assets_test.go index b6c0bae..48c1105 100644 --- a/internal/modules/dnd/extract/npcinteractions/prompt_assets_test.go +++ b/internal/modules/dnd/extract/npcinteractions/prompt_assets_test.go @@ -51,40 +51,6 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) { if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npc_interactions_llm.v1.json" { t.Fatalf("prepared prompt = %#v", prepared) } - referenceIndex := -1 - transcriptIndex := -1 - npcIndex := -1 - for index, message := range prepared.Messages { - if strings.Contains(message.Content, "interaction-player") { - referenceIndex = index - } - if strings.Contains(message.Content, "interaction-transcript") { - transcriptIndex = index - } - if strings.Contains(message.Content, "interaction-npc") { - npcIndex = index - } - } - instructionsIndex := len(prepared.Messages) - 1 - if referenceIndex < 0 || transcriptIndex <= referenceIndex || npcIndex <= transcriptIndex || instructionsIndex <= npcIndex { - t.Fatalf("message order = references %d, transcript %d, NPCs %d, instructions %d; want that order", referenceIndex, transcriptIndex, npcIndex, instructionsIndex) - } - for _, index := range []int{referenceIndex, transcriptIndex, instructionsIndex} { - if cache := prepared.Messages[index].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral { - t.Fatalf("message %d cache control = %#v, want ephemeral", index, cache) - } - } - for index, message := range prepared.Messages { - if index != referenceIndex && strings.Contains(message.Content, "interaction-player") { - t.Errorf("message %d unexpectedly rendered campaign-reference input", index) - } - if index != transcriptIndex && strings.Contains(message.Content, "interaction-transcript") { - t.Errorf("message %d unexpectedly rendered transcript input", index) - } - if index != npcIndex && strings.Contains(message.Content, "interaction-npc") { - t.Errorf("message %d unexpectedly rendered NPC grounding input", index) - } - } } func TestPromptMetadataDoesNotExposeAssetContent(t *testing.T) { diff --git a/internal/modules/dnd/extract/npcs/prompt_assets_test.go b/internal/modules/dnd/extract/npcs/prompt_assets_test.go index f63b722..5807448 100644 --- a/internal/modules/dnd/extract/npcs/prompt_assets_test.go +++ b/internal/modules/dnd/extract/npcs/prompt_assets_test.go @@ -42,33 +42,6 @@ func TestRegisterPromptAssetsAndPrepareNPCPrompt(t *testing.T) { if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npcs_llm.v1.json" { t.Fatalf("prepared prompt = %#v, want NPC prompt identity and wiring", prepared) } - referenceIndex := -1 - transcriptIndex := -1 - for index, message := range prepared.Messages { - if strings.Contains(message.Content, "npc-player") { - referenceIndex = index - } - if strings.Contains(message.Content, "npc-transcript") { - transcriptIndex = index - } - } - instructionsIndex := len(prepared.Messages) - 1 - if referenceIndex < 0 || transcriptIndex <= referenceIndex || transcriptIndex >= instructionsIndex { - t.Fatalf("message order = references %d, transcript %d, instructions %d; want that order", referenceIndex, transcriptIndex, instructionsIndex) - } - for _, index := range []int{referenceIndex, transcriptIndex, instructionsIndex} { - if cache := prepared.Messages[index].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral { - t.Fatalf("message %d cache control = %#v, want ephemeral", index, cache) - } - } - for index, message := range prepared.Messages { - if index != referenceIndex && strings.Contains(message.Content, "npc-player") { - t.Errorf("message %d unexpectedly rendered campaign-reference input", index) - } - if index != transcriptIndex && strings.Contains(message.Content, "npc-transcript") { - t.Errorf("message %d unexpectedly rendered transcript input", index) - } - } } func TestPromptMetadataAndDiagnosticsDoNotContainRawAssets(t *testing.T) { diff --git a/internal/modules/dnd/extract/scenedescriptions/prompt_assets_test.go b/internal/modules/dnd/extract/scenedescriptions/prompt_assets_test.go index c92608f..1ae1f18 100644 --- a/internal/modules/dnd/extract/scenedescriptions/prompt_assets_test.go +++ b/internal/modules/dnd/extract/scenedescriptions/prompt_assets_test.go @@ -42,33 +42,6 @@ func TestRegisterPromptAssetsPreparesSceneDescriptionPrompt(t *testing.T) { if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_scene_descriptions_llm.v1.json" { t.Fatalf("prepared prompt = %#v, want scene-description prompt identity and schema wiring", prepared) } - referenceIndex := -1 - transcriptIndex := -1 - for index, message := range prepared.Messages { - if strings.Contains(message.Content, "scene-description-player") { - referenceIndex = index - } - if strings.Contains(message.Content, "scene-description-transcript") { - transcriptIndex = index - } - } - instructionsIndex := len(prepared.Messages) - 1 - if referenceIndex < 0 || transcriptIndex <= referenceIndex || transcriptIndex >= instructionsIndex { - t.Fatalf("message order = references %d, transcript %d, instructions %d; want that order", referenceIndex, transcriptIndex, instructionsIndex) - } - for _, index := range []int{referenceIndex, transcriptIndex, instructionsIndex} { - if cache := prepared.Messages[index].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral { - t.Fatalf("message %d cache control = %#v, want ephemeral", index, cache) - } - } - for index, message := range prepared.Messages { - if index != referenceIndex && strings.Contains(message.Content, "scene-description-player") { - t.Errorf("message %d unexpectedly rendered campaign-reference input", index) - } - if index != transcriptIndex && strings.Contains(message.Content, "scene-description-transcript") { - t.Errorf("message %d unexpectedly rendered transcript input", index) - } - } } func TestPromptMetadataAndDiagnosticsDoNotContainRawAssets(t *testing.T) { diff --git a/internal/modules/dnd/extract/spells/prompt_assets_test.go b/internal/modules/dnd/extract/spells/prompt_assets_test.go index fa6a798..56ed650 100644 --- a/internal/modules/dnd/extract/spells/prompt_assets_test.go +++ b/internal/modules/dnd/extract/spells/prompt_assets_test.go @@ -21,35 +21,6 @@ func TestPromptAssetsPrepareSpellPrompt(t *testing.T) { if prepared.OutputContract.SchemaPath != "dnd_spells_llm.v1.json" { t.Fatalf("schema path = %q, want LLM-only schema", prepared.OutputContract.SchemaPath) } - indices := map[string]int{ - "spell-player": -1, - "spell-transcript": -1, - "spell-npc-sentinel": -1, - "spell-catalog-sentinel": -1, - } - for index, message := range prepared.Messages { - for sentinel := range indices { - if strings.Contains(message.Content, sentinel) { - indices[sentinel] = index - } - } - } - instructionsIndex := len(prepared.Messages) - 1 - if indices["spell-player"] < 0 || indices["spell-transcript"] <= indices["spell-player"] || indices["spell-npc-sentinel"] <= indices["spell-transcript"] || indices["spell-catalog-sentinel"] <= indices["spell-npc-sentinel"] || instructionsIndex <= indices["spell-catalog-sentinel"] { - t.Fatalf("message order = %#v with instructions at %d, want references, transcript, NPCs, catalog, instructions", indices, instructionsIndex) - } - for _, index := range []int{indices["spell-player"], indices["spell-transcript"], instructionsIndex} { - if cache := prepared.Messages[index].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral { - t.Fatalf("message %d cache control = %#v, want ephemeral", index, cache) - } - } - for sentinel, index := range indices { - for messageIndex, message := range prepared.Messages { - if messageIndex != index && strings.Contains(message.Content, sentinel) { - t.Errorf("message %d unexpectedly rendered %q", messageIndex, sentinel) - } - } - } } func TestPromptAssetsPrepareWithMissingOptionalReferences(t *testing.T) { diff --git a/internal/modules/dnd/register/prompt_cache_test.go b/internal/modules/dnd/register/prompt_cache_test.go index b0466c9..7041cd7 100644 --- a/internal/modules/dnd/register/prompt_cache_test.go +++ b/internal/modules/dnd/register/prompt_cache_test.go @@ -20,7 +20,7 @@ import ( "gitea.maximumdirect.net/eric/promptkit" ) -func TestExtractionPromptsShareRenderedPrefix(t *testing.T) { +func TestExtractionPromptComposition(t *testing.T) { const ( transcriptSentinel = "shared-transcript-sentinel" playersSentinel = "shared-players-sentinel" @@ -28,6 +28,7 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) { glossarySentinel = "shared-glossary-sentinel" npcSentinel = "npc-registry-sentinel" catalogSentinel = "spell-catalog-sentinel" + evidenceSentinel = "Transcript units are the only evidence" ) registry := llm.NewAssetRegistry() if err := registerPromptAssets(registry); err != nil { @@ -41,15 +42,15 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) { "glossary": promptkit.Inline(glossarySentinel), } cases := []struct { - name string - promptID string - promptVersion string - inputs map[string]promptkit.ArtifactRef - inputSentinels []string + name string + promptID string + promptVersion string + inputs map[string]promptkit.ArtifactRef + suffixGroups [][]string }{ - {name: "npcs", promptID: npcextract.PromptID, promptVersion: npcextract.SchemaVersion, inputs: commonInputs}, - {name: "locations", promptID: locationextract.PromptID, promptVersion: locationextract.SchemaVersion, inputs: commonInputs}, - {name: "item events", promptID: itemeventextract.PromptID, promptVersion: itemeventextract.SchemaVersion, inputs: commonInputs}, + {name: "npcs", promptID: npcextract.PromptID, promptVersion: npcextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}}, + {name: "locations", promptID: locationextract.PromptID, promptVersion: locationextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}}, + {name: "item events", promptID: itemeventextract.PromptID, promptVersion: itemeventextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}}, {name: "scene descriptions", promptID: scenedescriptionextract.PromptID, promptVersion: scenedescriptionextract.SchemaVersion, inputs: commonInputs}, { name: "combat turns", @@ -58,7 +59,7 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) { inputs: withPromptInputs(commonInputs, map[string]promptkit.ArtifactRef{ "npcs": promptkit.Inline(`{"sentinel":"` + npcSentinel + `"}`), }), - inputSentinels: []string{npcSentinel}, + suffixGroups: [][]string{{evidenceSentinel}, {npcSentinel}}, }, { name: "enemy events", @@ -69,7 +70,11 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) { "combat_turns": promptkit.Inline(`{"sentinel":"combat-turns-sentinel"}`), "npc_interactions": promptkit.Inline(`{"sentinel":"npc-interactions-sentinel"}`), }), - inputSentinels: []string{npcSentinel, "combat-turns-sentinel", "npc-interactions-sentinel"}, + suffixGroups: [][]string{ + {evidenceSentinel}, + {npcSentinel}, + {"combat-turns-sentinel", "npc-interactions-sentinel"}, + }, }, { name: "npc interactions", @@ -78,7 +83,7 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) { inputs: withPromptInputs(commonInputs, map[string]promptkit.ArtifactRef{ "npcs": promptkit.Inline(`{"sentinel":"` + npcSentinel + `"}`), }), - inputSentinels: []string{npcSentinel}, + suffixGroups: [][]string{{evidenceSentinel}, {npcSentinel}}, }, { name: "location occurrences", @@ -87,7 +92,7 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) { inputs: withPromptInputs(commonInputs, map[string]promptkit.ArtifactRef{ "locations": promptkit.Inline(`{"sentinel":"location-registry-sentinel"}`), }), - inputSentinels: []string{"location-registry-sentinel"}, + suffixGroups: [][]string{{evidenceSentinel}, {"location-registry-sentinel"}}, }, { name: "spells", @@ -97,7 +102,7 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) { "npcs": promptkit.Inline(`{"sentinel":"` + npcSentinel + `"}`), "spell_catalog": promptkit.Inline(`{"sentinel":"` + catalogSentinel + `"}`), }), - inputSentinels: []string{npcSentinel, catalogSentinel}, + suffixGroups: [][]string{{evidenceSentinel}, {npcSentinel}, {catalogSentinel}}, }, } @@ -114,6 +119,12 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) { t.Fatalf("Prepare() error = %v", err) } transcriptIndex := renderedInputMessageIndex(t, prepared.Messages, transcriptSentinel) + referenceIndex := renderedInputMessageIndex(t, prepared.Messages, playersSentinel) + if referenceIndex >= transcriptIndex { + t.Fatalf("campaign references rendered at message %d, want before transcript message %d", referenceIndex, transcriptIndex) + } + assertEphemeralCache(t, prepared.Messages, referenceIndex) + assertEphemeralCache(t, prepared.Messages, transcriptIndex) prefix := prepared.Messages[:transcriptIndex+1] if len(prepared.Messages) <= len(prefix) { t.Fatalf("prepared prompt has %d messages, want lane-specific suffix after transcript", len(prepared.Messages)) @@ -123,9 +134,27 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) { } else if !reflect.DeepEqual(prefix, sharedPrefix) { t.Fatalf("rendered prefix = %#v, want %#v", prefix, sharedPrefix) } - for _, sentinel := range testCase.inputSentinels { - assertRenderedInputAfter(t, prepared.Messages, sentinel, transcriptIndex) + previousIndex := transcriptIndex + for _, group := range testCase.suffixGroups { + groupIndex := -1 + for _, sentinel := range group { + inputIndex := renderedInputMessageIndex(t, prepared.Messages, sentinel) + if groupIndex < 0 { + groupIndex = inputIndex + } else if inputIndex != groupIndex { + t.Fatalf("input sentinel %q rendered at message %d, want grouped at message %d", sentinel, inputIndex, groupIndex) + } + } + if groupIndex <= previousIndex { + t.Fatalf("prompt suffix group rendered at message %d, want after message %d", groupIndex, previousIndex) + } + previousIndex = groupIndex } + instructionIndex := len(prepared.Messages) - 1 + if instructionIndex <= previousIndex { + t.Fatalf("instructions rendered at message %d, want after lane input message %d", instructionIndex, previousIndex) + } + assertEphemeralCache(t, prepared.Messages, instructionIndex) }) } } @@ -157,10 +186,10 @@ func withPromptInputs(inputs, extras map[string]promptkit.ArtifactRef) map[strin return merged } -func assertRenderedInputAfter(t *testing.T, messages []promptkit.RenderedMessage, sentinel string, index int) { +func assertEphemeralCache(t *testing.T, messages []promptkit.RenderedMessage, index int) { t.Helper() - if inputIndex := renderedInputMessageIndex(t, messages, sentinel); inputIndex <= index { - t.Fatalf("input sentinel %q rendered at message %d, want after transcript message %d", sentinel, inputIndex, index) + if cache := messages[index].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral { + t.Fatalf("message %d cache control = %#v, want ephemeral", index, cache) } }