diff --git a/assets/dnd/combat-turns/prompts/instructions.md b/assets/dnd/combat-turns/prompts/instructions.md index adf55c2..26d04e5 100644 --- a/assets/dnd/combat-turns/prompts/instructions.md +++ b/assets/dnd/combat-turns/prompts/instructions.md @@ -1,6 +1,18 @@ -Return the combat_turns array even when no combat turn is established. Return -actor, turn_kind, and source_refs for every record. For turn_kind, use exactly -one of: turn, reaction, legendary_action, lair_action, or other. Cite the -transcript ranges that establish both the actor and the combat event. Use the -players, party, and transcript context to map speakers to in-world actors. NPC -names may help disambiguate identity but do not replace transcript evidence. +Extract Dungeons & Dragons combat-turn artifacts from the supplied transcript. +Include a record only when the transcript establishes that an in-world +participant takes a combat turn or performs a discrete interrupting combat +event. Keep events in transcript chronology; place an interrupting event where +it occurs. + +Exclude initiative setup without a turn or combat event, tactical planning, +table talk, rules lookup, hypothetical events, abandoned intentions, recaps +outside the current passage, and downstream consequences. Do not infer combat +events from Dungeons & Dragons rules knowledge. Preserve the session as played +and attribute relevant nonstandard rulings to the GM or table. Unmatched actors +remain permitted. + +Treat each record as one turn-level event and keep its supporting transcript +evidence together. Use `turn` for a regular combat turn, `reaction` for an +off-turn reaction, `legendary_action` for a legendary action, +`lair_action` for a lair action, and `other` for another discrete combat +event that does not fit those categories. diff --git a/assets/dnd/combat-turns/prompts/prompt.yaml b/assets/dnd/combat-turns/prompts/prompt.yaml index 1d298b3..e709214 100644 --- a/assets/dnd/combat-turns/prompts/prompt.yaml +++ b/assets/dnd/combat-turns/prompts/prompt.yaml @@ -34,8 +34,6 @@ messages: content_file: ./sharedassets/common-dnd-extraction-evidence.md - role: user content_file: ./sharedassets/common-dnd-npcs.md - - role: user - content_file: ./task.md - role: user content_file: ./instructions.md cache_control: diff --git a/assets/dnd/combat-turns/prompts/task.md b/assets/dnd/combat-turns/prompts/task.md deleted file mode 100644 index 3125037..0000000 --- a/assets/dnd/combat-turns/prompts/task.md +++ /dev/null @@ -1,18 +0,0 @@ -Extract Dungeons & Dragons combat-turn artifacts from the supplied transcript. - -Include a record only when the transcript establishes that an in-world -participant takes a combat turn or performs a discrete interrupting combat -event. Interrupting events belong at the point where they occur in transcript -chronology. - -Exclude initiative setup without a turn or combat event, tactical planning, -table talk, rules lookup, hypothetical events, abandoned intentions, recaps -outside the current passage, and downstream consequences. - -Do not infer combat events from D&D rules knowledge. Preserve the session as -played and attribute relevant nonstandard rulings to the GM or table. - -Unmatched actors remain permitted. - -Place all supporting transcript ranges for a turn in its turn-level source_refs -collection. diff --git a/assets/dnd/spells/prompts/instructions.md b/assets/dnd/spells/prompts/instructions.md index e4cd186..eccb32a 100644 --- a/assets/dnd/spells/prompts/instructions.md +++ b/assets/dnd/spells/prompts/instructions.md @@ -1,16 +1,14 @@ -For each spell cast, source references must collectively support the in-world -caster, spell name, and the fact that the cast or declared attempt occurred. +Extract Dungeons & Dragons spell-cast artifacts from the provided transcript. +Include an actual casting event or an unambiguous declared casting attempt. +Exclude spell mentions, hypothetical plans, rules discussion, and catalog +matches that do not establish a casting event in the transcript. -Return only D&D spell-cast artifacts. For each record, identify the in-world -caster, canonical spell name, and source references. +For every extracted cast, the transcript evidence must collectively support the +in-world caster, the spell, and the fact that the cast or declared attempt +occurred. -Use the player and party references together with transcript context to map -first-person player speech to the associated player character and use the -canonical character name from the references. Likewise, attribute a spell -narrated by the GM to the in-world creature that casts it. If the caster cannot -be resolved, use only the most specific in-world identity supported by the -transcript; do not invent a name. - -Use the canonical spell-name catalog to select spell names. Do not return a -spell name absent from that catalog, even when it is suggested by general D&D -knowledge or reference material. +Attribute every cast to its in-world caster. Map first-person player speech to +the associated player character, and attribute a spell narrated by the GM to +the in-world creature that casts it. If the caster cannot be resolved, use only +the most specific in-world identity supported by the transcript; do not invent +a name. diff --git a/assets/dnd/spells/prompts/prompt.yaml b/assets/dnd/spells/prompts/prompt.yaml index 2b65460..a16e291 100644 --- a/assets/dnd/spells/prompts/prompt.yaml +++ b/assets/dnd/spells/prompts/prompt.yaml @@ -39,8 +39,6 @@ messages: content_file: ./sharedassets/common-dnd-npcs.md - role: user content_file: ./spell-catalog.md - - role: user - content_file: ./task.md - role: user content_file: ./instructions.md cache_control: diff --git a/assets/dnd/spells/prompts/task.md b/assets/dnd/spells/prompts/task.md deleted file mode 100644 index 9d09d97..0000000 --- a/assets/dnd/spells/prompts/task.md +++ /dev/null @@ -1,9 +0,0 @@ -Extract Dungeons & Dragons spell-cast artifacts from the provided transcript. - -Include an actual casting event or an unambiguous declared casting attempt. -Exclude spell mentions, hypothetical plans, rules discussion, and catalog -matches that do not establish a casting event in the transcript. - -Use the provided canonical spell-name catalog when naming each extracted spell. -Return the canonical catalog spelling exactly. The catalog is a recognition -aid and never evidence that a spell was cast. diff --git a/internal/modules/dnd/extract/combatturns/prompt_assets.go b/internal/modules/dnd/extract/combatturns/prompt_assets.go index 471dc53..c3fe280 100644 --- a/internal/modules/dnd/extract/combatturns/prompt_assets.go +++ b/internal/modules/dnd/extract/combatturns/prompt_assets.go @@ -17,7 +17,6 @@ var promptAssetManifest = shared.PromptAssetManifest{ ModuleDir: "dnd.combat_turns", ModuleFiles: []promptfs.ModulePromptFile{ {Name: "prompt.yaml", Path: "prompts/prompt.yaml"}, - {Name: "task.md", Path: "prompts/task.md"}, {Name: "instructions.md", Path: "prompts/instructions.md"}, }, SharedFiles: []string{ diff --git a/internal/modules/dnd/extract/combatturns/prompt_assets_test.go b/internal/modules/dnd/extract/combatturns/prompt_assets_test.go index 51775e0..e752bd0 100644 --- a/internal/modules/dnd/extract/combatturns/prompt_assets_test.go +++ b/internal/modules/dnd/extract/combatturns/prompt_assets_test.go @@ -45,15 +45,15 @@ func TestPromptAssetsPrepareRequiredInputs(t *testing.T) { if err != nil { t.Fatalf("NewEngine() error = %v, want nil", err) } - transcript := `{"units":[1]}` + transcript := `{"units":[{"sentinel":"combat-transcript"}]}` prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{ PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "combat-test-profile", Inputs: map[string]promptkit.ArtifactRef{ "transcript": promptkit.InlineWithURI("file:///session.json", transcript), - "players": promptkit.Inline(" "), + "players": promptkit.Inline("combat-player"), "party": promptkit.Inline(" "), "glossary": promptkit.Inline(" "), - "npcs": promptkit.Inline(" "), + "npcs": promptkit.Inline(`{"npcs":[{"name":"combat-npc"}]}`), }, }) if err != nil { @@ -62,4 +62,41 @@ 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) } + if len(prepared.Messages) < 6 { + t.Fatalf("prepared messages = %#v, want shared context, NPC grounding, and instructions", prepared.Messages) + } + 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/spells/prompt_assets.go b/internal/modules/dnd/extract/spells/prompt_assets.go index fd7a717..7344cf9 100644 --- a/internal/modules/dnd/extract/spells/prompt_assets.go +++ b/internal/modules/dnd/extract/spells/prompt_assets.go @@ -18,7 +18,6 @@ var promptAssetManifest = shared.PromptAssetManifest{ ModuleFiles: []promptfs.ModulePromptFile{ {Name: "prompt.yaml", Path: "prompts/prompt.yaml"}, {Name: "spell-catalog.md", Path: "prompts/spell-catalog.md"}, - {Name: "task.md", Path: "prompts/task.md"}, {Name: "instructions.md", Path: "prompts/instructions.md"}, }, SharedFiles: []string{ diff --git a/internal/modules/dnd/extract/spells/prompt_assets_test.go b/internal/modules/dnd/extract/spells/prompt_assets_test.go index 866b2b8..a34ec7b 100644 --- a/internal/modules/dnd/extract/spells/prompt_assets_test.go +++ b/internal/modules/dnd/extract/spells/prompt_assets_test.go @@ -12,8 +12,8 @@ import ( ) func TestPromptAssetsPrepareSpellPrompt(t *testing.T) { - transcript := []byte(`{"id":"session-1","segments":[{"id":"u1","text":"Mira casts shield."}]}`) - prepared := prepareSpellsPrompt(t, transcript, "Dana: Mira", "Mira: wizard", "Shield: abjuration") + transcript := []byte(`{"id":"session-1","segments":[{"id":"u1","text":"spell-transcript"}]}`) + prepared := prepareSpellsPrompt(t, transcript, "spell-player", "Mira: wizard", "Shield: abjuration") if prepared.PromptID != PromptID { t.Fatalf("prompt id = %q, want %q", prepared.PromptID, PromptID) @@ -21,6 +21,38 @@ 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) } + if len(prepared.Messages) < 7 { + t.Fatalf("prepared messages = %#v, want shared context, grounded inputs, and instructions", prepared.Messages) + } + 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) { @@ -107,8 +139,8 @@ func prepareSpellsPrompt(t *testing.T, transcript []byte, players string, party ProfileID: "spell-test-profile", Inputs: map[string]promptkit.ArtifactRef{ "transcript": promptkit.InlineWithURI("file:///session.json", string(transcript)), - "spell_catalog": promptkit.Inline(`{"spell_names":["Cure Wounds"]}`), - "npcs": promptkit.Inline(`{"npcs":[]}`), + "spell_catalog": promptkit.Inline(`{"spell_names":["spell-catalog-sentinel"]}`), + "npcs": promptkit.Inline(`{"npcs":[{"name":"spell-npc-sentinel"}]}`), "players": promptkit.Inline(players), "party": promptkit.Inline(party), "glossary": promptkit.Inline(glossary),