Consolidate spell and combat turn instructions

This commit is contained in:
2026-08-05 14:52:26 +00:00
parent 856b26b718
commit 16d1b29b14
10 changed files with 106 additions and 60 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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