Tighten D&D prompt contracts and tests

This commit is contained in:
2026-08-05 17:38:51 +00:00
parent 4093ff2e8b
commit 299c110267
15 changed files with 78 additions and 331 deletions

View File

@@ -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:

View File

@@ -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" }}

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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)
}
}