Add D&D spells prompt assets
This commit is contained in:
9
internal/framework/prompt/assets/dnd/spells/system.md
Normal file
9
internal/framework/prompt/assets/dnd/spells/system.md
Normal file
@@ -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.
|
||||
21
internal/framework/prompt/assets/dnd/spells/user.md
Normal file
21
internal/framework/prompt/assets/dnd/spells/user.md
Normal file
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
90
internal/modules/extract/dnd/spells/prompt.go
Normal file
90
internal/modules/extract/dnd/spells/prompt.go
Normal file
@@ -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
|
||||
}
|
||||
159
internal/modules/extract/dnd/spells/prompt_test.go
Normal file
159
internal/modules/extract/dnd/spells/prompt_test.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user