diff --git a/assets/dnd/scene-descriptions/prompts/instructions.md b/assets/dnd/scene-descriptions/prompts/instructions.md index e019bcef..a4dd64c8 100644 --- a/assets/dnd/scene-descriptions/prompts/instructions.md +++ b/assets/dnd/scene-descriptions/prompts/instructions.md @@ -5,9 +5,7 @@ into multiple scenes or use facts that are not supported by it. Return one kind, one concise title, and one concise summary. Choose exactly one kind: -- combat: active combat materially organizes the scene, including - initiative-like exchanges or sustained hostile action. Planning a fight or - discussing a completed fight is not combat by itself. +- combat: a scene classified as combat under the shared combat policy. - narrative: current-session in-world play that is not principally active combat, a prior-session recap, or sustained out-of-character session discussion. This includes exploration, travel, dialogue, investigation, @@ -20,15 +18,13 @@ kind: play. Narrative is the default for actual current-session gameplay that does not meet -another definition. When the accepted chunk is mixed: +another definition. When the accepted chunk has no substantive active combat: -1. use combat when active combat is a substantive central activity, even with - brief setup, rules clarification, or immediate aftermath; -2. otherwise use recap when recounting a previous session is the chunk's +1. use recap when recounting a previous session is the chunk's primary table purpose; -3. otherwise use meta when sustained out-of-character session discussion is +2. otherwise use meta when sustained out-of-character session discussion is primary and in-world progression is no more than incidental; and -4. use narrative for all remaining current-session in-world play. +3. use narrative for all remaining current-session in-world play. Brief table talk, dice resolution, rules clarification, jokes, or administrative comments do not make a gameplay scene meta. A short recollection diff --git a/assets/dnd/scene-descriptions/prompts/prompt.yaml b/assets/dnd/scene-descriptions/prompts/prompt.yaml index 61b0f222..6dd06fa8 100644 --- a/assets/dnd/scene-descriptions/prompts/prompt.yaml +++ b/assets/dnd/scene-descriptions/prompts/prompt.yaml @@ -27,6 +27,8 @@ messages: content_file: ./sharedassets/common-dnd-transcript-chunk.md cache_control: type: ephemeral + - role: user + content_file: ./sharedassets/common-dnd-scene-combat-policy.md - role: user content_file: ./instructions.md cache_control: diff --git a/assets/dnd/shared/prompts/common-dnd-scene-combat-policy.md b/assets/dnd/shared/prompts/common-dnd-scene-combat-policy.md new file mode 100644 index 00000000..1698a108 --- /dev/null +++ b/assets/dnd/shared/prompts/common-dnd-scene-combat-policy.md @@ -0,0 +1,13 @@ +Use `combat` only when substantive active combat materially organizes the +scene. Active combat includes initiative or turn exchanges, attacks, combat +spells, damage, saves, movement, or similarly sustained hostile action. + +Do not use `combat` for planning or preparing for a possible fight; threats, +hostile dialogue, or a tense confrontation; immediate aftermath, looting, +healing, or discussion of a completed fight; a recap or in-world recollection +of earlier combat; or out-of-character rules discussion without active +encounter play. + +When a chunk contains substantive active combat alongside brief setup, rules +clarification, interruption, phase transition, or immediate aftermath, classify +it as `combat`. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 7e2116be..46eb439a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -59,6 +59,8 @@ before beginning the next one. ## Stage 1: Extract The Shared Combat Policy +✅ Complete + ### Goal Give the existing extractor and future validator one byte-identical owner for diff --git a/internal/modules/dnd/extract/scenedescriptions/prompt_assets.go b/internal/modules/dnd/extract/scenedescriptions/prompt_assets.go index 3aea296a..f03a10ed 100644 --- a/internal/modules/dnd/extract/scenedescriptions/prompt_assets.go +++ b/internal/modules/dnd/extract/scenedescriptions/prompt_assets.go @@ -24,6 +24,7 @@ var promptAssetManifest = shared.PromptAssetManifest{ "common-dnd-identity.md", "common-dnd-references.md", "common-dnd-transcript-chunk.md", + "common-dnd-scene-combat-policy.md", }, } diff --git a/internal/modules/dnd/extract/scenedescriptions/prompt_assets_test.go b/internal/modules/dnd/extract/scenedescriptions/prompt_assets_test.go index 1ae1f18f..258b8aa4 100644 --- a/internal/modules/dnd/extract/scenedescriptions/prompt_assets_test.go +++ b/internal/modules/dnd/extract/scenedescriptions/prompt_assets_test.go @@ -42,6 +42,23 @@ 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) } + const combatPolicySentinel = "substantive active combat materially organizes the" + transcriptIndex := -1 + policyIndex := -1 + instructionsIndex := len(prepared.Messages) - 1 + policyOccurrences := 0 + for index, message := range prepared.Messages { + if strings.Contains(message.Content, "scene-description-transcript") { + transcriptIndex = index + } + if count := strings.Count(message.Content, combatPolicySentinel); count > 0 { + policyIndex = index + policyOccurrences += count + } + } + if policyOccurrences != 1 || policyIndex <= transcriptIndex || policyIndex >= instructionsIndex { + t.Fatalf("combat policy appeared %d times at message %d; want once after transcript message %d and before instructions message %d", policyOccurrences, policyIndex, transcriptIndex, instructionsIndex) + } } func TestPromptMetadataAndDiagnosticsDoNotContainRawAssets(t *testing.T) { diff --git a/internal/modules/dnd/register/prompt_cache_test.go b/internal/modules/dnd/register/prompt_cache_test.go index 9308c6fb..d038bda1 100644 --- a/internal/modules/dnd/register/prompt_cache_test.go +++ b/internal/modules/dnd/register/prompt_cache_test.go @@ -23,13 +23,14 @@ import ( func TestExtractionPromptComposition(t *testing.T) { const ( - transcriptSentinel = "shared-transcript-sentinel" - playersSentinel = "shared-players-sentinel" - partySentinel = "shared-party-sentinel" - glossarySentinel = "shared-glossary-sentinel" - npcSentinel = "npc-registry-sentinel" - catalogSentinel = "spell-catalog-sentinel" - evidenceSentinel = "Transcript units are the only evidence" + transcriptSentinel = "shared-transcript-sentinel" + playersSentinel = "shared-players-sentinel" + partySentinel = "shared-party-sentinel" + glossarySentinel = "shared-glossary-sentinel" + npcSentinel = "npc-registry-sentinel" + catalogSentinel = "spell-catalog-sentinel" + evidenceSentinel = "Transcript units are the only evidence" + combatPolicySentinel = "substantive active combat materially organizes the" ) registry := llm.NewAssetRegistry() if err := registerPromptAssets(registry); err != nil { @@ -43,11 +44,12 @@ func TestExtractionPromptComposition(t *testing.T) { "glossary": promptkit.Inline(glossarySentinel), } cases := []struct { - name string - promptID string - promptVersion string - inputs map[string]promptkit.ArtifactRef - suffixGroups [][]string + name string + promptID string + promptVersion string + inputs map[string]promptkit.ArtifactRef + suffixGroups [][]string + middleSentinel string }{ {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}}}, @@ -55,7 +57,7 @@ func TestExtractionPromptComposition(t *testing.T) { "item_registry": promptkit.Inline(`{"items":[{"id":"item-registry-sentinel","name":"Torch"}]}`), }), suffixGroups: [][]string{{evidenceSentinel}, {"item-registry-sentinel"}}}, {name: "item registry", promptID: itemregistryextract.PromptID, promptVersion: itemregistryextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}}, - {name: "scene descriptions", promptID: scenedescriptionextract.PromptID, promptVersion: scenedescriptionextract.SchemaVersion, inputs: commonInputs}, + {name: "scene descriptions", promptID: scenedescriptionextract.PromptID, promptVersion: scenedescriptionextract.SchemaVersion, inputs: commonInputs, middleSentinel: combatPolicySentinel}, { name: "combat turns", promptID: combatextract.PromptID, @@ -155,6 +157,12 @@ func TestExtractionPromptComposition(t *testing.T) { previousIndex = groupIndex } instructionIndex := len(prepared.Messages) - 1 + if testCase.middleSentinel != "" { + policyIndex := renderedInputMessageIndex(t, prepared.Messages, testCase.middleSentinel) + if policyIndex <= transcriptIndex || policyIndex >= instructionIndex { + t.Fatalf("combat policy rendered at message %d, want after transcript message %d and before instructions message %d", policyIndex, transcriptIndex, instructionIndex) + } + } if instructionIndex <= previousIndex { t.Fatalf("instructions rendered at message %d, want after lane input message %d", instructionIndex, previousIndex) } diff --git a/internal/modules/dnd/shared/assets.go b/internal/modules/dnd/shared/assets.go index 47910e0d..d5492ae7 100644 --- a/internal/modules/dnd/shared/assets.go +++ b/internal/modules/dnd/shared/assets.go @@ -27,6 +27,7 @@ var sharedPromptPaths = map[string]string{ "common-dnd-identity.md": "prompts/common-dnd-identity.md", "common-dnd-transcript-full.md": "prompts/common-dnd-transcript-full.md", "common-dnd-transcript-chunk.md": "prompts/common-dnd-transcript-chunk.md", + "common-dnd-scene-combat-policy.md": "prompts/common-dnd-scene-combat-policy.md", "common-dnd-references.md": "prompts/common-dnd-references.md", "common-dnd-npc-registry.md": "prompts/common-dnd-npc-registry.md", }