Adjust the prompt FS layout and test strategy
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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...),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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{}}}
|
||||
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user