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: dnd-session:
input: seriatim input: seriatim
references: references:
roster: ./dnd-spells-roster.txt party: ./dnd-spells-roster.txt
glossary: ./dnd-spells-glossary.txt glossary: ./dnd-spells-glossary.txt
chunk: chunk:
module: generic module: generic
@@ -164,7 +164,8 @@ pipelines:
dnd-session: dnd-session:
input: seriatim input: seriatim
references: references:
roster: ./campaign/party-roster.txt players: ./campaign/players.txt
party: ./campaign/party-roster.txt
glossary: ./campaign/glossary.txt glossary: ./campaign/glossary.txt
artifacts: artifacts:
spells: spells:
@@ -188,7 +189,7 @@ pipelines:
extract: extract:
module: dnd/spells module: dnd/spells
references: references:
roster: ./campaign/session-roster.txt party: ./campaign/session-party.txt
``` ```
`chunk.references` and `normalize.references` are accepted in object-form `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 The `dnd/scenes` chunker requires transcript source capabilities, calls the
configured structured LLM runtime, and does not accept module options. It 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: The `dnd/spells` extractor declares optional reference slots:
- `roster` - `players`
- `party`
- `glossary` - `glossary`
- `roster` as a deprecated compatibility alias for `party`
Both modules accept UTF-8 plain text, Markdown, YAML, or JSON reference files. Both modules accept UTF-8 plain text, Markdown, YAML, or JSON reference files.
The extractor uses references only as supporting disambiguation material; spell 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 Its prompt definition lives under `assets/prompts` and its schema under
`assets/schemas`. Shared reusable prompt fragments are provided by `assets/schemas`. Shared reusable prompt fragments are provided by
`internal/modules/sharedassets`. `internal/modules/sharedassets` and referenced from prompt definitions under
`./sharedassets/`.
Requires: Requires:
@@ -146,7 +147,8 @@ validators.
Its prompt definition lives under `assets/prompts` and its schema under Its prompt definition lives under `assets/prompts` and its schema under
`assets/schemas`. Shared reusable prompt fragments are provided by `assets/schemas`. Shared reusable prompt fragments are provided by
`internal/modules/sharedassets`. `internal/modules/sharedassets` and referenced from prompt definitions under
`./sharedassets/`.
Requires: Requires:
@@ -167,10 +169,11 @@ metadata under `artifact_lanes[].metadata.extractor`. Durable artifact payload
details belong in the details belong in the
[D&D spell artifact contract](../integrations/dnd-spell-artifacts.md). [D&D spell artifact contract](../integrations/dnd-spell-artifacts.md).
The `dnd/scenes` chunker and `dnd/spells` extractor declare optional `roster` The `dnd/scenes` chunker and `dnd/spells` extractor declare optional `players`,
and `glossary` reference slots accepting UTF-8 plain text, Markdown, YAML, or `party`, and `glossary` reference slots accepting UTF-8 plain text, Markdown,
JSON. Their prompts frame references as supporting disambiguation material only; YAML, or JSON. They also accept `roster` as a deprecated compatibility alias for
spell-cast artifacts must still be grounded in the source transcript. `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 ## D&D Spell Validators

View File

@@ -3,7 +3,7 @@ pipelines:
dnd-session: dnd-session:
input: seriatim input: seriatim
references: references:
roster: ./dnd-spells-roster.txt party: ./dnd-spells-roster.txt
glossary: ./dnd-spells-glossary.txt glossary: ./dnd-spells-glossary.txt
chunk: chunk:
module: generic module: generic

View File

