From 404bfc8da50880cb3813fa6831ed72c39ac16c62 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 3 Jul 2026 23:34:10 +0000 Subject: [PATCH] Add D&D spells prompt assets --- .../prompt/assets/dnd/spells/system.md | 9 + .../prompt/assets/dnd/spells/user.md | 21 +++ internal/framework/prompt/registry.go | 8 + internal/framework/prompt/registry_test.go | 57 ++++--- internal/framework/prompt/render_test.go | 44 +++++ internal/modules/extract/dnd/spells/prompt.go | 90 ++++++++++ .../modules/extract/dnd/spells/prompt_test.go | 159 ++++++++++++++++++ 7 files changed, 368 insertions(+), 20 deletions(-) create mode 100644 internal/framework/prompt/assets/dnd/spells/system.md create mode 100644 internal/framework/prompt/assets/dnd/spells/user.md create mode 100644 internal/modules/extract/dnd/spells/prompt.go create mode 100644 internal/modules/extract/dnd/spells/prompt_test.go diff --git a/internal/framework/prompt/assets/dnd/spells/system.md b/internal/framework/prompt/assets/dnd/spells/system.md new file mode 100644 index 0000000..78b5fe9 --- /dev/null +++ b/internal/framework/prompt/assets/dnd/spells/system.md @@ -0,0 +1,9 @@ +You extract D&D spell-cast artifacts from source units. + +{{ hardening }} + +Extract only spell casts that are supported by the provided source text. Do not +infer spells from general D&D knowledge or from table chatter that does not +identify a spell being cast. + +Source references must use the source-unit IDs exactly as provided. diff --git a/internal/framework/prompt/assets/dnd/spells/user.md b/internal/framework/prompt/assets/dnd/spells/user.md new file mode 100644 index 0000000..8cd1cf6 --- /dev/null +++ b/internal/framework/prompt/assets/dnd/spells/user.md @@ -0,0 +1,21 @@ +Source document ID: {{ .SourceID }} +{{ if .HasChunk }} +Chunk ID: {{ .ChunkID }} +Chunk index: {{ .ChunkIndex }} +{{ end }} + +Source units: +{{ range .Units }} +- Unit ID: {{ .ID }} + Text: {{ .Text }} +{{ if .Metadata }} + Metadata: +{{ range .Metadata }} + - {{ .Key }}: {{ .Value }} +{{ end }} +{{ end }} +{{ end }} + +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. diff --git a/internal/framework/prompt/registry.go b/internal/framework/prompt/registry.go index f9b8253..f67e03c 100644 --- a/internal/framework/prompt/registry.go +++ b/internal/framework/prompt/registry.go @@ -17,6 +17,7 @@ var embeddedAssets embed.FS const ( SourceBuiltin = "builtin" VersionV1 = "v1" + DNDSpellsPromptID = "dnd.spells" TestGenericPromptID = "test.generic" ) @@ -65,6 +66,13 @@ func init() { } defs := []definition{ + { + id: DNDSpellsPromptID, + version: VersionV1, + embeddedDir: "assets/dnd/spells", + systemPath: "assets/dnd/spells/system.md", + userPath: "assets/dnd/spells/user.md", + }, { id: TestGenericPromptID, version: VersionV1, diff --git a/internal/framework/prompt/registry_test.go b/internal/framework/prompt/registry_test.go index 90c1370..5da24c9 100644 --- a/internal/framework/prompt/registry_test.go +++ b/internal/framework/prompt/registry_test.go @@ -6,26 +6,38 @@ import ( "testing" ) -func TestLookupMetadataSucceedsForGenericPrompt(t *testing.T) { - metadata, ok := LookupMetadata(TestGenericPromptID) - if !ok { - t.Fatalf("expected metadata for %q", TestGenericPromptID) +func TestLookupMetadataSucceedsForRegisteredPrompts(t *testing.T) { + tests := []struct { + promptID string + embeddedPath string + }{ + {promptID: DNDSpellsPromptID, embeddedPath: "assets/dnd/spells"}, + {promptID: TestGenericPromptID, embeddedPath: "assets/test/generic"}, } - if metadata.PromptID != TestGenericPromptID { - t.Fatalf("unexpected prompt ID: %q", metadata.PromptID) - } - if metadata.PromptVersion != VersionV1 { - t.Fatalf("unexpected prompt version: %q", metadata.PromptVersion) - } - if metadata.PromptSource != SourceBuiltin { - t.Fatalf("unexpected prompt source: %q", metadata.PromptSource) - } - if metadata.EmbeddedPath != "assets/test/generic" { - t.Fatalf("unexpected embedded path: %q", metadata.EmbeddedPath) - } - if !strings.HasPrefix(metadata.SHA256, "sha256:") { - t.Fatalf("expected prefixed hash, got %q", metadata.SHA256) + for _, tc := range tests { + t.Run(tc.promptID, func(t *testing.T) { + metadata, ok := LookupMetadata(tc.promptID) + if !ok { + t.Fatalf("expected metadata for %q", tc.promptID) + } + + if metadata.PromptID != tc.promptID { + t.Fatalf("unexpected prompt ID: %q", metadata.PromptID) + } + if metadata.PromptVersion != VersionV1 { + t.Fatalf("unexpected prompt version: %q", metadata.PromptVersion) + } + if metadata.PromptSource != SourceBuiltin { + t.Fatalf("unexpected prompt source: %q", metadata.PromptSource) + } + if metadata.EmbeddedPath != tc.embeddedPath { + t.Fatalf("unexpected embedded path: %q", metadata.EmbeddedPath) + } + if !strings.HasPrefix(metadata.SHA256, "sha256:") { + t.Fatalf("expected prefixed hash, got %q", metadata.SHA256) + } + }) } } @@ -47,17 +59,22 @@ func TestMustLookupMetadataPanicsForUnknownPromptID(t *testing.T) { func TestRegisteredMetadataSortedByPromptID(t *testing.T) { registered := RegisteredMetadata() - if len(registered) != 1 { - t.Fatalf("expected one registered prompt, got %d", len(registered)) + if len(registered) != 2 { + t.Fatalf("expected two registered prompts, got %d", len(registered)) } ids := make([]string, len(registered)) + seen := make(map[string]bool, len(registered)) for i, metadata := range registered { ids[i] = metadata.PromptID + seen[metadata.PromptID] = true } if !sort.StringsAreSorted(ids) { t.Fatalf("expected sorted prompt IDs, got %v", ids) } + if !seen[DNDSpellsPromptID] { + t.Fatalf("registered prompt IDs = %v, want %q", ids, DNDSpellsPromptID) + } } func TestHardeningTextAvailable(t *testing.T) { diff --git a/internal/framework/prompt/render_test.go b/internal/framework/prompt/render_test.go index cda4deb..af6bebb 100644 --- a/internal/framework/prompt/render_test.go +++ b/internal/framework/prompt/render_test.go @@ -64,3 +64,47 @@ func TestRenderUserSystemIncludesHardeningText(t *testing.T) { t.Fatalf("expected rendered system prompt to include hardening text: %q", system) } } + +func TestRenderDNDSpellsPromptIncludesHardeningText(t *testing.T) { + system, user, metadata, err := RenderUserSystem(DNDSpellsPromptID, map[string]any{ + "SourceID": "session-alpha", + "HasChunk": true, + "ChunkID": "session-alpha:chunk:0", + "ChunkIndex": 0, + "Units": []map[string]any{ + { + "ID": "seg-001", + "Text": "Aria casts Cure Wounds.", + "Metadata": []map[string]string{ + {"Key": "speaker", "Value": "Alice"}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("RenderUserSystem: %v", err) + } + + hardening := strings.TrimSpace(HardeningText()) + if hardening == "" { + t.Fatalf("expected hardening text") + } + if !strings.Contains(system, hardening) { + t.Fatalf("expected rendered system prompt to include hardening text: %q", system) + } + for _, want := range []string{"session-alpha", "session-alpha:chunk:0", "seg-001", "Aria casts Cure Wounds.", "speaker: Alice"} { + if !strings.Contains(user, want) { + t.Fatalf("rendered user prompt = %q, want substring %q", user, want) + } + } + if metadata.PromptID != DNDSpellsPromptID { + t.Fatalf("unexpected metadata: %+v", metadata) + } +} + +func TestRenderDNDSpellsPromptMissingTemplateDataReturnsError(t *testing.T) { + _, _, _, err := RenderUserSystem(DNDSpellsPromptID, map[string]any{}) + if err == nil || !strings.Contains(err.Error(), "SourceID") { + t.Fatalf("expected missing SourceID error, got %v", err) + } +} diff --git a/internal/modules/extract/dnd/spells/prompt.go b/internal/modules/extract/dnd/spells/prompt.go new file mode 100644 index 0000000..ee30419 --- /dev/null +++ b/internal/modules/extract/dnd/spells/prompt.go @@ -0,0 +1,90 @@ +package spells + +import ( + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/prompt" +) + +type promptData struct { + SourceID string + HasChunk bool + ChunkID string + ChunkIndex int + Units []promptUnit +} + +type promptUnit struct { + ID string + Text string + Metadata []promptMetadata +} + +type promptMetadata struct { + Key string + Value string +} + +func buildPromptData(req contracts.ExtractionRequest) (promptData, error) { + if req.Source == nil { + return promptData{}, fmt.Errorf("dnd spells prompt: source must not be nil") + } + if req.Chunk == nil { + return promptData{}, fmt.Errorf("dnd spells prompt: chunk must not be nil") + } + + data := promptData{ + SourceID: req.Source.ID, + HasChunk: true, + ChunkID: req.Chunk.ID, + ChunkIndex: req.Chunk.Index, + Units: make([]promptUnit, 0, len(req.Chunk.Units)), + } + for _, unit := range req.Chunk.Units { + data.Units = append(data.Units, promptUnit{ + ID: unit.ID, + Text: unit.Text, + Metadata: selectedMetadata(unit), + }) + } + return data, nil +} + +func renderPrompt(req contracts.ExtractionRequest) (system string, user string, metadata prompt.Metadata, err error) { + data, err := buildPromptData(req) + if err != nil { + return "", "", prompt.Metadata{}, err + } + system, user, metadata, err = prompt.RenderUserSystem(prompt.DNDSpellsPromptID, data) + if err != nil { + return "", "", prompt.Metadata{}, fmt.Errorf("dnd spells prompt: %w", err) + } + return system, user, metadata, nil +} + +func selectedMetadata(unit source.SourceUnit) []promptMetadata { + if len(unit.Metadata) == 0 { + return nil + } + + keys := []string{"speaker", "start", "end"} + metadata := make([]promptMetadata, 0, len(keys)) + for _, key := range keys { + value, ok := unit.Metadata[key] + if !ok { + continue + } + rendered := strings.TrimSpace(fmt.Sprint(value)) + if rendered == "" { + continue + } + metadata = append(metadata, promptMetadata{ + Key: key, + Value: rendered, + }) + } + return metadata +} diff --git a/internal/modules/extract/dnd/spells/prompt_test.go b/internal/modules/extract/dnd/spells/prompt_test.go new file mode 100644 index 0000000..fd91d77 --- /dev/null +++ b/internal/modules/extract/dnd/spells/prompt_test.go @@ -0,0 +1,159 @@ +package spells + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/prompt" +) + +func TestBuildPromptDataFromGenericSourceChunk(t *testing.T) { + req := promptExtractionRequest() + + data, err := buildPromptData(req) + if err != nil { + t.Fatalf("buildPromptData() error = %v, want nil", err) + } + + if data.SourceID != "session-alpha" { + t.Fatalf("SourceID = %q, want session-alpha", data.SourceID) + } + if !data.HasChunk || data.ChunkID != "session-alpha:chunk:0" || data.ChunkIndex != 0 { + t.Fatalf("chunk data = %#v, want fixture chunk", data) + } + if len(data.Units) != 2 { + t.Fatalf("len(Units) = %d, want 2", len(data.Units)) + } + first := data.Units[0] + if first.ID != "seg-001" || first.Text != "Aria raises her hand and casts Cure Wounds." { + t.Fatalf("first unit = %#v, want source unit data", first) + } + wantMetadata := []promptMetadata{ + {Key: "speaker", Value: "Alice"}, + {Key: "start", Value: "1.25"}, + {Key: "end", Value: "3.5"}, + } + if !reflect.DeepEqual(first.Metadata, wantMetadata) { + t.Fatalf("first.Metadata = %#v, want %#v", first.Metadata, wantMetadata) + } + if len(data.Units[1].Metadata) != 0 { + t.Fatalf("second.Metadata = %#v, want no selected metadata", data.Units[1].Metadata) + } +} + +func TestBuildPromptDataDoesNotMutateRequest(t *testing.T) { + req := promptExtractionRequest() + beforeSource := mustJSON(t, req.Source) + beforeChunk := mustJSON(t, req.Chunk) + beforeRequest := mustJSON(t, req) + + if _, err := buildPromptData(req); err != nil { + t.Fatalf("buildPromptData() error = %v, want nil", err) + } + afterSource := mustJSON(t, req.Source) + afterChunk := mustJSON(t, req.Chunk) + afterRequest := mustJSON(t, req) + if beforeSource != afterSource || beforeChunk != afterChunk || beforeRequest != afterRequest { + t.Fatalf( + "request mutated:\nsource before: %s\nsource after: %s\nchunk before: %s\nchunk after: %s\nrequest before: %s\nrequest after: %s", + beforeSource, + afterSource, + beforeChunk, + afterChunk, + beforeRequest, + afterRequest, + ) + } +} + +func TestRenderPromptIncludesSourceContext(t *testing.T) { + system, user, metadata, err := renderPrompt(promptExtractionRequest()) + if err != nil { + t.Fatalf("renderPrompt() error = %v, want nil", err) + } + + if !strings.Contains(system, prompt.HardeningText()) { + t.Fatalf("system prompt = %q, want hardening text", system) + } + for _, want := range []string{ + "session-alpha", + "session-alpha:chunk:0", + "seg-001", + "Aria raises her hand and casts Cure Wounds.", + "speaker: Alice", + "start: 1.25", + "end: 3.5", + } { + if !strings.Contains(user, want) { + t.Fatalf("user prompt = %q, want substring %q", user, want) + } + } + if metadata.PromptID != prompt.DNDSpellsPromptID { + t.Fatalf("metadata.PromptID = %q, want %q", metadata.PromptID, prompt.DNDSpellsPromptID) + } +} + +func TestBuildPromptDataRejectsMissingSourceContext(t *testing.T) { + if _, err := buildPromptData(contracts.ExtractionRequest{}); err == nil || !strings.Contains(err.Error(), "source") { + t.Fatalf("buildPromptData() error = %v, want source error", err) + } + if _, err := buildPromptData(contracts.ExtractionRequest{Source: promptSourceDocument()}); err == nil || !strings.Contains(err.Error(), "chunk") { + t.Fatalf("buildPromptData() error = %v, want chunk error", err) + } +} + +func promptExtractionRequest() contracts.ExtractionRequest { + doc := promptSourceDocument() + chunk := &contracts.SourceChunk{ + ID: "session-alpha:chunk:0", + SourceID: doc.ID, + Index: 0, + Units: append([]source.SourceUnit(nil), doc.Units...), + Metadata: map[string]any{"ignored": "chunk metadata"}, + } + return contracts.ExtractionRequest{ + Source: doc, + Chunk: chunk, + } +} + +func promptSourceDocument() *source.SourceDocument { + return &source.SourceDocument{ + ID: "session-alpha", + Kind: "transcript", + Format: "application/vnd.seriatim.minimal+json", + Digest: "sha256:test", + Units: []source.SourceUnit{ + { + ID: "seg-001", + Kind: "transcript_segment", + Text: "Aria raises her hand and casts Cure Wounds.", + Metadata: map[string]any{ + "speaker": "Alice", + "start": json.Number("1.25"), + "end": json.Number("3.5"), + "ignored": "not rendered", + }, + }, + { + ID: "seg-002", + Kind: "transcript_segment", + Text: "The fighter's wounds begin to close.", + Metadata: map[string]any{"ignored": "not rendered"}, + }, + }, + } +} + +func mustJSON(t *testing.T, value any) string { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("Marshal() error = %v, want nil", err) + } + return string(encoded) +}