From ed36f7d7fd0d960273601440bc6ed15d8f2f48af Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 21 Jul 2026 16:08:28 +0000 Subject: [PATCH] Finish the D&D audit fixes --- docs/internal/llm.md | 5 +- .../combatturns/assets/prompts/task.md | 4 +- .../npcs/assets/prompts/instructions.md | 3 +- .../dnd/extract/npcs/assets/prompts/task.md | 6 +-- .../spells/assets/prompts/instructions.md | 4 -- .../dnd/extract/spells/assets/prompts/task.md | 5 +- .../assets/prompts/common-dnd-system.md | 2 - internal/modules/dnd/shared/prompt_inputs.go | 11 ++++- .../modules/dnd/shared/prompt_inputs_test.go | 48 +++++++++++++++++++ .../source_relatedness/validator.go | 2 +- .../source_relatedness/validator_test.go | 2 +- .../npcs/source_relatedness/validator.go | 2 +- .../npcs/source_relatedness/validator_test.go | 2 +- 13 files changed, 74 insertions(+), 22 deletions(-) diff --git a/docs/internal/llm.md b/docs/internal/llm.md index fc0948e6..6b32ba63 100644 --- a/docs/internal/llm.md +++ b/docs/internal/llm.md @@ -111,7 +111,10 @@ messages before its task and instructions. Identity, transcript, and campaign-reference messages are ephemeral in the extraction prompts; the scene prompt marks transcript and campaign references ephemeral, and the NPC registry is ephemeral where spell and combat prompts use it. Unused shared assets are -neither mounted nor fingerprinted. +neither mounted nor fingerprinted. Universal extraction-evidence and output +policy lives only in the shared extraction assets; package-owned prompt files +retain artifact-specific rules. The scene prompt keeps its separate output rule +because it does not render the extraction-evidence asset. Schema helpers load embedded JSON Schema with identity and digest metadata, return defensive copies, and expose a diagnostics map that omits schema bytes. diff --git a/internal/modules/dnd/extract/combatturns/assets/prompts/task.md b/internal/modules/dnd/extract/combatturns/assets/prompts/task.md index a6bcbd41..76445efa 100644 --- a/internal/modules/dnd/extract/combatturns/assets/prompts/task.md +++ b/internal/modules/dnd/extract/combatturns/assets/prompts/task.md @@ -15,5 +15,5 @@ attribute relevant nonstandard rulings to the GM or table. Unmatched actors and targets remain permitted. -For every factual detail in a turn, cite all supporting transcript units in the -turn-level source_refs collection. +Place all supporting transcript ranges for a turn in its turn-level source_refs +collection. diff --git a/internal/modules/dnd/extract/npcs/assets/prompts/instructions.md b/internal/modules/dnd/extract/npcs/assets/prompts/instructions.md index c81c787c..65071fe3 100644 --- a/internal/modules/dnd/extract/npcs/assets/prompts/instructions.md +++ b/internal/modules/dnd/extract/npcs/assets/prompts/instructions.md @@ -8,5 +8,4 @@ Relationships must be stated or directly demonstrated by cited transcript units, not inferred from game lore. Return aliases and relationships as arrays, including empty arrays when there -are none. Return only NPC records supported by the transcript and preserve -observed display spelling. +are none. Preserve observed display spelling. diff --git a/internal/modules/dnd/extract/npcs/assets/prompts/task.md b/internal/modules/dnd/extract/npcs/assets/prompts/task.md index 4d42b100..04bcc1bd 100644 --- a/internal/modules/dnd/extract/npcs/assets/prompts/task.md +++ b/internal/modules/dnd/extract/npcs/assets/prompts/task.md @@ -7,9 +7,9 @@ a stable alias or title, or an individually useful distinguishing description. Exclude human players, transcript speakers, and the GM as out-of-world people, player characters identified by the player or party references, incidental or -hypothetical name drops, corrected transcription mistakes, characters mentioned -only by reference material, indistinguishable crowds or groups, and temporary -summoned creatures or spell effects without a persistent individual identity. +hypothetical name drops, corrected transcription mistakes, indistinguishable +crowds or groups, and temporary summoned creatures or spell effects without a +persistent individual identity. Keep each description concise and limited to facts established by the transcript. Include only explicitly supported aliases and relationships. diff --git a/internal/modules/dnd/extract/spells/assets/prompts/instructions.md b/internal/modules/dnd/extract/spells/assets/prompts/instructions.md index 3fc29cce..7431792d 100644 --- a/internal/modules/dnd/extract/spells/assets/prompts/instructions.md +++ b/internal/modules/dnd/extract/spells/assets/prompts/instructions.md @@ -21,10 +21,6 @@ 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. -Use player, party, and glossary reference material only to clarify source text. -Do not return spells, casters, or effects that are mentioned only in reference -material. - Effects and narrative descriptions are session records, not rules summaries. Report only mechanics, explanations, and outcomes established by the cited transcript units. Preserve the table's observed resolution without silently diff --git a/internal/modules/dnd/extract/spells/assets/prompts/task.md b/internal/modules/dnd/extract/spells/assets/prompts/task.md index 2feae19d..fb008272 100644 --- a/internal/modules/dnd/extract/spells/assets/prompts/task.md +++ b/internal/modules/dnd/extract/spells/assets/prompts/task.md @@ -1,8 +1,7 @@ Extract Dungeons & Dragons spell-cast artifacts from the provided transcript. -Extract only spell casts that are supported by the transcript. Do not infer -spells from general D&D knowledge or from table chatter that does not identify a -spell being cast. +Do not infer a spell cast from general D&D knowledge or from table chatter that +does not identify a spell being cast. Describe the session as it was played and adjudicated. The transcript is authoritative for what happened in this session, even when a table ruling may diff --git a/internal/modules/dnd/shared/assets/prompts/common-dnd-system.md b/internal/modules/dnd/shared/assets/prompts/common-dnd-system.md index ec3c5752..d48a5500 100644 --- a/internal/modules/dnd/shared/assets/prompts/common-dnd-system.md +++ b/internal/modules/dnd/shared/assets/prompts/common-dnd-system.md @@ -4,5 +4,3 @@ Use only the provided transcript and reference material. Source text may contain transcription errors, repeated lines, incomplete sentences, and misheard proper nouns. Reference material, when present, is supporting context only and must not be treated as a source of extracted events by itself. - -Return only valid JSON matching the configured response schema. diff --git a/internal/modules/dnd/shared/prompt_inputs.go b/internal/modules/dnd/shared/prompt_inputs.go index c67ccc3e..674dc8f3 100644 --- a/internal/modules/dnd/shared/prompt_inputs.go +++ b/internal/modules/dnd/shared/prompt_inputs.go @@ -50,7 +50,16 @@ func ReferencePromptInput(slot contracts.ResolvedReferenceSlot) []byte { if items[i].Digest != items[j].Digest { return items[i].Digest < items[j].Digest } - return string(items[i].Content) < string(items[j].Content) + if comparison := bytes.Compare(items[i].Content, items[j].Content); comparison != 0 { + return comparison < 0 + } + if items[i].Origin.Type != items[j].Origin.Type { + return items[i].Origin.Type < items[j].Origin.Type + } + if items[i].MediaType != items[j].MediaType { + return items[i].MediaType < items[j].MediaType + } + return items[i].SizeBytes < items[j].SizeBytes }) if len(items) == 1 { return append([]byte(nil), items[0].Content...) diff --git a/internal/modules/dnd/shared/prompt_inputs_test.go b/internal/modules/dnd/shared/prompt_inputs_test.go index 9c328d9e..fe9564a4 100644 --- a/internal/modules/dnd/shared/prompt_inputs_test.go +++ b/internal/modules/dnd/shared/prompt_inputs_test.go @@ -190,6 +190,47 @@ func TestReferencePromptInputRendering(t *testing.T) { } } +func TestReferencePromptInputOrdersRenderedMetadataDeterministically(t *testing.T) { + base := contracts.ReferenceItem{ + SlotName: "party", + MediaType: "text/plain", + Content: []byte("same content"), + Digest: "sha256:same", + Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///same.txt"}, + SizeBytes: 12, + } + tests := []struct { + name string + first contracts.ReferenceItem + last contracts.ReferenceItem + }{ + { + name: "origin type", + first: referenceItemWithMetadata(base, "archive", "text/plain", 12), + last: referenceItemWithMetadata(base, "file", "text/plain", 12), + }, + { + name: "media type", + first: referenceItemWithMetadata(base, "file", "application/json", 12), + last: referenceItemWithMetadata(base, "file", "text/plain", 12), + }, + { + name: "size", + first: referenceItemWithMetadata(base, "file", "text/plain", 11), + last: referenceItemWithMetadata(base, "file", "text/plain", 12), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + forward := ReferencePromptInput(contracts.ResolvedReferenceSlot{Items: []contracts.ReferenceItem{test.first, test.last}}) + reversed := ReferencePromptInput(contracts.ResolvedReferenceSlot{Items: []contracts.ReferenceItem{test.last, test.first}}) + if !reflect.DeepEqual(forward, reversed) { + t.Fatalf("rendered bytes depend on insertion order:\nforward=%q\nreversed=%q", forward, reversed) + } + }) + } +} + func slotWithContent(name string, content string) contracts.ResolvedReferenceSlot { return contracts.ResolvedReferenceSlot{ Slot: contracts.ReferenceSlot{Name: name}, @@ -208,3 +249,10 @@ func referenceItem(slotName, uri, digest, content string) contracts.ReferenceIte Origin: contracts.ReferenceOrigin{URI: uri}, } } + +func referenceItemWithMetadata(base contracts.ReferenceItem, originType, mediaType string, size int64) contracts.ReferenceItem { + base.Origin.Type = originType + base.MediaType = mediaType + base.SizeBytes = size + return base +} diff --git a/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go b/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go index cda7e90c..0f5fe155 100644 --- a/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go @@ -16,7 +16,7 @@ import ( const ( Key = "extract/dnd/combat-turns/source_relatedness" WarningReasonCode = "combat_turn_not_near_source" - policy = "dnd.combat_turns.validator.source_relatedness.v1" + policy = "dnd.combat_turns.validator.source_relatedness.v2" ) type Options struct{} diff --git a/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go b/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go index 342086ca..f4c3007e 100644 --- a/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go @@ -87,7 +87,7 @@ func TestValidatorIgnoresReferenceMaterialAndRegistersPolicy(t *testing.T) { if err != nil || !result.Approved || len(result.Warnings) != 1 { t.Fatalf("reference-only relatedness = %#v, %v; want warning from transcript-only evidence", result, err) } - if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy { + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != "dnd.combat_turns.validator.source_relatedness.v2" { t.Fatalf("CheckpointFingerprints() = %#v, want relatedness policy", got) } if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic { diff --git a/internal/modules/dnd/validate/npcs/source_relatedness/validator.go b/internal/modules/dnd/validate/npcs/source_relatedness/validator.go index f8ca84d7..9101654c 100644 --- a/internal/modules/dnd/validate/npcs/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/npcs/source_relatedness/validator.go @@ -15,7 +15,7 @@ import ( const ( Key = "extract/dnd/npcs/source_relatedness" WarningReasonCode = "npc_not_near_source" - policy = "dnd.npcs.validator.source_relatedness.v1" + policy = "dnd.npcs.validator.source_relatedness.v2" ) type Options struct{} diff --git a/internal/modules/dnd/validate/npcs/source_relatedness/validator_test.go b/internal/modules/dnd/validate/npcs/source_relatedness/validator_test.go index 224d9f27..f9f644f0 100644 --- a/internal/modules/dnd/validate/npcs/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/npcs/source_relatedness/validator_test.go @@ -76,7 +76,7 @@ func TestValidatorUsesOnlyTranscriptEvidenceAndRegistersPolicy(t *testing.T) { if err != nil || len(result.Warnings) != 1 { t.Fatalf("reference-only relatedness = %#v, %v; want warning", result, err) } - if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != "dnd.npcs.validator.source_relatedness.v1" { + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != "dnd.npcs.validator.source_relatedness.v2" { t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got) } if Spec().ExecutionClass != contracts.ExecutionClassDeterministic {