Bugfix in the built-in prompt references definition
This commit is contained in:
@@ -252,15 +252,17 @@ The `generic` chunker accepts:
|
|||||||
`max_units`.
|
`max_units`.
|
||||||
|
|
||||||
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.
|
configured structured LLM runtime, and does not accept module options. It
|
||||||
|
declares optional `roster` and `glossary` references for scene disambiguation.
|
||||||
|
|
||||||
The `dnd/spells` extractor declares optional text reference slots:
|
The `dnd/spells` extractor declares optional reference slots:
|
||||||
|
|
||||||
- `roster`
|
- `roster`
|
||||||
- `glossary`
|
- `glossary`
|
||||||
|
|
||||||
The extractor uses these references only as supporting disambiguation material;
|
Both modules accept UTF-8 plain text, Markdown, YAML, or JSON reference files.
|
||||||
spell casts still must be present in the source transcript.
|
The extractor uses references only as supporting disambiguation material; spell
|
||||||
|
casts still must be present in the source transcript.
|
||||||
|
|
||||||
## Diagnostics
|
## Diagnostics
|
||||||
|
|
||||||
|
|||||||
@@ -157,10 +157,10 @@ 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 extractor declares optional `roster` and `glossary` reference slots
|
The `dnd/scenes` chunker and `dnd/spells` extractor declare optional `roster`
|
||||||
accepting UTF-8 text without narrowing accepted media types. Its prompt frames
|
and `glossary` reference slots accepting UTF-8 plain text, Markdown, YAML, or
|
||||||
references as supporting disambiguation material only; spell-cast artifacts must
|
JSON. Their prompts frame references as supporting disambiguation material only;
|
||||||
still be grounded in the source transcript.
|
spell-cast artifacts must still be grounded in the source transcript.
|
||||||
|
|
||||||
## D&D Spell Validators
|
## D&D Spell Validators
|
||||||
|
|
||||||
|
|||||||
@@ -119,8 +119,8 @@ Symptoms include:
|
|||||||
Fix:
|
Fix:
|
||||||
|
|
||||||
- Confirm the selected chunker, extractor, or normalizer declares the slot. The
|
- Confirm the selected chunker, extractor, or normalizer declares the slot. The
|
||||||
implemented `dnd/spells` extractor declares optional `roster` and `glossary`
|
implemented `dnd/scenes` chunker and `dnd/spells` extractor declare optional
|
||||||
slots.
|
`roster` and `glossary` slots.
|
||||||
- Use a specific selector when more than one selected target declares the same
|
- Use a specific selector when more than one selected target declares the same
|
||||||
slot. Examples include `chunk.context=./context.txt`,
|
slot. Examples include `chunk.context=./context.txt`,
|
||||||
`spells.extract.context=./extract-context.txt`, and
|
`spells.extract.context=./extract-context.txt`, and
|
||||||
@@ -136,7 +136,8 @@ Fix:
|
|||||||
- Ensure the file is readable UTF-8 text and within any byte limit declared by
|
- Ensure the file is readable UTF-8 text and within any byte limit declared by
|
||||||
the declaring module.
|
the declaring module.
|
||||||
- If the declaring module narrows accepted media types, use a file extension that
|
- If the declaring module narrows accepted media types, use a file extension that
|
||||||
infers an accepted type such as `text/markdown` or `application/json`.
|
infers an accepted type such as `text/markdown`, `application/yaml`, or
|
||||||
|
`application/json`.
|
||||||
Unknown extensions infer `application/octet-stream`.
|
Unknown extensions infer `application/octet-stream`.
|
||||||
- If diagnostics are retained, inspect `resolved-pipeline.json`,
|
- If diagnostics are retained, inspect `resolved-pipeline.json`,
|
||||||
`resolved-references.json`, and `error.log`.
|
`resolved-references.json`, and `error.log`.
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ func referenceMediaTypeForPath(path string) string {
|
|||||||
if extension == ".md" || extension == ".markdown" {
|
if extension == ".md" || extension == ".markdown" {
|
||||||
return "text/markdown"
|
return "text/markdown"
|
||||||
}
|
}
|
||||||
|
if extension == ".yaml" || extension == ".yml" {
|
||||||
|
return "application/yaml"
|
||||||
|
}
|
||||||
return unknownMediaType
|
return unknownMediaType
|
||||||
}
|
}
|
||||||
return canonicalMediaType(mediaType)
|
return canonicalMediaType(mediaType)
|
||||||
|
|||||||
@@ -249,6 +249,25 @@ func TestMaterializeReferencesAcceptsDeclaredJSONMediaType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMaterializeReferencesAcceptsDeclaredYAMLMediaType(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
path := filepath.Join(configDir, "roster.yaml")
|
||||||
|
writeReferenceFile(t, path, []byte("aria: cleric\n"))
|
||||||
|
|
||||||
|
slot := contracts.ReferenceSlot{Name: "roster", AcceptedMediaTypes: []string{"application/yaml"}}
|
||||||
|
resolved := resolvedPipelineWithReference(t, "roster", "roster.yaml", contracts.ReferenceBindingSourceConfig, slot)
|
||||||
|
materialized, _, err := MaterializeReferences(resolved, referenceCatalog(t, []contracts.ReferenceSlot{slot}), ReferenceMaterializationOptions{
|
||||||
|
ConfigPath: filepath.Join(configDir, "config.yml"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MaterializeReferences() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
item := materialized.ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["roster"].Items[0]
|
||||||
|
if item.MediaType != "application/yaml" {
|
||||||
|
t.Fatalf("MediaType = %q, want application/yaml", item.MediaType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMaterializeReferencesRejectsUnacceptedMediaType(t *testing.T) {
|
func TestMaterializeReferencesRejectsUnacceptedMediaType(t *testing.T) {
|
||||||
configDir := t.TempDir()
|
configDir := t.TempDir()
|
||||||
path := filepath.Join(configDir, "roster.json")
|
path := filepath.Join(configDir, "roster.json")
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
id: dnd.scenes
|
id: dnd.scenes
|
||||||
version: "v1"
|
version: "v1"
|
||||||
default_profile: mistral-small-3
|
default_profile: gemini-2-flash
|
||||||
inputs:
|
inputs:
|
||||||
- name: transcript
|
- name: transcript
|
||||||
required: true
|
required: true
|
||||||
content_type: application/json
|
content_type: application/json
|
||||||
|
- name: roster
|
||||||
|
required: false
|
||||||
|
content_type: text/plain
|
||||||
|
- name: glossary
|
||||||
|
required: false
|
||||||
|
content_type: text/plain
|
||||||
messages:
|
messages:
|
||||||
- role: system
|
- role: system
|
||||||
content_file: ./shared/system.md
|
content_file: ./shared/system.md
|
||||||
@@ -12,6 +18,10 @@ messages:
|
|||||||
content_file: ./shared/transcript.md
|
content_file: ./shared/transcript.md
|
||||||
cache_control:
|
cache_control:
|
||||||
type: ephemeral
|
type: ephemeral
|
||||||
|
- role: user
|
||||||
|
content_file: ./shared/references.md
|
||||||
|
cache_control:
|
||||||
|
type: ephemeral
|
||||||
- role: user
|
- role: user
|
||||||
content_file: ./dnd/scenes/task.md
|
content_file: ./dnd/scenes/task.md
|
||||||
- role: user
|
- role: user
|
||||||
|
|||||||
@@ -21,6 +21,27 @@ var providedCapabilities = []string{
|
|||||||
"chunks.scenes",
|
"chunks.scenes",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var acceptedReferenceMediaTypes = []string{
|
||||||
|
"application/json",
|
||||||
|
"application/x-yaml",
|
||||||
|
"application/yaml",
|
||||||
|
"text/markdown",
|
||||||
|
"text/plain",
|
||||||
|
}
|
||||||
|
|
||||||
|
var referenceSlots = []contracts.ReferenceSlot{
|
||||||
|
{
|
||||||
|
Name: "glossary",
|
||||||
|
Description: "Optional campaign glossary 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.",
|
||||||
|
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
var _ contracts.Chunker = (*Chunker)(nil)
|
var _ contracts.Chunker = (*Chunker)(nil)
|
||||||
var _ contracts.ManifestMetadataProvider = (*Chunker)(nil)
|
var _ contracts.ManifestMetadataProvider = (*Chunker)(nil)
|
||||||
|
|
||||||
@@ -35,7 +56,7 @@ func (c *Chunker) Key() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Chunker) ReferenceSlots() []contracts.ReferenceSlot {
|
func (c *Chunker) ReferenceSlots() []contracts.ReferenceSlot {
|
||||||
return nil
|
return cloneReferenceSlots(referenceSlots)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Chunker) ManifestMetadata() map[string]any {
|
func (c *Chunker) ManifestMetadata() map[string]any {
|
||||||
@@ -91,9 +112,7 @@ func (c *Chunker) Chunk(ctx context.Context, req contracts.ChunkRequest) (contra
|
|||||||
PromptVersion: ResponseSchemaVersion,
|
PromptVersion: ResponseSchemaVersion,
|
||||||
ProfileID: req.LLMProfile,
|
ProfileID: req.LLMProfile,
|
||||||
SessionID: req.SessionID,
|
SessionID: req.SessionID,
|
||||||
Inputs: contracts.LLMInputSet{
|
Inputs: promptInputs(req),
|
||||||
"transcript": transcriptPromptInput(req.SourceInput),
|
|
||||||
},
|
|
||||||
}, &response); err != nil {
|
}, &response); err != nil {
|
||||||
return contracts.ChunkResult{}, chunkerErrorf("complete structured output: %w", err)
|
return contracts.ChunkResult{}, chunkerErrorf("complete structured output: %w", err)
|
||||||
}
|
}
|
||||||
@@ -112,18 +131,13 @@ func (c *Chunker) Chunk(ctx context.Context, req contracts.ChunkRequest) (contra
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func transcriptPromptInput(material contracts.LLMInputMaterial) contracts.LLMInputMaterial {
|
|
||||||
out := material.Clone()
|
|
||||||
out.Name = "transcript"
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func ModuleSpec() pipeline.ModuleSpec {
|
func ModuleSpec() pipeline.ModuleSpec {
|
||||||
return pipeline.ModuleSpec{
|
return pipeline.ModuleSpec{
|
||||||
Key: Key,
|
Key: Key,
|
||||||
Stage: pipeline.StageChunk,
|
Stage: pipeline.StageChunk,
|
||||||
Requires: append([]string(nil), requiredCapabilities...),
|
Requires: append([]string(nil), requiredCapabilities...),
|
||||||
Provides: append([]string(nil), providedCapabilities...),
|
Provides: append([]string(nil), providedCapabilities...),
|
||||||
|
ReferenceSlots: cloneReferenceSlots(referenceSlots),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,3 +328,15 @@ func cloneMetadata(metadata map[string]any) map[string]any {
|
|||||||
func chunkerErrorf(format string, args ...any) error {
|
func chunkerErrorf(format string, args ...any) error {
|
||||||
return fmt.Errorf("dnd scenes chunker: "+format, args...)
|
return fmt.Errorf("dnd scenes chunker: "+format, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneReferenceSlots(slots []contracts.ReferenceSlot) []contracts.ReferenceSlot {
|
||||||
|
if len(slots) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]contracts.ReferenceSlot, len(slots))
|
||||||
|
for i, slot := range slots {
|
||||||
|
slot.AcceptedMediaTypes = append([]string(nil), slot.AcceptedMediaTypes...)
|
||||||
|
out[i] = slot
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ func TestNewModuleSpecAndRegister(t *testing.T) {
|
|||||||
Stage: pipeline.StageChunk,
|
Stage: pipeline.StageChunk,
|
||||||
Requires: []string{"source.transcript"},
|
Requires: []string{"source.transcript"},
|
||||||
Provides: []string{"chunks", "chunks.scenes"},
|
Provides: []string{"chunks", "chunks.scenes"},
|
||||||
|
ReferenceSlots: wantReferenceSlots(),
|
||||||
}
|
}
|
||||||
if got := ModuleSpec(); !reflect.DeepEqual(got, want) {
|
if got := ModuleSpec(); !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("ModuleSpec() = %#v, want %#v", got, want)
|
t.Fatalf("ModuleSpec() = %#v, want %#v", got, want)
|
||||||
@@ -34,6 +35,7 @@ func TestNewModuleSpecAndRegister(t *testing.T) {
|
|||||||
got := ModuleSpec()
|
got := ModuleSpec()
|
||||||
got.Requires[0] = "changed"
|
got.Requires[0] = "changed"
|
||||||
got.Provides[0] = "changed"
|
got.Provides[0] = "changed"
|
||||||
|
got.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed"
|
||||||
if again := ModuleSpec(); !reflect.DeepEqual(again, want) {
|
if again := ModuleSpec(); !reflect.DeepEqual(again, want) {
|
||||||
t.Fatalf("ModuleSpec() after caller mutation = %#v, want %#v", again, want)
|
t.Fatalf("ModuleSpec() after caller mutation = %#v, want %#v", again, want)
|
||||||
}
|
}
|
||||||
@@ -56,8 +58,8 @@ func TestNewModuleSpecAndRegister(t *testing.T) {
|
|||||||
if built.Key() != Key {
|
if built.Key() != Key {
|
||||||
t.Fatalf("built Key() = %q, want %q", built.Key(), Key)
|
t.Fatalf("built Key() = %q, want %q", built.Key(), Key)
|
||||||
}
|
}
|
||||||
if slots := built.ReferenceSlots(); len(slots) != 0 {
|
if slots := built.ReferenceSlots(); !reflect.DeepEqual(slots, want.ReferenceSlots) {
|
||||||
t.Fatalf("ReferenceSlots() = %#v, want none", slots)
|
t.Fatalf("ReferenceSlots() = %#v, want %#v", slots, want.ReferenceSlots)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +73,22 @@ func TestRegisterNilRegistryReturnsError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func wantReferenceSlots() []contracts.ReferenceSlot {
|
||||||
|
accepted := []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}
|
||||||
|
return []contracts.ReferenceSlot{
|
||||||
|
{
|
||||||
|
Name: "glossary",
|
||||||
|
Description: "Optional campaign glossary 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.",
|
||||||
|
AcceptedMediaTypes: append([]string(nil), accepted...),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChunkReturnsSceneChunksFromStructuredOutput(t *testing.T) {
|
func TestChunkReturnsSceneChunksFromStructuredOutput(t *testing.T) {
|
||||||
client := &fakeScenesLLMClient{
|
client := &fakeScenesLLMClient{
|
||||||
response: chunkResponse{
|
response: chunkResponse{
|
||||||
@@ -128,6 +146,12 @@ 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 != " " {
|
||||||
|
t.Fatalf("roster 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)
|
||||||
|
}
|
||||||
|
|
||||||
if got := chunkIDs(result.Chunks); !reflect.DeepEqual(got, []string{"scene-000001", "scene-000002"}) {
|
if got := chunkIDs(result.Chunks); !reflect.DeepEqual(got, []string{"scene-000001", "scene-000002"}) {
|
||||||
t.Fatalf("chunk IDs = %#v, want deterministic scene IDs", got)
|
t.Fatalf("chunk IDs = %#v, want deterministic scene IDs", got)
|
||||||
@@ -162,6 +186,54 @@ func TestChunkReturnsSceneChunksFromStructuredOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChunkPassesReferencesAsPromptInputs(t *testing.T) {
|
||||||
|
client := &fakeScenesLLMClient{response: chunkResponse{
|
||||||
|
Scenes: []sceneResponse{
|
||||||
|
{
|
||||||
|
StartUnitID: "seg-001",
|
||||||
|
EndUnitID: "seg-004",
|
||||||
|
ShortTitle: "Ambush",
|
||||||
|
PrimaryMode: "Combat",
|
||||||
|
MainParticipants: []string{"Aria"},
|
||||||
|
Summary: "The party is ambushed.",
|
||||||
|
BoundaryNote: "One scene covers the short fixture.",
|
||||||
|
BoundaryConfidence: "High",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
req := chunkRequestWithClient(client)
|
||||||
|
req.References = contracts.ReferenceSet{
|
||||||
|
Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||||
|
"roster": {
|
||||||
|
Slot: contracts.ReferenceSlot{Name: "roster"},
|
||||||
|
Items: []contracts.ReferenceItem{
|
||||||
|
{SlotName: "roster", Content: []byte("Aria: cleric")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"glossary": {
|
||||||
|
Slot: contracts.ReferenceSlot{Name: "glossary"},
|
||||||
|
Items: []contracts.ReferenceItem{
|
||||||
|
{SlotName: "glossary", Content: []byte("Brightmantle: local temple")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := New().Chunk(context.Background(), req); err != nil {
|
||||||
|
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["glossary"].Content); got != "Brightmantle: local temple" {
|
||||||
|
t.Fatalf("glossary input = %q, want reference content", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(request.Inputs["transcript"].Content), "Aria: cleric") {
|
||||||
|
t.Fatalf("transcript input contains reference content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChunkRejectsWhitespaceOnlyBoundaryCaveats(t *testing.T) {
|
func TestChunkRejectsWhitespaceOnlyBoundaryCaveats(t *testing.T) {
|
||||||
client := &fakeScenesLLMClient{
|
client := &fakeScenesLLMClient{
|
||||||
response: chunkResponse{
|
response: chunkResponse{
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package scenes
|
package scenes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/promptassets"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/promptassets"
|
||||||
)
|
)
|
||||||
@@ -16,18 +20,88 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
|||||||
return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas")
|
return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func promptInputs(req contracts.ChunkRequest) contracts.LLMInputSet {
|
||||||
|
return contracts.LLMInputSet{
|
||||||
|
"transcript": transcriptPromptInput(req.SourceInput),
|
||||||
|
"roster": referencePromptMaterial("roster", req.References.Slots["roster"]),
|
||||||
|
"glossary": referencePromptMaterial("glossary", req.References.Slots["glossary"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func transcriptPromptInput(material contracts.LLMInputMaterial) contracts.LLMInputMaterial {
|
||||||
|
out := material.Clone()
|
||||||
|
out.Name = "transcript"
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func referencePromptMaterial(name string, slot contracts.ResolvedReferenceSlot) contracts.LLMInputMaterial {
|
||||||
|
body := referencePromptInput(slot)
|
||||||
|
digest := ""
|
||||||
|
originURI := ""
|
||||||
|
if len(slot.Items) == 1 {
|
||||||
|
digest = slot.Items[0].Digest
|
||||||
|
originURI = slot.Items[0].Origin.URI
|
||||||
|
}
|
||||||
|
return contracts.NewLLMInputMaterial(name, "text/plain", body, digest, originURI)
|
||||||
|
}
|
||||||
|
|
||||||
func scriptoriumPromptMetadata() (string, error) {
|
func scriptoriumPromptMetadata() (string, error) {
|
||||||
scriptoriumPromptHashOnce.Do(func() {
|
scriptoriumPromptHashOnce.Do(func() {
|
||||||
parts := append([]llm.AssetHashPart{
|
parts := append([]llm.AssetHashPart{
|
||||||
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd.scenes.yaml"},
|
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd.scenes.yaml"},
|
||||||
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/scenes/task.md"},
|
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/scenes/task.md"},
|
||||||
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/scenes/instructions.md"},
|
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/scenes/instructions.md"},
|
||||||
}, promptassets.CommonHashParts()...)
|
}, append(promptassets.CommonHashParts(), promptassets.ReferenceHashParts()...)...)
|
||||||
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
|
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
|
||||||
})
|
})
|
||||||
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func referencePromptInput(slot contracts.ResolvedReferenceSlot) []byte {
|
||||||
|
if len(slot.Items) == 0 {
|
||||||
|
return []byte(" ")
|
||||||
|
}
|
||||||
|
items := append([]contracts.ReferenceItem(nil), slot.Items...)
|
||||||
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
if items[i].Origin.URI != items[j].Origin.URI {
|
||||||
|
return items[i].Origin.URI < items[j].Origin.URI
|
||||||
|
}
|
||||||
|
if items[i].Digest != items[j].Digest {
|
||||||
|
return items[i].Digest < items[j].Digest
|
||||||
|
}
|
||||||
|
return string(items[i].Content) < string(items[j].Content)
|
||||||
|
})
|
||||||
|
if len(items) == 1 {
|
||||||
|
return append([]byte(nil), items[0].Content...)
|
||||||
|
}
|
||||||
|
|
||||||
|
var b bytes.Buffer
|
||||||
|
for i, item := range items {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "Reference %d\n", i+1)
|
||||||
|
if item.Origin.Type != "" {
|
||||||
|
fmt.Fprintf(&b, "Origin-Type: %s\n", item.Origin.Type)
|
||||||
|
}
|
||||||
|
if item.Origin.URI != "" {
|
||||||
|
fmt.Fprintf(&b, "Origin-URI: %s\n", item.Origin.URI)
|
||||||
|
}
|
||||||
|
if item.Digest != "" {
|
||||||
|
fmt.Fprintf(&b, "Digest: %s\n", item.Digest)
|
||||||
|
}
|
||||||
|
if item.MediaType != "" {
|
||||||
|
fmt.Fprintf(&b, "Media-Type: %s\n", item.MediaType)
|
||||||
|
}
|
||||||
|
if item.SizeBytes > 0 {
|
||||||
|
fmt.Fprintf(&b, "Size-Bytes: %d\n", item.SizeBytes)
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.Write(item.Content)
|
||||||
|
}
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
scriptoriumPromptHashOnce sync.Once
|
scriptoriumPromptHashOnce sync.Once
|
||||||
scriptoriumPromptHash string
|
scriptoriumPromptHash string
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ 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)
|
prepared := prepareScenesPrompt(t, transcript, "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)
|
||||||
}
|
}
|
||||||
if got := len(prepared.Messages); got != 4 {
|
if got := len(prepared.Messages); got != 5 {
|
||||||
t.Fatalf("message count = %d, want 4", got)
|
t.Fatalf("message count = %d, want 5", got)
|
||||||
}
|
}
|
||||||
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])
|
||||||
@@ -29,17 +29,26 @@ func TestScriptoriumPromptPreparesTranscriptAndTaskMessages(t *testing.T) {
|
|||||||
if prepared.Messages[1].Content != wantTranscript {
|
if prepared.Messages[1].Content != wantTranscript {
|
||||||
t.Fatalf("transcript message = %q, want byte-identical shared transcript body", prepared.Messages[1].Content)
|
t.Fatalf("transcript message = %q, want byte-identical shared transcript body", prepared.Messages[1].Content)
|
||||||
}
|
}
|
||||||
if !strings.Contains(prepared.Messages[2].Content, "Divide the provided transcript") {
|
if prepared.Messages[2].CacheControl == nil {
|
||||||
t.Fatalf("task message missing scene task text: %q", prepared.Messages[2].Content)
|
t.Fatalf("reference message did not render as cacheable user message: %#v", prepared.Messages[2])
|
||||||
}
|
}
|
||||||
if strings.Contains(prepared.Messages[2].Content, string(transcript)) {
|
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, "Glossary reference:\nBrightmantle: temple") {
|
||||||
|
t.Fatalf("reference message missing glossary content: %q", prepared.Messages[2].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[3].Content, string(transcript)) {
|
||||||
t.Fatalf("task message leaked transcript bytes")
|
t.Fatalf("task message leaked transcript bytes")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
|
func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
|
||||||
transcript := []byte(`{"secret":"source text"}`)
|
transcript := []byte(`{"secret":"source text"}`)
|
||||||
prepared := prepareScenesPrompt(t, transcript)
|
prepared := prepareScenesPrompt(t, transcript, "private roster note", "private glossary note")
|
||||||
metadata := New().ManifestMetadata()
|
metadata := New().ManifestMetadata()
|
||||||
|
|
||||||
payload, err := json.Marshal(map[string]any{
|
payload, err := json.Marshal(map[string]any{
|
||||||
@@ -61,6 +70,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 roster note",
|
||||||
|
"private glossary note",
|
||||||
"Divide the provided transcript",
|
"Divide the provided transcript",
|
||||||
`"properties"`,
|
`"properties"`,
|
||||||
"start_unit_id",
|
"start_unit_id",
|
||||||
@@ -77,7 +88,7 @@ func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareScenesPrompt(t *testing.T, transcript []byte) *scriptorium.PreparedRun {
|
func prepareScenesPrompt(t *testing.T, transcript []byte, roster string, glossary string) *scriptorium.PreparedRun {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
registry := llm.NewAssetRegistry()
|
registry := llm.NewAssetRegistry()
|
||||||
if err := promptassets.Register(registry); err != nil {
|
if err := promptassets.Register(registry); err != nil {
|
||||||
@@ -93,6 +104,8 @@ func prepareScenesPrompt(t *testing.T, transcript []byte) *scriptorium.PreparedR
|
|||||||
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),
|
||||||
|
"glossary": scriptorium.Inline(glossary),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
id: dnd.spells
|
id: dnd.spells
|
||||||
version: "v1"
|
version: "v1"
|
||||||
default_profile: mistral-small-3
|
default_profile: gemini-2-flash
|
||||||
inputs:
|
inputs:
|
||||||
- name: transcript
|
- name: transcript
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -25,16 +25,24 @@ var providedCapabilities = []string{
|
|||||||
"dnd.spell_casts",
|
"dnd.spell_casts",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var acceptedReferenceMediaTypes = []string{
|
||||||
|
"application/json",
|
||||||
|
"application/x-yaml",
|
||||||
|
"application/yaml",
|
||||||
|
"text/markdown",
|
||||||
|
"text/plain",
|
||||||
|
}
|
||||||
|
|
||||||
var referenceSlots = []contracts.ReferenceSlot{
|
var referenceSlots = []contracts.ReferenceSlot{
|
||||||
{
|
{
|
||||||
Name: "glossary",
|
Name: "glossary",
|
||||||
Description: "Optional campaign glossary reference material used only for disambiguation.",
|
Description: "Optional campaign glossary reference material used only for disambiguation.",
|
||||||
AcceptedMediaTypes: []string{"text/plain; charset=utf-8"},
|
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "roster",
|
Name: "roster",
|
||||||
Description: "Optional campaign roster or player-character reference material used only for disambiguation.",
|
Description: "Optional campaign roster or player-character reference material used only for disambiguation.",
|
||||||
AcceptedMediaTypes: []string{"text/plain; charset=utf-8"},
|
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ func TestModuleSpec(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "glossary",
|
Name: "glossary",
|
||||||
Description: "Optional campaign glossary reference material used only for disambiguation.",
|
Description: "Optional campaign glossary reference material used only for disambiguation.",
|
||||||
AcceptedMediaTypes: []string{"text/plain; charset=utf-8"},
|
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: "Optional campaign roster or player-character reference material used only for disambiguation.",
|
||||||
AcceptedMediaTypes: []string{"text/plain; charset=utf-8"},
|
AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user