Add Scriptorium prompt assets

This commit is contained in:
2026-07-05 18:13:10 +00:00
parent 0fc740470f
commit de6689bc1d
21 changed files with 1123 additions and 8 deletions

View File

@@ -2,5 +2,5 @@ package spells
import "embed"
//go:embed assets/prompts/*.md assets/schemas/*.json
//go:embed assets/prompts/*.md assets/schemas/*.json assets/scriptorium/prompts/*.yaml assets/scriptorium/prompts/dnd/spells/*.md
var embeddedAssets embed.FS

View File

@@ -0,0 +1,32 @@
id: dnd.spells
version: "v1"
inputs:
- name: transcript
required: true
content_type: application/json
- name: roster
required: false
content_type: text/plain
- name: glossary
required: false
content_type: text/plain
messages:
- role: system
content_file: ./shared/system.md
- role: user
content_file: ./shared/transcript.md
cache_control:
type: ephemeral
- role: user
content_file: ./shared/references.md
cache_control:
type: ephemeral
- role: user
content_file: ./dnd/spells/task.md
- role: user
content_file: ./dnd/spells/instructions.md
output:
format: json
validation_mode: json_schema
schema_path: dnd_spells.v1.json
repair_attempts: 0

View File

@@ -0,0 +1,11 @@
Source references must use the source-unit IDs exactly as provided.
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
material.
Return exactly one JSON object and no explanatory text.

View File

@@ -0,0 +1,5 @@
Extract Dungeons & Dragons spell-cast artifacts from the provided transcript.
Extract only spell casts that are supported by the transcript. Do not infer
spells from general D&D knowledge or from table chatter that does not identify a
spell being cast.

View File

