Add Scriptorium prompt assets
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
84
internal/modules/extract/dnd/spells/scriptorium_assets.go
Normal file
84
internal/modules/extract/dnd/spells/scriptorium_assets.go
Normal 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
|
||||
)
|
||||
180
internal/modules/extract/dnd/spells/scriptorium_assets_test.go
Normal file
180
internal/modules/extract/dnd/spells/scriptorium_assets_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user