Adjust the prompt FS layout and test strategy

This commit is contained in:
2026-07-06 11:26:21 -05:00
parent 8cafa64174
commit 47cf7e76ec
20 changed files with 266 additions and 111 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"]),
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"},
},
},

View File

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

View File

@@ -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"]),
}
}

View File

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

View File

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

View File

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

View File

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