@@ -63,11 +63,14 @@ func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot {
}
func (e *Extractor) ManifestMetadata() map[string]any {
promptMetadata := spellsPromptBundle.Metadata()
promptSHA, err := scriptoriumPromptMetadata()
if err != nil {
promptSHA = spellsPromptBundle.Metadata().SHA256
}
metadata := map[string]any{
"prompt_id": PromptID,
"prompt_version": promptMetadata.PromptVersion,
"prompt_sha256": promptMetadata.SHA256,
"prompt_version": SchemaVersion,
"prompt_sha256": promptSHA,
"response_schema_key": string(ResponseSchemaKey),
"response_schema_id": ResponseSchemaID,
"response_schema_name": ResponseSchemaName,

View File

@@ -0,0 +1,84 @@
package spells
import (
"bytes"
"fmt"
"sort"
"sync"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/promptassets"
)
const scriptoriumPromptRoot = "assets/scriptorium/prompts"
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
if err := registry.RegisterPromptFS(embeddedAssets, scriptoriumPromptRoot); err != nil {
return err
}
return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas")
}
func scriptoriumPromptMetadata() (string, error) {
scriptoriumPromptHashOnce.Do(func() {
parts := append([]llm.AssetHashPart{
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd.spells.yaml"},
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/spells/task.md"},
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/spells/instructions.md"},
}, append(promptassets.CommonHashParts(), promptassets.ReferenceHashParts()...)...)
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
})
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 (
scriptoriumPromptHashOnce sync.Once
scriptoriumPromptHash string
scriptoriumPromptHashErr error
)

View File

@@ -0,0 +1,180 @@
package spells
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/promptassets"
"gitea.maximumdirect.net/eric/scriptorium"
)
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")
if prepared.PromptID != PromptID {
t.Fatalf("prompt id = %q, want %q", prepared.PromptID, PromptID)
}
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 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, "Glossary reference:\nShield: abjuration") {
t.Fatalf("reference message missing glossary content: %q", prepared.Messages[2].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[3].Content, string(transcript)) {
t.Fatalf("task message leaked transcript bytes")
}
}
func TestScriptoriumPromptPreparesWithMissingOptionalReferences(t *testing.T) {
transcript := []byte(`{"id":"session-1","segments":[]}`)
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)
}
}
func TestReferencePromptInputRenderingIsDeterministic(t *testing.T) {
slot := contracts.ResolvedReferenceSlot{
Items: []contracts.ReferenceItem{
{
SlotName: "roster",
MediaType: "text/plain",
Content: []byte("second"),
Digest: "sha256:bbb",
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///b.txt"},
SizeBytes: 6,
},
{
SlotName: "roster",
MediaType: "text/plain",
Content: []byte("first"),
Digest: "sha256:aaa",
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///a.txt"},
SizeBytes: 5,
},
},
}
first := string(referencePromptInput(slot))
second := string(referencePromptInput(slot))
if first != second {
t.Fatalf("reference rendering was not deterministic:\nfirst=%q\nsecond=%q", first, second)
}
if !strings.Contains(first, "Reference 1\nOrigin-Type: file\nOrigin-URI: file:///a.txt\nDigest: sha256:aaa") {
t.Fatalf("first reference heading was not stable: %q", first)
}
if strings.Index(first, "first") > strings.Index(first, "second") {
t.Fatalf("references were not sorted deterministically: %q", first)
}
}
func TestSingleReferencePromptInputKeepsContentOnly(t *testing.T) {
got := string(referencePromptInput(contracts.ResolvedReferenceSlot{
Items: []contracts.ReferenceItem{{Content: []byte("single reference")}},
}))
if got != "single reference" {
t.Fatalf("single reference rendering = %q, want raw content only", got)
}
}
func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
transcript := []byte(`{"secret":"source text"}`)
reference := "private roster note"
prepared := prepareSpellsPrompt(t, transcript, reference, " ")
metadata := New().ManifestMetadata()
payload, err := json.Marshal(map[string]any{
"prepared": map[string]any{
"prompt_id": prepared.PromptID,
"prompt_version": prepared.PromptVersion,
"prompt_hash": prepared.PromptHash,
"rendered_prompt_hash": prepared.RenderedPromptHash,
"selected_profile_id": prepared.SelectedProfileID,
"output_contract": prepared.OutputContract,
"input_hashes": prepared.InputHashes,
"effective_model_params": prepared.EffectiveModelParams,
},
"manifest": metadata,
})
if err != nil {
t.Fatalf("marshal diagnostics: %v", err)
}
diagnostics := string(payload)
for _, forbidden := range []string{
"source text",
reference,
"Extract Dungeons & Dragons spell-cast artifacts",
`"properties"`,
"spell_casts",
} {
if strings.Contains(diagnostics, forbidden) {
t.Fatalf("diagnostics leaked %q: %s", forbidden, diagnostics)
}
}
if metadata["prompt_id"] != PromptID || metadata["prompt_version"] != SchemaVersion {
t.Fatalf("manifest prompt metadata = %#v", metadata)
}
if !strings.HasPrefix(metadata["prompt_sha256"].(string), "sha256:") {
t.Fatalf("manifest prompt hash = %#v, want sha256-prefixed", metadata["prompt_sha256"])
}
}
func prepareSpellsPrompt(t *testing.T, transcript []byte, roster string, glossary string) *scriptorium.PreparedRun {
t.Helper()
registry := llm.NewAssetRegistry()
if err := promptassets.Register(registry); err != nil {
t.Fatalf("register shared prompt assets: %v", err)
}
if err := RegisterPromptAssets(registry); err != nil {
t.Fatalf("register spell prompt assets: %v", err)
}
options, err := registry.ScriptoriumOptions()
if err != nil {
t.Fatalf("ScriptoriumOptions() error = %v, want nil", err)
}
options = append(options, scriptorium.WithProfiles(scriptorium.OpenAICompatibleProfile(scriptorium.OpenAICompatibleProfileConfig{
ID: "spell-test-profile",
Endpoint: "http://127.0.0.1:1/v1",
Model: "spell-test-model",
})))
engine, err := scriptorium.NewEngine(scriptorium.Config{Timeout: time.Second}, options...)
if err != nil {
t.Fatalf("NewEngine() error = %v, want nil", err)
}
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: PromptID,
PromptVersion: SchemaVersion,
ProfileID: "spell-test-profile",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.InlineWithURI("file:///session.json", string(transcript)),
"roster": scriptorium.Inline(roster),
"glossary": scriptorium.Inline(glossary),
},
})
if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err)
}
return prepared
}