diff --git a/assets/dnd/enemy-events/prompts/instructions.md b/assets/dnd/enemy-events/prompts/instructions.md index b8244cd..37163b9 100644 --- a/assets/dnd/enemy-events/prompts/instructions.md +++ b/assets/dnd/enemy-events/prompts/instructions.md @@ -1,14 +1,25 @@ +Extract Dungeons & Dragons enemy events from the supplied combat transcript. +An `engaged` event requires direct establishment that a subject is actively +opposing the party in combat. A `killed`, `fled`, `captured`, or +`incapacitated` event requires explicit establishment of that outcome. An +outcome may share evidence with an engagement, and a later engagement or +outcome for the same subject remains a separate observation. Emit at most one +`engaged` observation for the same subject in this combat scene. + +For `killed`, direct death or killing is required. For `fled`, the subject +must explicitly escape, retreat, or leave combat to avoid continued engagement. +For `captured`, the subject must be explicitly taken prisoner or secured +under the party's control. For `incapacitated`, the subject must be explicitly +unable to continue acting without being established as killed or captured. + +When the transcript identifies a named NPC, use its normalized registry +spelling. A hostile creature without a registry entry is allowed. For unnamed +individuals or groups, use only the narrowest transcript-grounded label, such +as `Orcs`, `One orc`, or `Remaining orcs`; never invent member names, IDs, +or quantities. + Exclude party members, allies, neutral observers, mentioned-but-absent enemies, hazards, traps, environmental effects, uncertain allegiance, table talk, planning, hypotheses, recaps outside this passage, and downstream inference. - Do not infer an engagement or outcome from initiative, turn absence, damage, -defeat, movement, a scene ending, combat-opponent grounding, or any auxiliary -artifact. Auxiliary inputs can guide attention but cannot prove or supply an -event. Cite only narrow current-transcript ranges that establish each event. - -Return the `events` array even when no enemy event is established. Every event -must contain only `name`, `kind`, and `source_refs`. Use exactly one kind: -`engaged`, `killed`, `fled`, `captured`, or `incapacitated`. Each source range -uses integer `start_unit_id` and `end_unit_id`; omit `source_id` because -Notarius assigns the current source identity. +defeat, movement, or a scene ending. diff --git a/assets/dnd/enemy-events/prompts/prompt.yaml b/assets/dnd/enemy-events/prompts/prompt.yaml index 373c2dd..ca22a45 100644 --- a/assets/dnd/enemy-events/prompts/prompt.yaml +++ b/assets/dnd/enemy-events/prompts/prompt.yaml @@ -42,8 +42,6 @@ messages: content_file: ./sharedassets/common-dnd-npcs.md - role: user content_file: ./combat-grounding.md - - role: user - content_file: ./task.md - role: user content_file: ./instructions.md cache_control: diff --git a/assets/dnd/enemy-events/prompts/task.md b/assets/dnd/enemy-events/prompts/task.md deleted file mode 100644 index 717d50c..0000000 --- a/assets/dnd/enemy-events/prompts/task.md +++ /dev/null @@ -1,20 +0,0 @@ -Extract Dungeons & Dragons enemy events from the supplied combat transcript. - -Return an `engaged` event only when the transcript directly establishes that a -subject is actively opposing the party in combat. Return `killed`, `fled`, -`captured`, or `incapacitated` only when the transcript explicitly establishes -that outcome. An outcome may share evidence with an engagement, and a later -engagement or outcome for the same subject remains a separate observation. -Emit at most one engagement for the same subject in this combat scene. - -For `killed`, direct death or killing is required. For `fled`, the subject must -explicitly escape, retreat, or leave combat to avoid continued engagement. For -`captured`, the subject must be explicitly taken prisoner or secured under the -party's control. For `incapacitated`, the subject must be explicitly unable to -continue acting without being established as killed or captured. - -Use a normalized NPC registry spelling when the transcript identifies that -named NPC. A hostile creature without a registry entry is allowed. For unnamed -individuals or groups, use only the narrowest transcript-grounded label, such -as `Orcs`, `One orc`, or `Remaining orcs`; never invent member names, IDs, or -quantities. diff --git a/assets/dnd/location-occurrences/prompts/instructions.md b/assets/dnd/location-occurrences/prompts/instructions.md index a40e4da..45541b4 100644 --- a/assets/dnd/location-occurrences/prompts/instructions.md +++ b/assets/dnd/location-occurrences/prompts/instructions.md @@ -1,8 +1,24 @@ -Return the occurrences array even when no occurrence is established. Every -record must contain location_id, name, kind, and source_refs. Copy location_id -and name from one supplied registry record, and cite only narrow transcript -ranges that support both that location and its classified occurrence. +Extract Dungeons & Dragons location occurrences from the supplied transcript. +Include an occurrence only when the transcript establishes one supplied +location, one occurrence kind, and a coherent passage supporting both. -Do not summarize location descriptions, infer a missing registry record, or -use registry context as evidence. Omit source_id; Notarius assigns the current -transcript source identity. +Use exactly one kind per occurrence: + +- visited: party members are physically present, arrive, remain, or depart; +- planned: the party explicitly proposes, intends, or agrees to future travel; +- recalled: the transcript explicitly recounts an earlier party visit; or +- mentioned: the location is explicitly referenced without stronger support, + including non-actionable speculation or a mere hypothetical reference. + +A mere hypothetical or speculative reference is not planned unless the +transcript also establishes an actual proposal, intention, or agreement to +travel. When the hypothetical explicitly names a supplied location, it may be +mentioned. + +For overlapping support, visited outranks planned, recalled, and mentioned; +planned outranks recalled and mentioned; recalled outranks mentioned. A passage +may produce multiple records when it independently establishes separate facts, +such as recalling an earlier visit while planning a return. Omit inferred, +unstated, uncertain, or unsupported places and occurrences. Do not infer a +location or occurrence from surrounding events when the transcript does not +state it. Do not summarize location descriptions. diff --git a/assets/dnd/location-occurrences/prompts/prompt.yaml b/assets/dnd/location-occurrences/prompts/prompt.yaml index c1caf1f..ee668fa 100644 --- a/assets/dnd/location-occurrences/prompts/prompt.yaml +++ b/assets/dnd/location-occurrences/prompts/prompt.yaml @@ -34,8 +34,6 @@ messages: content_file: ./sharedassets/common-dnd-extraction-evidence.md - role: user content_file: ./location-registry.md - - role: user - content_file: ./task.md - role: user content_file: ./instructions.md cache_control: diff --git a/assets/dnd/location-occurrences/prompts/task.md b/assets/dnd/location-occurrences/prompts/task.md deleted file mode 100644 index 17c283b..0000000 --- a/assets/dnd/location-occurrences/prompts/task.md +++ /dev/null @@ -1,29 +0,0 @@ -Extract Dungeons & Dragons location occurrences from the supplied transcript. - -Include an occurrence only when the transcript establishes one supplied -location, one occurrence kind, and a coherent passage supporting both. Use -only the exact ID and name pair from the supplied location registry. Return an -empty occurrences array when no supplied location has an evidenced occurrence -in this transcript passage. - -Use exactly one kind per occurrence: - -- visited: party members are physically present, arrive, remain, or depart; -- planned: the party explicitly proposes, intends, or agrees to future travel; -- recalled: the transcript explicitly recounts an earlier party visit; or -- mentioned: the location is explicitly referenced without stronger support, - including non-actionable speculation or a mere hypothetical reference. - -A mere hypothetical or speculative reference is not planned unless the -transcript also establishes an actual proposal, intention, or agreement to -travel. When the hypothetical itself explicitly names a supplied registry -location, it may be mentioned using the narrow passage that supports that -reference. - -For overlapping support, visited outranks planned, recalled, and mentioned; -planned outranks recalled and mentioned; recalled outranks mentioned. A passage -may produce multiple records when it independently establishes separate facts, -such as recalling an earlier visit while planning a return. Omit inferred, -unstated, uncertain, or unsupported places and occurrences. Do not infer a -location or occurrence from surrounding events when the transcript does not -state it. diff --git a/assets/dnd/npc-interactions/prompts/instructions.md b/assets/dnd/npc-interactions/prompts/instructions.md index b5b90be..a64db94 100644 --- a/assets/dnd/npc-interactions/prompts/instructions.md +++ b/assets/dnd/npc-interactions/prompts/instructions.md @@ -1,6 +1,13 @@ -Return the interactions array even when no interaction is established. Every -record must contain name, kind, and source_refs. Cite transcript ranges that -support both the NPC identity and the interaction kind. +Extract Dungeons & Dragons NPC interaction occurrences from the supplied +transcript. Include an occurrence only when the transcript establishes one +supplied NPC, one interaction kind, and a coherent passage supporting both. +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. Use exactly one kind per occurrence: diff --git a/assets/dnd/npc-interactions/prompts/prompt.yaml b/assets/dnd/npc-interactions/prompts/prompt.yaml index 73dc072..a8afcdd 100644 --- a/assets/dnd/npc-interactions/prompts/prompt.yaml +++ b/assets/dnd/npc-interactions/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/npc-interactions/prompts/task.md b/assets/dnd/npc-interactions/prompts/task.md deleted file mode 100644 index 0feda98..0000000 --- a/assets/dnd/npc-interactions/prompts/task.md +++ /dev/null @@ -1,16 +0,0 @@ -Extract Dungeons & Dragons NPC interaction occurrences from the supplied -transcript. - -Include an occurrence only when the transcript establishes one supplied NPC, -one interaction kind, and a coherent passage supporting both. Use only names -from the supplied NPC registry. The registry helps ground identity but never -proves that an interaction occurred. - -Do not summarize, infer relationships, sentiment, factions, motives, aliases, -or persistent state. Do not identify player characters, anonymous groups, or -invented NPCs. Return an empty interactions array when no supplied NPC has an -evidenced interaction in this transcript passage. - -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. diff --git a/internal/modules/dnd/extract/enemyevents/prompt_assets.go b/internal/modules/dnd/extract/enemyevents/prompt_assets.go index 61bcf3e..c16dd70 100644 --- a/internal/modules/dnd/extract/enemyevents/prompt_assets.go +++ b/internal/modules/dnd/extract/enemyevents/prompt_assets.go @@ -18,7 +18,6 @@ var promptAssetManifest = shared.PromptAssetManifest{ ModuleFiles: []promptfs.ModulePromptFile{ {Name: "prompt.yaml", Path: "prompts/prompt.yaml"}, {Name: "combat-grounding.md", Path: "prompts/combat-grounding.md"}, - {Name: "task.md", Path: "prompts/task.md"}, {Name: "instructions.md", Path: "prompts/instructions.md"}, }, SharedFiles: []string{ diff --git a/internal/modules/dnd/extract/enemyevents/prompt_assets_test.go b/internal/modules/dnd/extract/enemyevents/prompt_assets_test.go index 23cd73a..a306155 100644 --- a/internal/modules/dnd/extract/enemyevents/prompt_assets_test.go +++ b/internal/modules/dnd/extract/enemyevents/prompt_assets_test.go @@ -28,21 +28,32 @@ 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") - for _, sentinel := range []string{"enemy-npc", "enemy-turn", "enemy-opponent", "Extract Dungeons & Dragons enemy events"} { + 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 := renderedMessageIndex(t, prepared.Messages, "Return the `events` array") - if instructionIndex <= transcriptIndex { - t.Fatalf("instruction index = %d, want after transcript index %d", instructionIndex, transcriptIndex) + instructionIndex := renderedMessageIndex(t, prepared.Messages, "Extract Dungeons & Dragons enemy events") + 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) } - if cache := prepared.Messages[instructionIndex].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral { - t.Fatalf("final instruction cache control = %#v", cache) + 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) + } } } diff --git a/internal/modules/dnd/extract/locationoccurrences/prompt_assets.go b/internal/modules/dnd/extract/locationoccurrences/prompt_assets.go index 67b17eb..3bf33b2 100644 --- a/internal/modules/dnd/extract/locationoccurrences/prompt_assets.go +++ b/internal/modules/dnd/extract/locationoccurrences/prompt_assets.go @@ -18,7 +18,6 @@ var promptAssetManifest = shared.PromptAssetManifest{ ModuleFiles: []promptfs.ModulePromptFile{ {Name: "prompt.yaml", Path: "prompts/prompt.yaml"}, {Name: "location-registry.md", Path: "prompts/location-registry.md"}, - {Name: "task.md", Path: "prompts/task.md"}, {Name: "instructions.md", Path: "prompts/instructions.md"}, }, SharedFiles: []string{ diff --git a/internal/modules/dnd/extract/locationoccurrences/prompt_assets_test.go b/internal/modules/dnd/extract/locationoccurrences/prompt_assets_test.go index 7a9669f..4c57d1d 100644 --- a/internal/modules/dnd/extract/locationoccurrences/prompt_assets_test.go +++ b/internal/modules/dnd/extract/locationoccurrences/prompt_assets_test.go @@ -29,8 +29,8 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) { prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{ PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "location-occurrences-test", Inputs: map[string]promptkit.ArtifactRef{ - "transcript": promptkit.Inline(`{"units":[1]}`), "players": promptkit.Inline(" "), "party": promptkit.Inline(" "), "glossary": promptkit.Inline(" "), - "locations": promptkit.Inline(`{"locations":[{"id":"location:sha256:test","name":"The Mill"}]}`), + "transcript": promptkit.Inline(`{"units":[{"sentinel":"location-occurrence-transcript"}]}`), "players": promptkit.Inline("location-occurrence-player"), "party": promptkit.Inline(" "), "glossary": promptkit.Inline(" "), + "locations": promptkit.Inline(`{"locations":[{"id":"location:sha256:test","name":"location-occurrence-registry"}]}`), }, }) if err != nil { @@ -40,10 +40,17 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) { t.Fatalf("prepared prompt = %#v", prepared) } var registryMessage string + referenceIndex := -1 + transcriptIndex := -1 registryIndex := -1 evidenceIndex := -1 - taskIndex := -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 + } if strings.Contains(message.Content, "normalized location registry") { registryMessage = message.Content registryIndex = index @@ -51,14 +58,28 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) { if strings.Contains(message.Content, "Transcript units are the only evidence") { evidenceIndex = index } - if strings.Contains(message.Content, "Extract Dungeons & Dragons location occurrences") { - taskIndex = index - } } - if !strings.Contains(registryMessage, "location:sha256:test") || !strings.Contains(registryMessage, "The Mill") || strings.Contains(registryMessage, "source_refs") { + 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) } - if evidenceIndex < 0 || taskIndex < 0 || registryIndex <= evidenceIndex || registryIndex >= taskIndex { - t.Fatalf("registry prompt placement = evidence %d, registry %d, task %d", evidenceIndex, registryIndex, taskIndex) + 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/npcinteractions/prompt_assets.go b/internal/modules/dnd/extract/npcinteractions/prompt_assets.go index 31bb190..130fcda 100644 --- a/internal/modules/dnd/extract/npcinteractions/prompt_assets.go +++ b/internal/modules/dnd/extract/npcinteractions/prompt_assets.go @@ -17,7 +17,6 @@ var promptAssetManifest = shared.PromptAssetManifest{ ModuleDir: "dnd.npc_interactions", 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/npcinteractions/prompt_assets_test.go b/internal/modules/dnd/extract/npcinteractions/prompt_assets_test.go index 4b41447..f99f54f 100644 --- a/internal/modules/dnd/extract/npcinteractions/prompt_assets_test.go +++ b/internal/modules/dnd/extract/npcinteractions/prompt_assets_test.go @@ -34,15 +34,15 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) { if err != nil { t.Fatal(err) } - transcript := `{"units":[1]}` + transcript := `{"units":[{"sentinel":"interaction-transcript"}]}` prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{ PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "npc-interactions-test-profile", Inputs: map[string]promptkit.ArtifactRef{ "transcript": promptkit.InlineWithURI("file:///session.json", transcript), - "players": promptkit.Inline("Dana: Mira"), + "players": promptkit.Inline("interaction-player"), "party": promptkit.Inline("Mira: ranger"), "glossary": promptkit.Inline("Greencloak: title"), - "npcs": promptkit.Inline(`{"npcs":[{"name":"Mira Thorn"}]}`), + "npcs": promptkit.Inline(`{"npcs":[{"name":"interaction-npc"}]}`), }, }) if err != nil { @@ -51,6 +51,43 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) { if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npc_interactions_llm.v1.json" { t.Fatalf("prepared prompt = %#v", 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, "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) {