@@ -197,9 +197,15 @@ func TestProductionPromptAssetsRegisterAndPrepareDndPrompts(t *testing.T) {
"dnd.scenes/dnd.scenes.yaml", "dnd.scenes/dnd.scenes.yaml",
"dnd.scenes/task.md", "dnd.scenes/task.md",
"dnd.scenes/instructions.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/dnd.spells.yaml",
"dnd.spells/task.md", "dnd.spells/task.md",
"dnd.spells/instructions.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 { if _, err := promptFS.Open(name); err != nil {
t.Fatalf("PromptFS().Open(%q) error = %v, want nil", name, err) 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) 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{ scenesPrepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: scenes.PromptID, PromptID: scenes.PromptID,
PromptVersion: scenes.ResponseSchemaVersion, PromptVersion: scenes.ResponseSchemaVersion,
ProfileID: "production-test-profile", ProfileID: "production-test-profile",
Inputs: map[string]scriptorium.ArtifactRef{ Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.InlineWithURI("file:///session.json", `{"id":"session-1","segments":[{"id":"u1","text":"We enter the crypt."}]}`), "transcript": scriptorium.InlineWithURI("file:///session.json", sceneTranscript),
"roster": scriptorium.Inline("Aria: cleric"), "players": scriptorium.Inline("Alice: Aria"),
"party": scriptorium.Inline("Aria: cleric"),
"glossary": scriptorium.Inline("Brightmantle: temple"), "glossary": scriptorium.Inline("Brightmantle: temple"),
}, },
}) })
@@ -236,17 +244,24 @@ func TestProductionPromptAssetsRegisterAndPrepareDndPrompts(t *testing.T) {
if got := len(scenesPrepared.Messages); got != 5 { if got := len(scenesPrepared.Messages); got != 5 {
t.Fatalf("scene message count = %d, want 5", got) t.Fatalf("scene message count = %d, want 5", got)
} }
if !strings.Contains(scenesPrepared.Messages[3].Content, "Divide the provided transcript") { if !strings.Contains(scenesPrepared.Messages[1].Content, sceneTranscript) {
t.Fatalf("scene task message missing module text: %q", scenesPrepared.Messages[3].Content) 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{ spellsPrepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: spells.PromptID, PromptID: spells.PromptID,
PromptVersion: spells.SchemaVersion, PromptVersion: spells.SchemaVersion,
ProfileID: "production-test-profile", ProfileID: "production-test-profile",
Inputs: map[string]scriptorium.ArtifactRef{ Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.InlineWithURI("file:///session.json", `{"id":"session-1","segments":[{"id":"u1","text":"Mira casts shield."}]}`), "transcript": scriptorium.InlineWithURI("file:///session.json", spellTranscript),
"roster": scriptorium.Inline("Mira: wizard"), "players": scriptorium.Inline("Dana: Mira"),
"party": scriptorium.Inline("Mira: wizard"),
"glossary": scriptorium.Inline("Shield: abjuration"), "glossary": scriptorium.Inline("Shield: abjuration"),
}, },
}) })
@@ -256,8 +271,13 @@ func TestProductionPromptAssetsRegisterAndPrepareDndPrompts(t *testing.T) {
if got := len(spellsPrepared.Messages); got != 5 { if got := len(spellsPrepared.Messages); got != 5 {
t.Fatalf("spell message count = %d, want 5", got) t.Fatalf("spell message count = %d, want 5", got)
} }
if !strings.Contains(spellsPrepared.Messages[3].Content, "Extract Dungeons & Dragons spell-cast artifacts") { if !strings.Contains(spellsPrepared.Messages[1].Content, spellTranscript) {
t.Fatalf("spell task message missing module text: %q", spellsPrepared.Messages[3].Content) 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 - name: transcript
required: true required: true
content_type: application/json content_type: application/json
- name: roster - name: players
required: false
content_type: text/plain
- name: party
required: false required: false
content_type: text/plain content_type: text/plain
- name: glossary - name: glossary
@@ -13,13 +16,13 @@ inputs:
content_type: text/plain content_type: text/plain
messages: messages:
- role: system - role: system
content_file: ./common-dnd-system.md content_file: ./sharedassets/common-dnd-system.md
- role: user - role: user
content_file: ./common-dnd-transcript.md content_file: ./sharedassets/common-dnd-transcript.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user - role: user
content_file: ./common-dnd-references.md content_file: ./sharedassets/common-dnd-references.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user - role: user

View File

@@ -35,9 +35,19 @@ var referenceSlots = []contracts.ReferenceSlot{
Description: "Optional campaign glossary reference material used only for scene disambiguation.", Description: "Optional campaign glossary reference material used only for scene disambiguation.",
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...), 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", 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...), 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.", Description: "Optional campaign glossary reference material used only for scene disambiguation.",
AcceptedMediaTypes: append([]string(nil), accepted...), 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", 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...), AcceptedMediaTypes: append([]string(nil), accepted...),
}, },
} }
@@ -146,8 +156,11 @@ func TestChunkReturnsSceneChunksFromStructuredOutput(t *testing.T) {
if got := string(transcript.Content); got != sceneTranscriptJSON { if got := string(transcript.Content); got != sceneTranscriptJSON {
t.Fatalf("transcript content = %q, want original source input", got) t.Fatalf("transcript content = %q, want original source input", got)
} }
if got := string(req.Inputs["roster"].Content); got != " " { if got := string(req.Inputs["players"].Content); got != " " {
t.Fatalf("roster input = %q, want empty reference placeholder", 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 != " " { if got := string(req.Inputs["glossary"].Content); got != " " {
t.Fatalf("glossary input = %q, want empty reference placeholder", got) t.Fatalf("glossary input = %q, want empty reference placeholder", got)
@@ -204,10 +217,16 @@ func TestChunkPassesReferencesAsPromptInputs(t *testing.T) {
req := chunkRequestWithClient(client) req := chunkRequestWithClient(client)
req.References = contracts.ReferenceSet{ req.References = contracts.ReferenceSet{
Slots: map[string]contracts.ResolvedReferenceSlot{ Slots: map[string]contracts.ResolvedReferenceSlot{
"roster": { "players": {
Slot: contracts.ReferenceSlot{Name: "roster"}, Slot: contracts.ReferenceSlot{Name: "players"},
Items: []contracts.ReferenceItem{ 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": { "glossary": {
@@ -223,8 +242,11 @@ func TestChunkPassesReferencesAsPromptInputs(t *testing.T) {
t.Fatalf("Chunk() error = %v, want nil", err) t.Fatalf("Chunk() error = %v, want nil", err)
} }
request := client.requests[0] request := client.requests[0]
if got := string(request.Inputs["roster"].Content); got != "Aria: cleric" { if got := string(request.Inputs["players"].Content); got != "Alice: Aria" {
t.Fatalf("roster input = %q, want reference content", got) 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" { if got := string(request.Inputs["glossary"].Content); got != "Brightmantle: local temple" {
t.Fatalf("glossary input = %q, want reference content", got) 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) { func TestChunkRejectsWhitespaceOnlyBoundaryCaveats(t *testing.T) {
client := &fakeScenesLLMClient{ client := &fakeScenesLLMClient{
response: chunkResponse{ response: chunkResponse{

View File

@@ -29,9 +29,14 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
} }
func promptInputs(req contracts.ChunkRequest) contracts.LLMInputSet { 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{ return contracts.LLMInputSet{
"transcript": transcriptPromptInput(req.SourceInput), "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"]), "glossary": referencePromptMaterial("glossary", req.References.Slots["glossary"]),
} }
} }

View File

@@ -14,7 +14,7 @@ import (
func TestScriptoriumPromptPreparesTranscriptAndTaskMessages(t *testing.T) { func TestScriptoriumPromptPreparesTranscriptAndTaskMessages(t *testing.T) {
transcript := []byte(`{"id":"session-1","segments":[{"id":"u1","text":"We enter the crypt."}]}`) 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 { if prepared.PromptID != PromptID {
t.Fatalf("prompt id = %q, want %q", 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 { 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]) 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 !strings.Contains(prepared.Messages[1].Content, string(transcript)) {
if prepared.Messages[1].Content != wantTranscript { t.Fatalf("transcript message did not include source input")
t.Fatalf("transcript message = %q, want byte-identical shared transcript body", prepared.Messages[1].Content)
} }
if prepared.Messages[2].CacheControl == nil { if prepared.Messages[2].CacheControl == nil {
t.Fatalf("reference message did not render as cacheable user message: %#v", prepared.Messages[2]) 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") { if !strings.Contains(prepared.Messages[2].Content, "Alice: Aria") {
t.Fatalf("reference message missing roster content: %q", prepared.Messages[2].Content) t.Fatalf("reference message missing player content")
} }
if !strings.Contains(prepared.Messages[2].Content, "Glossary reference:\nBrightmantle: temple") { if !strings.Contains(prepared.Messages[2].Content, "Aria: cleric") {
t.Fatalf("reference message missing glossary content: %q", prepared.Messages[2].Content) t.Fatalf("reference message missing party content")
} }
if !strings.Contains(prepared.Messages[3].Content, "Divide the provided transcript") { if !strings.Contains(prepared.Messages[2].Content, "Brightmantle: temple") {
t.Fatalf("task message missing scene task text: %q", prepared.Messages[3].Content) t.Fatalf("reference message missing glossary content")
} }
if strings.Contains(prepared.Messages[3].Content, string(transcript)) { if strings.Contains(prepared.Messages[3].Content, string(transcript)) {
t.Fatalf("task message leaked transcript bytes") t.Fatalf("task message leaked transcript bytes")
@@ -48,7 +47,7 @@ func TestScriptoriumPromptPreparesTranscriptAndTaskMessages(t *testing.T) {
func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) { func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
transcript := []byte(`{"secret":"source text"}`) 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() metadata := New().ManifestMetadata()
payload, err := json.Marshal(map[string]any{ payload, err := json.Marshal(map[string]any{
@@ -70,9 +69,9 @@ func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
diagnostics := string(payload) diagnostics := string(payload)
for _, forbidden := range []string{ for _, forbidden := range []string{
"source text", "source text",
"private roster note", "private player note",
"private party note",
"private glossary note", "private glossary note",
"Divide the provided transcript",
`"properties"`, `"properties"`,
"start_unit_id", "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() t.Helper()
registry := llm.NewAssetRegistry() registry := llm.NewAssetRegistry()
if err := sharedassets.Register(registry); err != nil { 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", ProfileID: "scene-test-profile",
Inputs: map[string]scriptorium.ArtifactRef{ Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.InlineWithURI("file:///session.json", string(transcript)), "transcript": scriptorium.InlineWithURI("file:///session.json", string(transcript)),
"roster": scriptorium.Inline(roster), "players": scriptorium.Inline(players),
"party": scriptorium.Inline(party),
"glossary": scriptorium.Inline(glossary), "glossary": scriptorium.Inline(glossary),
}, },
}) })

View File

@@ -5,7 +5,10 @@ inputs:
- name: transcript - name: transcript
required: true required: true
content_type: application/json content_type: application/json
- name: roster - name: players
required: false
content_type: text/plain
- name: party
required: false required: false
content_type: text/plain content_type: text/plain
- name: glossary - name: glossary
@@ -13,13 +16,13 @@ inputs:
content_type: text/plain content_type: text/plain
messages: messages:
- role: system - role: system
content_file: ./common-dnd-system.md content_file: ./sharedassets/common-dnd-system.md
- role: user - role: user
content_file: ./common-dnd-transcript.md content_file: ./sharedassets/common-dnd-transcript.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user - role: user
content_file: ./common-dnd-references.md content_file: ./sharedassets/common-dnd-references.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user - 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 caster, spell name, effect, narrative description, and source references using
source_id, start_unit_id, and end_unit_id. source_id, start_unit_id, and end_unit_id.
Use roster and glossary reference material only to clarify source text. Do not Use player, party, and glossary reference material only to clarify source text.
return spells, casters, or effects that are mentioned only in reference Do not return spells, casters, or effects that are mentioned only in reference
material. material.
Return exactly one JSON object and no explanatory text. 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.", Description: "Optional campaign glossary reference material used only for disambiguation.",
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...), 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", 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...), AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
}, },
} }

View File

@@ -109,10 +109,16 @@ func TestExtractPassesReferencesAsPromptInputs(t *testing.T) {
req := extractionRequestWithClient(client) req := extractionRequestWithClient(client)
req.References = contracts.ReferenceSet{ req.References = contracts.ReferenceSet{
Slots: map[string]contracts.ResolvedReferenceSlot{ Slots: map[string]contracts.ResolvedReferenceSlot{
"roster": { "players": {
Slot: contracts.ReferenceSlot{Name: "roster"}, Slot: contracts.ReferenceSlot{Name: "players"},
Items: []contracts.ReferenceItem{ 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": { "glossary": {
@@ -135,8 +141,11 @@ func TestExtractPassesReferencesAsPromptInputs(t *testing.T) {
if request.PromptID != PromptID || request.PromptVersion != SchemaVersion { if request.PromptID != PromptID || request.PromptVersion != SchemaVersion {
t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, PromptID, 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" { if got := string(request.Inputs["players"].Content); got != "Alice: Aria Brightmantle" {
t.Fatalf("roster input = %q, want reference content", got) 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" { if got := string(request.Inputs["glossary"].Content); got != "Brightmantle: local temple name" {
t.Fatalf("glossary input = %q, want reference content", got) 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) { func TestExtractReturnsNoCandidatesForEmptyResponse(t *testing.T) {
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}} 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.", Description: "Optional campaign glossary reference material used only for disambiguation.",
AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}, 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", 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"}, 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) raw := readDNDSpellsFixture(t)
expectedDoc := parseDNDSpellsFixture(t, raw) expectedDoc := parseDNDSpellsFixture(t, raw)
resolved := resolveDNDSpellsPipeline(t) resolved := resolveDNDSpellsPipeline(t)
@@ -147,7 +147,7 @@ func TestRunnerPassesRosterAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T)
t.Fatalf("len(Approved) = %d, want 1", len(output.Approved)) t.Fatalf("len(Approved) = %d, want 1", len(output.Approved))
} }
if len(output.Manifest.References) != 2 { 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 { if len(llmClient.requests) != 1 {
t.Fatalf("LLM calls = %d, want 1", len(llmClient.requests)) 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 { if request.PromptID != PromptID || request.PromptVersion != SchemaVersion {
t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, PromptID, 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" { if got := string(request.Inputs["party"].Content); got != "Aria: party cleric\nBorin: fighter" {
t.Fatalf("roster input = %q, want reference text", got) t.Fatalf("party input = %q, want reference text", got)
} }
if got := string(request.Inputs["glossary"].Content); got != "Fire Bolt: evocation cantrip" { if got := string(request.Inputs["glossary"].Content); got != "Fire Bolt: evocation cantrip" {
t.Fatalf("glossary input = %q, want reference text", got) t.Fatalf("glossary input = %q, want reference text", got)
} }
} }
func TestRunnerDoesNotExtractSpellMentionedOnlyInRoster(t *testing.T) { func TestRunnerDoesNotExtractSpellMentionedOnlyInPartyReference(t *testing.T) {
raw := readDNDSpellsFixture(t) raw := readDNDSpellsFixture(t)
resolved := resolveDNDSpellsPipeline(t) resolved := resolveDNDSpellsPipeline(t)
resolved.ResolvedPipeline.ArtifactLanes[0].ExtractReferences.ReferenceSet = dndSpellsReferenceSet( resolved.ResolvedPipeline.ArtifactLanes[0].ExtractReferences.ReferenceSet = dndSpellsReferenceSet(
@@ -185,7 +185,7 @@ func TestRunnerDoesNotExtractSpellMentionedOnlyInRoster(t *testing.T) {
} }
if len(output.Approved) != 0 { 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 { if len(llmClient.requests) != 1 {
t.Fatalf("LLM calls = %d, want 1", len(llmClient.requests)) 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 { if request.PromptID != PromptID || request.PromptVersion != SchemaVersion {
t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, PromptID, 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") { if got := string(request.Inputs["party"].Content); !strings.Contains(got, "Lightning Bolt") {
t.Fatalf("roster input = %q, want roster-only spell in reference input", got) t.Fatalf("party input = %q, want party-reference-only spell in reference input", got)
} }
if output.Manifest.ValidationStatus != "approved" { if output.Manifest.ValidationStatus != "approved" {
t.Fatalf("ValidationStatus = %q, want approved empty extraction", output.Manifest.ValidationStatus) 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) slots := make(map[string]contracts.ResolvedReferenceSlot)
if strings.TrimSpace(roster) != "" { if strings.TrimSpace(party) != "" {
slots["roster"] = contracts.ResolvedReferenceSlot{ slots["party"] = contracts.ResolvedReferenceSlot{
Slot: contracts.ReferenceSlot{Name: "roster"}, Slot: contracts.ReferenceSlot{Name: "party"},
Items: []contracts.ReferenceItem{ Items: []contracts.ReferenceItem{
{ {
SlotName: "roster", SlotName: "party",
MediaType: "text/plain; charset=utf-8", MediaType: "text/plain; charset=utf-8",
Content: []byte(roster), Content: []byte(party),
Digest: "sha256:roster", Digest: "sha256:party",
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/roster.txt"}, Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/party.txt"},
SizeBytes: int64(len(roster)), SizeBytes: int64(len(party)),
BindingSource: contracts.ReferenceBindingSourceConfig, BindingSource: contracts.ReferenceBindingSourceConfig,
}, },
}, },

View File

@@ -29,9 +29,14 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
} }
func promptInputs(req contracts.ExtractionRequest) contracts.LLMInputSet { 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{ return contracts.LLMInputSet{
"transcript": transcriptPromptInput(req.SourceInput), "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"]), "glossary": referencePromptMaterial("glossary", req.References.Slots["glossary"]),
} }
} }

View File

@@ -15,7 +15,7 @@ import (
func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing.T) { func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing.T) {
transcript := []byte(`{"id":"session-1","segments":[{"id":"u1","text":"Mira casts shield."}]}`) 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 { if prepared.PromptID != PromptID {
t.Fatalf("prompt id = %q, want %q", 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 { if got := len(prepared.Messages); got != 5 {
t.Fatalf("message count = %d, want 5", got) 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 !strings.Contains(prepared.Messages[1].Content, string(transcript)) {
if prepared.Messages[1].Content != wantTranscript { t.Fatalf("transcript message did not include source input")
t.Fatalf("transcript message = %q, want byte-identical shared transcript body", prepared.Messages[1].Content)
} }
if prepared.Messages[1].CacheControl == nil || prepared.Messages[2].CacheControl == nil { if prepared.Messages[1].CacheControl == nil || prepared.Messages[2].CacheControl == nil {
t.Fatalf("expected transcript and reference messages to be cacheable: %#v", prepared.Messages) t.Fatalf("expected transcript and reference messages to be cacheable: %#v", prepared.Messages)
} }
if !strings.Contains(prepared.Messages[2].Content, "Roster reference:\nMira: wizard") { if !strings.Contains(prepared.Messages[2].Content, "Dana: Mira") {
t.Fatalf("reference message missing roster content: %q", prepared.Messages[2].Content) t.Fatalf("reference message missing player content")
} }
if !strings.Contains(prepared.Messages[2].Content, "Glossary reference:\nShield: abjuration") { if !strings.Contains(prepared.Messages[2].Content, "Mira: wizard") {
t.Fatalf("reference message missing glossary content: %q", prepared.Messages[2].Content) t.Fatalf("reference message missing party content")
} }
if !strings.Contains(prepared.Messages[3].Content, "Extract Dungeons & Dragons spell-cast artifacts") { if !strings.Contains(prepared.Messages[2].Content, "Shield: abjuration") {
t.Fatalf("task message missing spell task text: %q", prepared.Messages[3].Content) t.Fatalf("reference message missing glossary content")
} }
if strings.Contains(prepared.Messages[3].Content, string(transcript)) { if strings.Contains(prepared.Messages[3].Content, string(transcript)) {
t.Fatalf("task message leaked transcript bytes") t.Fatalf("task message leaked transcript bytes")
@@ -46,13 +45,10 @@ func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing
func TestScriptoriumPromptPreparesWithMissingOptionalReferences(t *testing.T) { func TestScriptoriumPromptPreparesWithMissingOptionalReferences(t *testing.T) {
transcript := []byte(`{"id":"session-1","segments":[]}`) transcript := []byte(`{"id":"session-1","segments":[]}`)
prepared := prepareSpellsPrompt(t, transcript, " ", " ") prepared := prepareSpellsPrompt(t, transcript, " ", " ", " ")
if !strings.Contains(prepared.Messages[2].Content, "Roster reference:\n ") { if !strings.Contains(prepared.Messages[2].Content, " ") {
t.Fatalf("reference message did not include empty roster input: %q", prepared.Messages[2].Content) t.Fatalf("reference message did not include empty optional reference placeholders")
}
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)
} }
} }
@@ -101,8 +97,8 @@ func TestSingleReferencePromptInputKeepsContentOnly(t *testing.T) {
func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) { func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
transcript := []byte(`{"secret":"source text"}`) transcript := []byte(`{"secret":"source text"}`)
reference := "private roster note" reference := "private party note"
prepared := prepareSpellsPrompt(t, transcript, reference, " ") prepared := prepareSpellsPrompt(t, transcript, "private player note", reference, " ")
metadata := New().ManifestMetadata() metadata := New().ManifestMetadata()
payload, err := json.Marshal(map[string]any{ payload, err := json.Marshal(map[string]any{
@@ -124,8 +120,8 @@ func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
diagnostics := string(payload) diagnostics := string(payload)
for _, forbidden := range []string{ for _, forbidden := range []string{
"source text", "source text",
"private player note",
reference, reference,
"Extract Dungeons & Dragons spell-cast artifacts",
`"properties"`, `"properties"`,
"spell_casts", "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() t.Helper()
registry := llm.NewAssetRegistry() registry := llm.NewAssetRegistry()
if err := sharedassets.Register(registry); err != nil { 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", ProfileID: "spell-test-profile",
Inputs: map[string]scriptorium.ArtifactRef{ Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.InlineWithURI("file:///session.json", string(transcript)), "transcript": scriptorium.InlineWithURI("file:///session.json", string(transcript)),
"roster": scriptorium.Inline(roster), "players": scriptorium.Inline(players),
"party": scriptorium.Inline(party),
"glossary": scriptorium.Inline(glossary), "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 below. Use it only to disambiguate names, aliases, speakers, campaign terms, or
spell names already present in the transcript. spell names already present in the transcript.
Roster reference: Player list reference:
{{ input "roster" }} {{ input "players" }}
Party roster reference:
{{ input "party" }}
Glossary reference: Glossary reference:
{{ input "glossary" }} {{ input "glossary" }}

View File

@@ -19,7 +19,8 @@ type ModulePromptFile struct {
} }
// ModulePromptFS builds a prompt filesystem for a module directory from // 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) { func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile) (fs.FS, error) {
return modulePromptFS(moduleDir, moduleFS, files, embeddedAssets) return modulePromptFS(moduleDir, moduleFS, files, embeddedAssets)
} }
@@ -60,7 +61,7 @@ func modulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile,
if err != nil { if err != nil {
return nil, fmt.Errorf("read shared prompt asset %s: %w", name, err) 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 return assets, nil
} }

View File

@@ -20,18 +20,21 @@ func TestModulePromptFSCombinesModuleAndSharedPrompts(t *testing.T) {
} }
tests := map[string]string{ tests := map[string]string{
"assets/prompts/dnd.test/dnd.test.yaml": "id: dnd.test", "assets/prompts/dnd.test/dnd.test.yaml": "id: dnd.test",
"assets/prompts/dnd.test/task.md": "task", "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/sharedassets/common-dnd-system.md": "",
"assets/prompts/dnd.test/common-dnd-transcript.md": "{{ input \"transcript\" }}", "assets/prompts/dnd.test/sharedassets/common-dnd-transcript.md": "",
"assets/prompts/dnd.test/common-dnd-references.md": "Roster reference:", "assets/prompts/dnd.test/sharedassets/common-dnd-references.md": "",
} }
for path, want := range tests { for path, want := range tests {
data, err := fs.ReadFile(fsys, path) data, err := fs.ReadFile(fsys, path)
if err != nil { if err != nil {
t.Fatalf("ReadFile(%q) error = %v, want nil", path, err) 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) t.Fatalf("ReadFile(%q) = %q, want substring %q", path, data, want)
} }
} }