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 fc0948e..6b32ba6 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 a6bcbd4..76445ef 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 c81c787..65071fe 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 4d42b10..04bcc1b 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 3fc29cc..7431792 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 2feae19..fb00827 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 ec3c575..d48a550 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 c67ccc3..674dc8f 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 9c328d9..fe9564a 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 cda7e90..0f5fe15 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 342086c..f4c3007 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 f8ca84d..9101654 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 224d9f2..f9f644f 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 {