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

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