From 47cf7e76ecd8f83419a10022a34dd18c0dd4fc82 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 6 Jul 2026 11:26:21 -0500 Subject: [PATCH] Adjust the prompt FS layout and test strategy --- docs/config.md | 15 +++-- docs/internal/modules.md | 15 +++-- examples/dnd-spells.config.yml | 2 +- internal/cli/run_test.go | 36 ++++++++--- .../dnd/scenes/assets/prompts/dnd.scenes.yaml | 11 ++-- internal/modules/chunk/dnd/scenes/chunker.go | 12 +++- .../modules/chunk/dnd/scenes/chunker_test.go | 61 ++++++++++++++++--- .../chunk/dnd/scenes/scriptorium_assets.go | 7 ++- .../dnd/scenes/scriptorium_assets_test.go | 30 ++++----- .../dnd/spells/assets/prompts/dnd.spells.yaml | 11 ++-- .../dnd/spells/assets/prompts/instructions.md | 4 +- .../modules/extract/dnd/spells/extractor.go | 12 +++- .../extract/dnd/spells/extractor_test.go | 42 +++++++++++-- .../extract/dnd/spells/registry_test.go | 12 +++- .../modules/extract/dnd/spells/runner_test.go | 34 +++++------ .../extract/dnd/spells/scriptorium_assets.go | 7 ++- .../dnd/spells/scriptorium_assets_test.go | 39 ++++++------ .../assets/prompts/common-dnd-references.md | 7 ++- internal/modules/sharedassets/prompt_fs.go | 5 +- .../modules/sharedassets/prompt_fs_test.go | 15 +++-- 20 files changed, 266 insertions(+), 111 deletions(-) diff --git a/docs/config.md b/docs/config.md index 272d247..3a54a17 100644 --- a/docs/config.md +++ b/docs/config.md @@ -23,7 +23,7 @@ pipelines: dnd-session: input: seriatim references: - roster: ./dnd-spells-roster.txt + party: ./dnd-spells-roster.txt glossary: ./dnd-spells-glossary.txt chunk: module: generic @@ -164,7 +164,8 @@ pipelines: dnd-session: input: seriatim references: - roster: ./campaign/party-roster.txt + players: ./campaign/players.txt + party: ./campaign/party-roster.txt glossary: ./campaign/glossary.txt artifacts: spells: @@ -188,7 +189,7 @@ pipelines: extract: module: dnd/spells references: - roster: ./campaign/session-roster.txt + party: ./campaign/session-party.txt ``` `chunk.references` and `normalize.references` are accepted in object-form @@ -253,12 +254,16 @@ The `generic` chunker accepts: The `dnd/scenes` chunker requires transcript source capabilities, calls the configured structured LLM runtime, and does not accept module options. It -declares optional `roster` and `glossary` references for scene disambiguation. +declares optional `players`, `party`, and `glossary` references for scene +disambiguation, and accepts `roster` as a deprecated compatibility alias for +`party`. The `dnd/spells` extractor declares optional reference slots: -- `roster` +- `players` +- `party` - `glossary` +- `roster` as a deprecated compatibility alias for `party` Both modules accept UTF-8 plain text, Markdown, YAML, or JSON reference files. The extractor uses references only as supporting disambiguation material; spell diff --git a/docs/internal/modules.md b/docs/internal/modules.md index 163bf8b..b3202b0 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -108,7 +108,8 @@ converts each scene into a deterministic source chunk. Its prompt definition lives under `assets/prompts` and its schema under `assets/schemas`. Shared reusable prompt fragments are provided by -`internal/modules/sharedassets`. +`internal/modules/sharedassets` and referenced from prompt definitions under +`./sharedassets/`. Requires: @@ -146,7 +147,8 @@ validators. Its prompt definition lives under `assets/prompts` and its schema under `assets/schemas`. Shared reusable prompt fragments are provided by -`internal/modules/sharedassets`. +`internal/modules/sharedassets` and referenced from prompt definitions under +`./sharedassets/`. Requires: @@ -167,10 +169,11 @@ metadata under `artifact_lanes[].metadata.extractor`. Durable artifact payload details belong in the [D&D spell artifact contract](../integrations/dnd-spell-artifacts.md). -The `dnd/scenes` chunker and `dnd/spells` extractor declare optional `roster` -and `glossary` reference slots accepting UTF-8 plain text, Markdown, YAML, or -JSON. Their prompts frame references as supporting disambiguation material only; -spell-cast artifacts must still be grounded in the source transcript. +The `dnd/scenes` chunker and `dnd/spells` extractor declare optional `players`, +`party`, and `glossary` reference slots accepting UTF-8 plain text, Markdown, +YAML, or JSON. They also accept `roster` as a deprecated compatibility alias for +`party`. Their prompts frame references as supporting disambiguation material +only; spell-cast artifacts must still be grounded in the source transcript. ## D&D Spell Validators diff --git a/examples/dnd-spells.config.yml b/examples/dnd-spells.config.yml index 2542c64..3531394 100644 --- a/examples/dnd-spells.config.yml +++ b/examples/dnd-spells.config.yml @@ -3,7 +3,7 @@ pipelines: dnd-session: input: seriatim references: - roster: ./dnd-spells-roster.txt + party: ./dnd-spells-roster.txt glossary: ./dnd-spells-glossary.txt chunk: module: generic diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index e35c59d..8f66f61 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -197,9 +197,15 @@ func TestProductionPromptAssetsRegisterAndPrepareDndPrompts(t *testing.T) { "dnd.scenes/dnd.scenes.yaml", "dnd.scenes/task.md", "dnd.scenes/instructions.md", + "dnd.scenes/sharedassets/common-dnd-system.md", + "dnd.scenes/sharedassets/common-dnd-transcript.md", + "dnd.scenes/sharedassets/common-dnd-references.md", "dnd.spells/dnd.spells.yaml", "dnd.spells/task.md", "dnd.spells/instructions.md", + "dnd.spells/sharedassets/common-dnd-system.md", + "dnd.spells/sharedassets/common-dnd-transcript.md", + "dnd.spells/sharedassets/common-dnd-references.md", } { if _, err := promptFS.Open(name); err != nil { t.Fatalf("PromptFS().Open(%q) error = %v, want nil", name, err) @@ -220,13 +226,15 @@ func TestProductionPromptAssetsRegisterAndPrepareDndPrompts(t *testing.T) { t.Fatalf("NewEngine() error = %v, want nil", err) } + sceneTranscript := `{"id":"session-1","segments":[{"id":"u1","text":"We enter the crypt."}]}` scenesPrepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{ PromptID: scenes.PromptID, PromptVersion: scenes.ResponseSchemaVersion, ProfileID: "production-test-profile", Inputs: map[string]scriptorium.ArtifactRef{ - "transcript": scriptorium.InlineWithURI("file:///session.json", `{"id":"session-1","segments":[{"id":"u1","text":"We enter the crypt."}]}`), - "roster": scriptorium.Inline("Aria: cleric"), + "transcript": scriptorium.InlineWithURI("file:///session.json", sceneTranscript), + "players": scriptorium.Inline("Alice: Aria"), + "party": scriptorium.Inline("Aria: cleric"), "glossary": scriptorium.Inline("Brightmantle: temple"), }, }) @@ -236,17 +244,24 @@ func TestProductionPromptAssetsRegisterAndPrepareDndPrompts(t *testing.T) { if got := len(scenesPrepared.Messages); got != 5 { t.Fatalf("scene message count = %d, want 5", got) } - if !strings.Contains(scenesPrepared.Messages[3].Content, "Divide the provided transcript") { - t.Fatalf("scene task message missing module text: %q", scenesPrepared.Messages[3].Content) + if !strings.Contains(scenesPrepared.Messages[1].Content, sceneTranscript) { + t.Fatalf("scene transcript message did not include source input") + } + for _, want := range []string{"Alice: Aria", "Aria: cleric", "Brightmantle: temple"} { + if !strings.Contains(scenesPrepared.Messages[2].Content, want) { + t.Fatalf("scene reference message missing %q", want) + } } + spellTranscript := `{"id":"session-1","segments":[{"id":"u1","text":"Mira casts shield."}]}` spellsPrepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{ PromptID: spells.PromptID, PromptVersion: spells.SchemaVersion, ProfileID: "production-test-profile", Inputs: map[string]scriptorium.ArtifactRef{ - "transcript": scriptorium.InlineWithURI("file:///session.json", `{"id":"session-1","segments":[{"id":"u1","text":"Mira casts shield."}]}`), - "roster": scriptorium.Inline("Mira: wizard"), + "transcript": scriptorium.InlineWithURI("file:///session.json", spellTranscript), + "players": scriptorium.Inline("Dana: Mira"), + "party": scriptorium.Inline("Mira: wizard"), "glossary": scriptorium.Inline("Shield: abjuration"), }, }) @@ -256,8 +271,13 @@ func TestProductionPromptAssetsRegisterAndPrepareDndPrompts(t *testing.T) { if got := len(spellsPrepared.Messages); got != 5 { t.Fatalf("spell message count = %d, want 5", got) } - if !strings.Contains(spellsPrepared.Messages[3].Content, "Extract Dungeons & Dragons spell-cast artifacts") { - t.Fatalf("spell task message missing module text: %q", spellsPrepared.Messages[3].Content) + if !strings.Contains(spellsPrepared.Messages[1].Content, spellTranscript) { + t.Fatalf("spell transcript message did not include source input") + } + for _, want := range []string{"Dana: Mira", "Mira: wizard", "Shield: abjuration"} { + if !strings.Contains(spellsPrepared.Messages[2].Content, want) { + t.Fatalf("spell reference message missing %q", want) + } } } diff --git a/internal/modules/chunk/dnd/scenes/assets/prompts/dnd.scenes.yaml b/internal/modules/chunk/dnd/scenes/assets/prompts/dnd.scenes.yaml index 82ce625..e473423 100644 --- a/internal/modules/chunk/dnd/scenes/assets/prompts/dnd.scenes.yaml +++ b/internal/modules/chunk/dnd/scenes/assets/prompts/dnd.scenes.yaml @@ -5,7 +5,10 @@ inputs: - name: transcript required: true content_type: application/json - - name: roster + - name: players + required: false + content_type: text/plain + - name: party required: false content_type: text/plain - name: glossary @@ -13,13 +16,13 @@ inputs: content_type: text/plain messages: - role: system - content_file: ./common-dnd-system.md + content_file: ./sharedassets/common-dnd-system.md - role: user - content_file: ./common-dnd-transcript.md + content_file: ./sharedassets/common-dnd-transcript.md cache_control: type: ephemeral - role: user - content_file: ./common-dnd-references.md + content_file: ./sharedassets/common-dnd-references.md cache_control: type: ephemeral - role: user diff --git a/internal/modules/chunk/dnd/scenes/chunker.go b/internal/modules/chunk/dnd/scenes/chunker.go index 058297b..b3e70cc 100644 --- a/internal/modules/chunk/dnd/scenes/chunker.go +++ b/internal/modules/chunk/dnd/scenes/chunker.go @@ -35,9 +35,19 @@ var referenceSlots = []contracts.ReferenceSlot{ Description: "Optional campaign glossary reference material used only for scene disambiguation.", AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...), }, + { + Name: "party", + Description: "Optional party roster reference material used only for scene disambiguation.", + AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...), + }, + { + Name: "players", + Description: "Optional player list reference material used only for scene disambiguation.", + AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...), + }, { Name: "roster", - Description: "Optional campaign roster or player-character reference material used only for scene disambiguation.", + Description: "Deprecated alias for party roster reference material used only for scene disambiguation.", AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...), }, } diff --git a/internal/modules/chunk/dnd/scenes/chunker_test.go b/internal/modules/chunk/dnd/scenes/chunker_test.go index ccae41d..338acc1 100644 --- a/internal/modules/chunk/dnd/scenes/chunker_test.go +++ b/internal/modules/chunk/dnd/scenes/chunker_test.go @@ -81,9 +81,19 @@ func wantReferenceSlots() []contracts.ReferenceSlot { Description: "Optional campaign glossary reference material used only for scene disambiguation.", AcceptedMediaTypes: append([]string(nil), accepted...), }, + { + Name: "party", + Description: "Optional party roster reference material used only for scene disambiguation.", + AcceptedMediaTypes: append([]string(nil), accepted...), + }, + { + Name: "players", + Description: "Optional player list reference material used only for scene disambiguation.", + AcceptedMediaTypes: append([]string(nil), accepted...), + }, { Name: "roster", - Description: "Optional campaign roster or player-character reference material used only for scene disambiguation.", + Description: "Deprecated alias for party roster reference material used only for scene disambiguation.", AcceptedMediaTypes: append([]string(nil), accepted...), }, } @@ -146,8 +156,11 @@ func TestChunkReturnsSceneChunksFromStructuredOutput(t *testing.T) { if got := string(transcript.Content); got != sceneTranscriptJSON { t.Fatalf("transcript content = %q, want original source input", got) } - if got := string(req.Inputs["roster"].Content); got != " " { - t.Fatalf("roster input = %q, want empty reference placeholder", got) + if got := string(req.Inputs["players"].Content); got != " " { + t.Fatalf("players input = %q, want empty reference placeholder", got) + } + if got := string(req.Inputs["party"].Content); got != " " { + t.Fatalf("party input = %q, want empty reference placeholder", got) } if got := string(req.Inputs["glossary"].Content); got != " " { t.Fatalf("glossary input = %q, want empty reference placeholder", got) @@ -204,10 +217,16 @@ func TestChunkPassesReferencesAsPromptInputs(t *testing.T) { req := chunkRequestWithClient(client) req.References = contracts.ReferenceSet{ Slots: map[string]contracts.ResolvedReferenceSlot{ - "roster": { - Slot: contracts.ReferenceSlot{Name: "roster"}, + "players": { + Slot: contracts.ReferenceSlot{Name: "players"}, Items: []contracts.ReferenceItem{ - {SlotName: "roster", Content: []byte("Aria: cleric")}, + {SlotName: "players", Content: []byte("Alice: Aria")}, + }, + }, + "party": { + Slot: contracts.ReferenceSlot{Name: "party"}, + Items: []contracts.ReferenceItem{ + {SlotName: "party", Content: []byte("Aria: cleric")}, }, }, "glossary": { @@ -223,8 +242,11 @@ func TestChunkPassesReferencesAsPromptInputs(t *testing.T) { t.Fatalf("Chunk() error = %v, want nil", err) } request := client.requests[0] - if got := string(request.Inputs["roster"].Content); got != "Aria: cleric" { - t.Fatalf("roster input = %q, want reference content", got) + if got := string(request.Inputs["players"].Content); got != "Alice: Aria" { + t.Fatalf("players input = %q, want reference content", got) + } + if got := string(request.Inputs["party"].Content); got != "Aria: cleric" { + t.Fatalf("party input = %q, want reference content", got) } if got := string(request.Inputs["glossary"].Content); got != "Brightmantle: local temple" { t.Fatalf("glossary input = %q, want reference content", got) @@ -234,6 +256,29 @@ func TestChunkPassesReferencesAsPromptInputs(t *testing.T) { } } +func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) { + inputs := promptInputs(contracts.ChunkRequest{ + SourceInput: sceneSourceInput(), + References: contracts.ReferenceSet{ + Slots: map[string]contracts.ResolvedReferenceSlot{ + "roster": { + Slot: contracts.ReferenceSlot{Name: "roster"}, + Items: []contracts.ReferenceItem{ + {SlotName: "roster", Content: []byte("Legacy roster text")}, + }, + }, + }, + }, + }) + + if got := string(inputs["party"].Content); got != "Legacy roster text" { + t.Fatalf("party input = %q, want legacy roster content", got) + } + if _, ok := inputs["roster"]; ok { + t.Fatalf("roster prompt input was present; want only party input") + } +} + func TestChunkRejectsWhitespaceOnlyBoundaryCaveats(t *testing.T) { client := &fakeScenesLLMClient{ response: chunkResponse{ diff --git a/internal/modules/chunk/dnd/scenes/scriptorium_assets.go b/internal/modules/chunk/dnd/scenes/scriptorium_assets.go index b6a1a1b..ec03ef5 100644 --- a/internal/modules/chunk/dnd/scenes/scriptorium_assets.go +++ b/internal/modules/chunk/dnd/scenes/scriptorium_assets.go @@ -29,9 +29,14 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error { } func promptInputs(req contracts.ChunkRequest) contracts.LLMInputSet { + partySlot := req.References.Slots["party"] + if len(partySlot.Items) == 0 { + partySlot = req.References.Slots["roster"] + } return contracts.LLMInputSet{ "transcript": transcriptPromptInput(req.SourceInput), - "roster": referencePromptMaterial("roster", req.References.Slots["roster"]), + "players": referencePromptMaterial("players", req.References.Slots["players"]), + "party": referencePromptMaterial("party", partySlot), "glossary": referencePromptMaterial("glossary", req.References.Slots["glossary"]), } } diff --git a/internal/modules/chunk/dnd/scenes/scriptorium_assets_test.go b/internal/modules/chunk/dnd/scenes/scriptorium_assets_test.go index 179fa74..a39cc25 100644 --- a/internal/modules/chunk/dnd/scenes/scriptorium_assets_test.go +++ b/internal/modules/chunk/dnd/scenes/scriptorium_assets_test.go @@ -14,7 +14,7 @@ import ( func TestScriptoriumPromptPreparesTranscriptAndTaskMessages(t *testing.T) { transcript := []byte(`{"id":"session-1","segments":[{"id":"u1","text":"We enter the crypt."}]}`) - prepared := prepareScenesPrompt(t, transcript, "Aria: cleric", "Brightmantle: temple") + prepared := prepareScenesPrompt(t, transcript, "Alice: Aria", "Aria: cleric", "Brightmantle: temple") if prepared.PromptID != PromptID { t.Fatalf("prompt id = %q, want %q", prepared.PromptID, PromptID) @@ -25,21 +25,20 @@ func TestScriptoriumPromptPreparesTranscriptAndTaskMessages(t *testing.T) { if prepared.Messages[1].Role != "user" || prepared.Messages[1].CacheControl == nil { t.Fatalf("transcript message did not render as cacheable user message: %#v", prepared.Messages[1]) } - wantTranscript := "A transcript of a Dungeons & Dragons gameplay session is provided below.\n\n" + string(transcript) + "\n" - if prepared.Messages[1].Content != wantTranscript { - t.Fatalf("transcript message = %q, want byte-identical shared transcript body", prepared.Messages[1].Content) + if !strings.Contains(prepared.Messages[1].Content, string(transcript)) { + t.Fatalf("transcript message did not include source input") } if prepared.Messages[2].CacheControl == nil { t.Fatalf("reference message did not render as cacheable user message: %#v", prepared.Messages[2]) } - if !strings.Contains(prepared.Messages[2].Content, "Roster reference:\nAria: cleric") { - t.Fatalf("reference message missing roster content: %q", prepared.Messages[2].Content) + if !strings.Contains(prepared.Messages[2].Content, "Alice: Aria") { + t.Fatalf("reference message missing player content") } - if !strings.Contains(prepared.Messages[2].Content, "Glossary reference:\nBrightmantle: temple") { - t.Fatalf("reference message missing glossary content: %q", prepared.Messages[2].Content) + if !strings.Contains(prepared.Messages[2].Content, "Aria: cleric") { + t.Fatalf("reference message missing party content") } - if !strings.Contains(prepared.Messages[3].Content, "Divide the provided transcript") { - t.Fatalf("task message missing scene task text: %q", prepared.Messages[3].Content) + if !strings.Contains(prepared.Messages[2].Content, "Brightmantle: temple") { + t.Fatalf("reference message missing glossary content") } if strings.Contains(prepared.Messages[3].Content, string(transcript)) { t.Fatalf("task message leaked transcript bytes") @@ -48,7 +47,7 @@ func TestScriptoriumPromptPreparesTranscriptAndTaskMessages(t *testing.T) { func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) { transcript := []byte(`{"secret":"source text"}`) - prepared := prepareScenesPrompt(t, transcript, "private roster note", "private glossary note") + prepared := prepareScenesPrompt(t, transcript, "private player note", "private party note", "private glossary note") metadata := New().ManifestMetadata() payload, err := json.Marshal(map[string]any{ @@ -70,9 +69,9 @@ func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) { diagnostics := string(payload) for _, forbidden := range []string{ "source text", - "private roster note", + "private player note", + "private party note", "private glossary note", - "Divide the provided transcript", `"properties"`, "start_unit_id", } { @@ -88,7 +87,7 @@ func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) { } } -func prepareScenesPrompt(t *testing.T, transcript []byte, roster string, glossary string) *scriptorium.PreparedRun { +func prepareScenesPrompt(t *testing.T, transcript []byte, players string, party string, glossary string) *scriptorium.PreparedRun { t.Helper() registry := llm.NewAssetRegistry() if err := sharedassets.Register(registry); err != nil { @@ -104,7 +103,8 @@ func prepareScenesPrompt(t *testing.T, transcript []byte, roster string, glossar ProfileID: "scene-test-profile", Inputs: map[string]scriptorium.ArtifactRef{ "transcript": scriptorium.InlineWithURI("file:///session.json", string(transcript)), - "roster": scriptorium.Inline(roster), + "players": scriptorium.Inline(players), + "party": scriptorium.Inline(party), "glossary": scriptorium.Inline(glossary), }, }) diff --git a/internal/modules/extract/dnd/spells/assets/prompts/dnd.spells.yaml b/internal/modules/extract/dnd/spells/assets/prompts/dnd.spells.yaml index 6cab237..e52871a 100644 --- a/internal/modules/extract/dnd/spells/assets/prompts/dnd.spells.yaml +++ b/internal/modules/extract/dnd/spells/assets/prompts/dnd.spells.yaml @@ -5,7 +5,10 @@ inputs: - name: transcript required: true content_type: application/json - - name: roster + - name: players + required: false + content_type: text/plain + - name: party required: false content_type: text/plain - name: glossary @@ -13,13 +16,13 @@ inputs: content_type: text/plain messages: - role: system - content_file: ./common-dnd-system.md + content_file: ./sharedassets/common-dnd-system.md - role: user - content_file: ./common-dnd-transcript.md + content_file: ./sharedassets/common-dnd-transcript.md cache_control: type: ephemeral - role: user - content_file: ./common-dnd-references.md + content_file: ./sharedassets/common-dnd-references.md cache_control: type: ephemeral - role: user diff --git a/internal/modules/extract/dnd/spells/assets/prompts/instructions.md b/internal/modules/extract/dnd/spells/assets/prompts/instructions.md index 15c5958..7f28a1c 100644 --- a/internal/modules/extract/dnd/spells/assets/prompts/instructions.md +++ b/internal/modules/extract/dnd/spells/assets/prompts/instructions.md @@ -4,8 +4,8 @@ Return only D&D spell-cast artifacts. For each spell cast, identify the in-world caster, spell name, effect, narrative description, and source references using source_id, start_unit_id, and end_unit_id. -Use roster and glossary reference material only to clarify source text. Do not -return spells, casters, or effects that are mentioned only in reference +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. Return exactly one JSON object and no explanatory text. diff --git a/internal/modules/extract/dnd/spells/extractor.go b/internal/modules/extract/dnd/spells/extractor.go index 4d7a0bc..3defa3b 100644 --- a/internal/modules/extract/dnd/spells/extractor.go +++ b/internal/modules/extract/dnd/spells/extractor.go @@ -39,9 +39,19 @@ var referenceSlots = []contracts.ReferenceSlot{ Description: "Optional campaign glossary reference material used only for disambiguation.", AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...), }, + { + Name: "party", + Description: "Optional party roster reference material used only for disambiguation.", + AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...), + }, + { + Name: "players", + Description: "Optional player list reference material used only for disambiguation.", + AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...), + }, { Name: "roster", - Description: "Optional campaign roster or player-character reference material used only for disambiguation.", + Description: "Deprecated alias for party roster reference material used only for disambiguation.", AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...), }, } diff --git a/internal/modules/extract/dnd/spells/extractor_test.go b/internal/modules/extract/dnd/spells/extractor_test.go index 3345e17..1782abb 100644 --- a/internal/modules/extract/dnd/spells/extractor_test.go +++ b/internal/modules/extract/dnd/spells/extractor_test.go @@ -109,10 +109,16 @@ func TestExtractPassesReferencesAsPromptInputs(t *testing.T) { req := extractionRequestWithClient(client) req.References = contracts.ReferenceSet{ Slots: map[string]contracts.ResolvedReferenceSlot{ - "roster": { - Slot: contracts.ReferenceSlot{Name: "roster"}, + "players": { + Slot: contracts.ReferenceSlot{Name: "players"}, Items: []contracts.ReferenceItem{ - {SlotName: "roster", Content: []byte("Aria Brightmantle: party cleric")}, + {SlotName: "players", Content: []byte("Alice: Aria Brightmantle")}, + }, + }, + "party": { + Slot: contracts.ReferenceSlot{Name: "party"}, + Items: []contracts.ReferenceItem{ + {SlotName: "party", Content: []byte("Aria Brightmantle: party cleric")}, }, }, "glossary": { @@ -135,8 +141,11 @@ func TestExtractPassesReferencesAsPromptInputs(t *testing.T) { if request.PromptID != PromptID || request.PromptVersion != SchemaVersion { t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, PromptID, SchemaVersion) } - if got := string(request.Inputs["roster"].Content); got != "Aria Brightmantle: party cleric" { - t.Fatalf("roster input = %q, want reference content", got) + if got := string(request.Inputs["players"].Content); got != "Alice: Aria Brightmantle" { + t.Fatalf("players input = %q, want reference content", got) + } + if got := string(request.Inputs["party"].Content); got != "Aria Brightmantle: party cleric" { + t.Fatalf("party input = %q, want reference content", got) } if got := string(request.Inputs["glossary"].Content); got != "Brightmantle: local temple name" { t.Fatalf("glossary input = %q, want reference content", got) @@ -146,6 +155,29 @@ func TestExtractPassesReferencesAsPromptInputs(t *testing.T) { } } +func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) { + inputs := promptInputs(contracts.ExtractionRequest{ + SourceInput: spellSourceInput(), + References: contracts.ReferenceSet{ + Slots: map[string]contracts.ResolvedReferenceSlot{ + "roster": { + Slot: contracts.ReferenceSlot{Name: "roster"}, + Items: []contracts.ReferenceItem{ + {SlotName: "roster", Content: []byte("Legacy roster text")}, + }, + }, + }, + }, + }) + + if got := string(inputs["party"].Content); got != "Legacy roster text" { + t.Fatalf("party input = %q, want legacy roster content", got) + } + if _, ok := inputs["roster"]; ok { + t.Fatalf("roster prompt input was present; want only party input") + } +} + func TestExtractReturnsNoCandidatesForEmptyResponse(t *testing.T) { client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}} diff --git a/internal/modules/extract/dnd/spells/registry_test.go b/internal/modules/extract/dnd/spells/registry_test.go index 19d4a98..26f5bfd 100644 --- a/internal/modules/extract/dnd/spells/registry_test.go +++ b/internal/modules/extract/dnd/spells/registry_test.go @@ -43,9 +43,19 @@ func TestModuleSpec(t *testing.T) { Description: "Optional campaign glossary reference material used only for disambiguation.", AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}, }, + { + Name: "party", + Description: "Optional party roster reference material used only for disambiguation.", + AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}, + }, + { + Name: "players", + Description: "Optional player list reference material used only for disambiguation.", + AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}, + }, { Name: "roster", - Description: "Optional campaign roster or player-character reference material used only for disambiguation.", + Description: "Deprecated alias for party roster reference material used only for disambiguation.", AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}, }, }, diff --git a/internal/modules/extract/dnd/spells/runner_test.go b/internal/modules/extract/dnd/spells/runner_test.go index 569e2c3..6348ff0 100644 --- a/internal/modules/extract/dnd/spells/runner_test.go +++ b/internal/modules/extract/dnd/spells/runner_test.go @@ -110,7 +110,7 @@ func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) { } } -func TestRunnerPassesRosterAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T) { +func TestRunnerPassesPartyAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T) { raw := readDNDSpellsFixture(t) expectedDoc := parseDNDSpellsFixture(t, raw) resolved := resolveDNDSpellsPipeline(t) @@ -147,7 +147,7 @@ func TestRunnerPassesRosterAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T) t.Fatalf("len(Approved) = %d, want 1", len(output.Approved)) } if len(output.Manifest.References) != 2 { - t.Fatalf("manifest references = %#v, want roster and glossary provenance", output.Manifest.References) + t.Fatalf("manifest references = %#v, want party and glossary provenance", output.Manifest.References) } if len(llmClient.requests) != 1 { t.Fatalf("LLM calls = %d, want 1", len(llmClient.requests)) @@ -156,15 +156,15 @@ func TestRunnerPassesRosterAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T) if request.PromptID != PromptID || request.PromptVersion != SchemaVersion { t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, PromptID, SchemaVersion) } - if got := string(request.Inputs["roster"].Content); got != "Aria: party cleric\nBorin: fighter" { - t.Fatalf("roster input = %q, want reference text", got) + if got := string(request.Inputs["party"].Content); got != "Aria: party cleric\nBorin: fighter" { + t.Fatalf("party input = %q, want reference text", got) } if got := string(request.Inputs["glossary"].Content); got != "Fire Bolt: evocation cantrip" { t.Fatalf("glossary input = %q, want reference text", got) } } -func TestRunnerDoesNotExtractSpellMentionedOnlyInRoster(t *testing.T) { +func TestRunnerDoesNotExtractSpellMentionedOnlyInPartyReference(t *testing.T) { raw := readDNDSpellsFixture(t) resolved := resolveDNDSpellsPipeline(t) resolved.ResolvedPipeline.ArtifactLanes[0].ExtractReferences.ReferenceSet = dndSpellsReferenceSet( @@ -185,7 +185,7 @@ func TestRunnerDoesNotExtractSpellMentionedOnlyInRoster(t *testing.T) { } if len(output.Approved) != 0 { - t.Fatalf("approved artifacts = %#v, want no roster-only spell casts", output.Approved) + t.Fatalf("approved artifacts = %#v, want no party-reference-only spell casts", output.Approved) } if len(llmClient.requests) != 1 { t.Fatalf("LLM calls = %d, want 1", len(llmClient.requests)) @@ -194,8 +194,8 @@ func TestRunnerDoesNotExtractSpellMentionedOnlyInRoster(t *testing.T) { if request.PromptID != PromptID || request.PromptVersion != SchemaVersion { t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, PromptID, SchemaVersion) } - if got := string(request.Inputs["roster"].Content); !strings.Contains(got, "Lightning Bolt") { - t.Fatalf("roster input = %q, want roster-only spell in reference input", got) + if got := string(request.Inputs["party"].Content); !strings.Contains(got, "Lightning Bolt") { + t.Fatalf("party input = %q, want party-reference-only spell in reference input", got) } if output.Manifest.ValidationStatus != "approved" { t.Fatalf("ValidationStatus = %q, want approved empty extraction", output.Manifest.ValidationStatus) @@ -247,19 +247,19 @@ func TestRunnerRejectsDNDSpellCastWithInvalidSourceRef(t *testing.T) { } } -func dndSpellsReferenceSet(roster string, glossary string) contracts.ReferenceSet { +func dndSpellsReferenceSet(party string, glossary string) contracts.ReferenceSet { slots := make(map[string]contracts.ResolvedReferenceSlot) - if strings.TrimSpace(roster) != "" { - slots["roster"] = contracts.ResolvedReferenceSlot{ - Slot: contracts.ReferenceSlot{Name: "roster"}, + if strings.TrimSpace(party) != "" { + slots["party"] = contracts.ResolvedReferenceSlot{ + Slot: contracts.ReferenceSlot{Name: "party"}, Items: []contracts.ReferenceItem{ { - SlotName: "roster", + SlotName: "party", MediaType: "text/plain; charset=utf-8", - Content: []byte(roster), - Digest: "sha256:roster", - Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/roster.txt"}, - SizeBytes: int64(len(roster)), + Content: []byte(party), + Digest: "sha256:party", + Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/party.txt"}, + SizeBytes: int64(len(party)), BindingSource: contracts.ReferenceBindingSourceConfig, }, }, diff --git a/internal/modules/extract/dnd/spells/scriptorium_assets.go b/internal/modules/extract/dnd/spells/scriptorium_assets.go index dc26472..47949d6 100644 --- a/internal/modules/extract/dnd/spells/scriptorium_assets.go +++ b/internal/modules/extract/dnd/spells/scriptorium_assets.go @@ -29,9 +29,14 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error { } func promptInputs(req contracts.ExtractionRequest) contracts.LLMInputSet { + partySlot := req.References.Slots["party"] + if len(partySlot.Items) == 0 { + partySlot = req.References.Slots["roster"] + } return contracts.LLMInputSet{ "transcript": transcriptPromptInput(req.SourceInput), - "roster": referencePromptMaterial("roster", req.References.Slots["roster"]), + "players": referencePromptMaterial("players", req.References.Slots["players"]), + "party": referencePromptMaterial("party", partySlot), "glossary": referencePromptMaterial("glossary", req.References.Slots["glossary"]), } } diff --git a/internal/modules/extract/dnd/spells/scriptorium_assets_test.go b/internal/modules/extract/dnd/spells/scriptorium_assets_test.go index 19abafe..fac4fb7 100644 --- a/internal/modules/extract/dnd/spells/scriptorium_assets_test.go +++ b/internal/modules/extract/dnd/spells/scriptorium_assets_test.go @@ -15,7 +15,7 @@ import ( func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing.T) { transcript := []byte(`{"id":"session-1","segments":[{"id":"u1","text":"Mira casts shield."}]}`) - prepared := prepareSpellsPrompt(t, transcript, "Mira: wizard", "Shield: abjuration") + prepared := prepareSpellsPrompt(t, transcript, "Dana: Mira", "Mira: wizard", "Shield: abjuration") if prepared.PromptID != PromptID { t.Fatalf("prompt id = %q, want %q", prepared.PromptID, PromptID) @@ -23,21 +23,20 @@ func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing if got := len(prepared.Messages); got != 5 { t.Fatalf("message count = %d, want 5", got) } - wantTranscript := "A transcript of a Dungeons & Dragons gameplay session is provided below.\n\n" + string(transcript) + "\n" - if prepared.Messages[1].Content != wantTranscript { - t.Fatalf("transcript message = %q, want byte-identical shared transcript body", prepared.Messages[1].Content) + if !strings.Contains(prepared.Messages[1].Content, string(transcript)) { + t.Fatalf("transcript message did not include source input") } if prepared.Messages[1].CacheControl == nil || prepared.Messages[2].CacheControl == nil { t.Fatalf("expected transcript and reference messages to be cacheable: %#v", prepared.Messages) } - if !strings.Contains(prepared.Messages[2].Content, "Roster reference:\nMira: wizard") { - t.Fatalf("reference message missing roster content: %q", prepared.Messages[2].Content) + if !strings.Contains(prepared.Messages[2].Content, "Dana: Mira") { + t.Fatalf("reference message missing player content") } - if !strings.Contains(prepared.Messages[2].Content, "Glossary reference:\nShield: abjuration") { - t.Fatalf("reference message missing glossary content: %q", prepared.Messages[2].Content) + if !strings.Contains(prepared.Messages[2].Content, "Mira: wizard") { + t.Fatalf("reference message missing party content") } - if !strings.Contains(prepared.Messages[3].Content, "Extract Dungeons & Dragons spell-cast artifacts") { - t.Fatalf("task message missing spell task text: %q", prepared.Messages[3].Content) + if !strings.Contains(prepared.Messages[2].Content, "Shield: abjuration") { + t.Fatalf("reference message missing glossary content") } if strings.Contains(prepared.Messages[3].Content, string(transcript)) { t.Fatalf("task message leaked transcript bytes") @@ -46,13 +45,10 @@ func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing func TestScriptoriumPromptPreparesWithMissingOptionalReferences(t *testing.T) { transcript := []byte(`{"id":"session-1","segments":[]}`) - prepared := prepareSpellsPrompt(t, transcript, " ", " ") + prepared := prepareSpellsPrompt(t, transcript, " ", " ", " ") - if !strings.Contains(prepared.Messages[2].Content, "Roster reference:\n ") { - t.Fatalf("reference message did not include empty roster input: %q", prepared.Messages[2].Content) - } - if !strings.Contains(prepared.Messages[2].Content, "Glossary reference:\n ") { - t.Fatalf("reference message did not include empty glossary input: %q", prepared.Messages[2].Content) + if !strings.Contains(prepared.Messages[2].Content, " ") { + t.Fatalf("reference message did not include empty optional reference placeholders") } } @@ -101,8 +97,8 @@ func TestSingleReferencePromptInputKeepsContentOnly(t *testing.T) { func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) { transcript := []byte(`{"secret":"source text"}`) - reference := "private roster note" - prepared := prepareSpellsPrompt(t, transcript, reference, " ") + reference := "private party note" + prepared := prepareSpellsPrompt(t, transcript, "private player note", reference, " ") metadata := New().ManifestMetadata() payload, err := json.Marshal(map[string]any{ @@ -124,8 +120,8 @@ func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) { diagnostics := string(payload) for _, forbidden := range []string{ "source text", + "private player note", reference, - "Extract Dungeons & Dragons spell-cast artifacts", `"properties"`, "spell_casts", } { @@ -141,7 +137,7 @@ func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) { } } -func prepareSpellsPrompt(t *testing.T, transcript []byte, roster string, glossary string) *scriptorium.PreparedRun { +func prepareSpellsPrompt(t *testing.T, transcript []byte, players string, party string, glossary string) *scriptorium.PreparedRun { t.Helper() registry := llm.NewAssetRegistry() if err := sharedassets.Register(registry); err != nil { @@ -169,7 +165,8 @@ func prepareSpellsPrompt(t *testing.T, transcript []byte, roster string, glossar ProfileID: "spell-test-profile", Inputs: map[string]scriptorium.ArtifactRef{ "transcript": scriptorium.InlineWithURI("file:///session.json", string(transcript)), - "roster": scriptorium.Inline(roster), + "players": scriptorium.Inline(players), + "party": scriptorium.Inline(party), "glossary": scriptorium.Inline(glossary), }, }) diff --git a/internal/modules/sharedassets/assets/prompts/common-dnd-references.md b/internal/modules/sharedassets/assets/prompts/common-dnd-references.md index d6e0b8b..2ad9c16 100644 --- a/internal/modules/sharedassets/assets/prompts/common-dnd-references.md +++ b/internal/modules/sharedassets/assets/prompts/common-dnd-references.md @@ -2,8 +2,11 @@ Optional reference material for this Dungeons & Dragons campaign is provided below. Use it only to disambiguate names, aliases, speakers, campaign terms, or spell names already present in the transcript. -Roster reference: -{{ input "roster" }} +Player list reference: +{{ input "players" }} + +Party roster reference: +{{ input "party" }} Glossary reference: {{ input "glossary" }} diff --git a/internal/modules/sharedassets/prompt_fs.go b/internal/modules/sharedassets/prompt_fs.go index 1aaa221..a998539 100644 --- a/internal/modules/sharedassets/prompt_fs.go +++ b/internal/modules/sharedassets/prompt_fs.go @@ -19,7 +19,8 @@ type ModulePromptFile struct { } // ModulePromptFS builds a prompt filesystem for a module directory from -// module-owned prompt files plus the common D&D shared prompt files. +// module-owned prompt files plus common D&D shared prompt files under the +// module's sharedassets subdirectory. func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile) (fs.FS, error) { return modulePromptFS(moduleDir, moduleFS, files, embeddedAssets) } @@ -60,7 +61,7 @@ func modulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile, if err != nil { return nil, fmt.Errorf("read shared prompt asset %s: %w", name, err) } - assets["assets/prompts/"+cleanModuleDir+"/"+name] = append([]byte(nil), data...) + assets["assets/prompts/"+cleanModuleDir+"/sharedassets/"+name] = append([]byte(nil), data...) } return assets, nil } diff --git a/internal/modules/sharedassets/prompt_fs_test.go b/internal/modules/sharedassets/prompt_fs_test.go index 8415758..896da05 100644 --- a/internal/modules/sharedassets/prompt_fs_test.go +++ b/internal/modules/sharedassets/prompt_fs_test.go @@ -20,18 +20,21 @@ func TestModulePromptFSCombinesModuleAndSharedPrompts(t *testing.T) { } tests := map[string]string{ - "assets/prompts/dnd.test/dnd.test.yaml": "id: dnd.test", - "assets/prompts/dnd.test/task.md": "task", - "assets/prompts/dnd.test/common-dnd-system.md": "You work with Dungeons & Dragons gameplay transcripts.", - "assets/prompts/dnd.test/common-dnd-transcript.md": "{{ input \"transcript\" }}", - "assets/prompts/dnd.test/common-dnd-references.md": "Roster reference:", + "assets/prompts/dnd.test/dnd.test.yaml": "id: dnd.test", + "assets/prompts/dnd.test/task.md": "task", + "assets/prompts/dnd.test/sharedassets/common-dnd-system.md": "", + "assets/prompts/dnd.test/sharedassets/common-dnd-transcript.md": "", + "assets/prompts/dnd.test/sharedassets/common-dnd-references.md": "", } for path, want := range tests { data, err := fs.ReadFile(fsys, path) if err != nil { t.Fatalf("ReadFile(%q) error = %v, want nil", path, err) } - if !strings.Contains(string(data), want) { + if len(data) == 0 { + t.Fatalf("ReadFile(%q) returned empty content", path) + } + if want != "" && !strings.Contains(string(data), want) { t.Fatalf("ReadFile(%q) = %q, want substring %q", path, data, want) } }