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 scenes
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/scenes/*.md
var embeddedAssets embed.FS

View File

@@ -0,0 +1,22 @@
id: dnd.scenes
version: "v1"
inputs:
- name: transcript
required: true
content_type: application/json
messages:
- role: system
content_file: ./shared/system.md
- role: user
content_file: ./shared/transcript.md
cache_control:
type: ephemeral
- role: user
content_file: ./dnd/scenes/task.md
- role: user
content_file: ./dnd/scenes/instructions.md
output:
format: json
validation_mode: json_schema
schema_path: dnd_scenes.v1.json
repair_attempts: 0

View File

@@ -0,0 +1,52 @@
Good reasons to start a new scene include:
- the party moves to a new location;
- a combat encounter begins or ends;
- combat changes into a substantially different phase;
- the party shifts between combat, exploration, social interaction, discussion,
planning, travel, rest, or downtime;
- a new NPC, faction, threat, or objective becomes central;
- the party completes one immediate goal and begins another;
- a major table-level rules discussion interrupts and materially changes play.
Do not start a new scene merely because:
- the speaker changes;
- a new combat round begins;
- a player asks a brief rules question;
- there is a joke, aside, or short table comment;
- a character takes a routine turn;
- the same encounter continues without a meaningful change in situation.
dnd/scenes boundary policy:
- cover the full provided transcript from the first source unit to the last
source unit;
- return sequential scenes with no gaps;
- do not overlap scenes;
- preserve source-unit order;
- use exact source-unit IDs from the transcript;
- each scene must have start_unit_id and end_unit_id;
- do not include final chunk IDs or chunk indexes.
For each scene:
- short_title should be brief and factual;
- primary_mode must be Recap, Discussion, Combat, or Narrative;
- main_participants should include only principal characters, NPCs, factions, or
groups involved;
- summary should be factual and compact, usually one to three sentences;
- boundary_note should explain why the scene begins at start_unit_id and ends at
end_unit_id;
- boundary_confidence must be High, Medium, or Low.
Primary mode guidance:
- Use Recap for opening recap, initiative setup, session framing, or immediate
continuation from prior events.
- Use Discussion when the party is primarily discussing options or choosing a
course of action.
- Use Combat when active combat or combat-resolution mechanics dominate.
- Use Narrative for all other non-combat gameplay, including exploration, social
interactions, shopping, preparation, travel, rest, and downtime.
In boundary_caveats, list overall caveats about scene divisions. Include scenes
that could reasonably be split differently, combat phases that were kept
together, gradual transitions, or places where map context would have helped.
Return exactly one JSON object and no explanatory text.

View File

@@ -0,0 +1,5 @@
Divide the provided transcript into coherent Dungeons & Dragons scenes for the
dnd/scenes chunk module.
A scene is a coherent unit of play. Start a new scene when there is a meaningful
change in location, objective, threat, activity, encounter, or mode of play.

View File

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

View File

@@ -0,0 +1,35 @@
package scenes
import (
"sync"
"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.scenes.yaml"},
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/scenes/task.md"},
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/scenes/instructions.md"},
}, promptassets.CommonHashParts()...)
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
})
return scriptoriumPromptHash, scriptoriumPromptHashErr
}
var (
scriptoriumPromptHashOnce sync.Once
scriptoriumPromptHash string
scriptoriumPromptHashErr error
)

View File

@@ -0,0 +1,120 @@
package scenes
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/promptassets"
"gitea.maximumdirect.net/eric/scriptorium"
)
func TestScriptoriumPromptPreparesTranscriptAndTaskMessages(t *testing.T) {
transcript := []byte(`{"id":"session-1","segments":[{"id":"u1","text":"We enter the crypt."}]}`)
prepared := prepareScenesPrompt(t, transcript)
if prepared.PromptID != PromptID {
t.Fatalf("prompt id = %q, want %q", prepared.PromptID, PromptID)
}
if got := len(prepared.Messages); got != 4 {
t.Fatalf("message count = %d, want 4", got)
}
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[2].Content, "Divide the provided transcript") {
t.Fatalf("task message missing scene task text: %q", prepared.Messages[2].Content)
}
if strings.Contains(prepared.Messages[2].Content, string(transcript)) {
t.Fatalf("task message leaked transcript bytes")
}
}
func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
transcript := []byte(`{"secret":"source text"}`)
prepared := prepareScenesPrompt(t, transcript)
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",
"Divide the provided transcript",
`"properties"`,
"start_unit_id",
} {
if strings.Contains(diagnostics, forbidden) {
t.Fatalf("diagnostics leaked %q: %s", forbidden, diagnostics)
}
}
if metadata["prompt_id"] != PromptID || metadata["prompt_version"] != ResponseSchemaVersion {
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 prepareScenesPrompt(t *testing.T, transcript []byte) *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 scene prompt assets: %v", err)
}
engine := newScenesScriptoriumEngine(t, registry)
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: PromptID,
PromptVersion: ResponseSchemaVersion,
ProfileID: "scene-test-profile",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.InlineWithURI("file:///session.json", string(transcript)),
},
})
if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err)
}
return prepared
}
func newScenesScriptoriumEngine(t *testing.T, registry *llm.AssetRegistry) *scriptorium.Engine {
t.Helper()
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: "scene-test-profile",
Endpoint: "http://127.0.0.1:1/v1",
Model: "scene-test-model",
})))
engine, err := scriptorium.NewEngine(scriptorium.Config{Timeout: time.Second}, options...)
if err != nil {
t.Fatalf("NewEngine() error = %v, want nil", err)
}
return engine
}