Move D&D spell assets into extractor module

This commit is contained in:
2026-07-04 00:39:31 +00:00
parent 07b3264b6b
commit ac14667797
16 changed files with 227 additions and 168 deletions

View File

@@ -6,6 +6,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs"
"sort" "sort"
"strings" "strings"
) )
@@ -17,13 +18,21 @@ var schemaAssets embed.FS
type ResponseSchemaKey string type ResponseSchemaKey string
const ( const (
DNDSpellsSchemaKey ResponseSchemaKey = "dnd_spells"
TestArtifactSchemaKey ResponseSchemaKey = "test_artifact" TestArtifactSchemaKey ResponseSchemaKey = "test_artifact"
TestValidatorDecisionSchemaKey ResponseSchemaKey = "test_validator_decision" TestValidatorDecisionSchemaKey ResponseSchemaKey = "test_validator_decision"
schemaVersionV1 = "v1" schemaVersionV1 = "v1"
) )
// ResponseSchemaDefinition identifies a caller-owned structured response schema asset.
type ResponseSchemaDefinition struct {
Key ResponseSchemaKey
ID string
Version string
Name string
AssetPath string
}
// ResponseSchema describes one registered structured response schema. // ResponseSchema describes one registered structured response schema.
type ResponseSchema struct { type ResponseSchema struct {
Key ResponseSchemaKey `json:"key"` Key ResponseSchemaKey `json:"key"`
@@ -35,27 +44,20 @@ type ResponseSchema struct {
} }
var responseSchemaRegistry = map[ResponseSchemaKey]ResponseSchema{ var responseSchemaRegistry = map[ResponseSchemaKey]ResponseSchema{
DNDSpellsSchemaKey: mustLoadResponseSchema( TestArtifactSchemaKey: mustLoadResponseSchema(schemaAssets, ResponseSchemaDefinition{
DNDSpellsSchemaKey, Key: TestArtifactSchemaKey,
"notarius.dnd.spells", ID: "notarius.test_artifact",
schemaVersionV1, Version: schemaVersionV1,
"notarius_dnd_spells_v1", Name: "notarius_test_artifact_v1",
"assets/schemas/dnd_spells.v1.json", AssetPath: "assets/schemas/test_artifact.v1.json",
), }),
TestArtifactSchemaKey: mustLoadResponseSchema( TestValidatorDecisionSchemaKey: mustLoadResponseSchema(schemaAssets, ResponseSchemaDefinition{
TestArtifactSchemaKey, Key: TestValidatorDecisionSchemaKey,
"notarius.test_artifact", ID: "notarius.test_validator_decision",
schemaVersionV1, Version: schemaVersionV1,
"notarius_test_artifact_v1", Name: "notarius_test_validator_decision_v1",
"assets/schemas/test_artifact.v1.json", AssetPath: "assets/schemas/test_validator_decision.v1.json",
), }),
TestValidatorDecisionSchemaKey: mustLoadResponseSchema(
TestValidatorDecisionSchemaKey,
"notarius.test_validator_decision",
schemaVersionV1,
"notarius_test_validator_decision_v1",
"assets/schemas/test_validator_decision.v1.json",
),
} }
// RegisteredResponseSchemas returns all registered response schemas sorted by key. // RegisteredResponseSchemas returns all registered response schemas sorted by key.
@@ -102,40 +104,35 @@ func (s ResponseSchema) DiagnosticsMap() map[string]any {
} }
} }
func mustLoadResponseSchema( // LoadResponseSchema loads a structured response schema from a caller-owned filesystem.
key ResponseSchemaKey, func LoadResponseSchema(fsys fs.FS, def ResponseSchemaDefinition) (ResponseSchema, error) {
id string, key := ResponseSchemaKey(strings.TrimSpace(string(def.Key)))
version string, id := strings.TrimSpace(def.ID)
name string, version := strings.TrimSpace(def.Version)
path string, name := strings.TrimSpace(def.Name)
) ResponseSchema { path := strings.TrimSpace(def.AssetPath)
key = ResponseSchemaKey(strings.TrimSpace(string(key)))
id = strings.TrimSpace(id)
version = strings.TrimSpace(version)
name = strings.TrimSpace(name)
path = strings.TrimSpace(path)
if key == "" { if key == "" {
panic("response schema key must not be empty") return ResponseSchema{}, fmt.Errorf("response schema key must not be empty")
} }
if id == "" { if id == "" {
panic("response schema id must not be empty") return ResponseSchema{}, fmt.Errorf("response schema id must not be empty")
} }
if version == "" { if version == "" {
panic("response schema version must not be empty") return ResponseSchema{}, fmt.Errorf("response schema version must not be empty")
} }
if name == "" { if name == "" {
panic("response schema name must not be empty") return ResponseSchema{}, fmt.Errorf("response schema name must not be empty")
} }
if path == "" { if path == "" {
panic("response schema asset path must not be empty") return ResponseSchema{}, fmt.Errorf("response schema asset path must not be empty")
} }
rawSchema, err := schemaAssets.ReadFile(path) rawSchema, err := fs.ReadFile(fsys, path)
if err != nil { if err != nil {
panic(fmt.Sprintf("read response schema %s: %v", path, err)) return ResponseSchema{}, fmt.Errorf("read response schema %s: %w", path, err)
} }
if !json.Valid(rawSchema) { if !json.Valid(rawSchema) {
panic(fmt.Sprintf("response schema %s is not valid JSON", path)) return ResponseSchema{}, fmt.Errorf("response schema %s is not valid JSON", path)
} }
hash := sha256.Sum256(rawSchema) hash := sha256.Sum256(rawSchema)
@@ -146,7 +143,15 @@ func mustLoadResponseSchema(
Name: name, Name: name,
JSONSchema: append(json.RawMessage(nil), rawSchema...), JSONSchema: append(json.RawMessage(nil), rawSchema...),
SHA256: "sha256:" + hex.EncodeToString(hash[:]), SHA256: "sha256:" + hex.EncodeToString(hash[:]),
}, nil
}
func mustLoadResponseSchema(fsys fs.FS, def ResponseSchemaDefinition) ResponseSchema {
schema, err := LoadResponseSchema(fsys, def)
if err != nil {
panic(err)
} }
return schema
} }
func cloneResponseSchema(in ResponseSchema) ResponseSchema { func cloneResponseSchema(in ResponseSchema) ResponseSchema {

View File

@@ -9,7 +9,6 @@ import (
func TestLookupResponseSchemaSucceedsForRegisteredSchemas(t *testing.T) { func TestLookupResponseSchemaSucceedsForRegisteredSchemas(t *testing.T) {
tests := []ResponseSchemaKey{ tests := []ResponseSchemaKey{
DNDSpellsSchemaKey,
TestArtifactSchemaKey, TestArtifactSchemaKey,
TestValidatorDecisionSchemaKey, TestValidatorDecisionSchemaKey,
} }
@@ -39,6 +38,12 @@ func TestLookupResponseSchemaUnknownReturnsFalse(t *testing.T) {
} }
} }
func TestLookupResponseSchemaDNDSpellsIsNotFrameworkRegistered(t *testing.T) {
if schema, ok := LookupResponseSchema("dnd_spells"); ok {
t.Fatalf("expected D&D spells schema lookup to fail in framework registry, got %+v", schema)
}
}
func TestMustLookupResponseSchemaPanicsForUnknownKey(t *testing.T) { func TestMustLookupResponseSchemaPanicsForUnknownKey(t *testing.T) {
defer func() { defer func() {
if recover() == nil { if recover() == nil {
@@ -51,8 +56,8 @@ func TestMustLookupResponseSchemaPanicsForUnknownKey(t *testing.T) {
func TestRegisteredResponseSchemasSortedByKey(t *testing.T) { func TestRegisteredResponseSchemasSortedByKey(t *testing.T) {
schemas := RegisteredResponseSchemas() schemas := RegisteredResponseSchemas()
if len(schemas) != 3 { if len(schemas) != 2 {
t.Fatalf("expected three schemas, got %d", len(schemas)) t.Fatalf("expected two schemas, got %d", len(schemas))
} }
keys := make([]string, len(schemas)) keys := make([]string, len(schemas))
@@ -64,8 +69,8 @@ func TestRegisteredResponseSchemasSortedByKey(t *testing.T) {
if !sort.StringsAreSorted(keys) { if !sort.StringsAreSorted(keys) {
t.Fatalf("expected sorted keys, got %v", keys) t.Fatalf("expected sorted keys, got %v", keys)
} }
if !seen[DNDSpellsSchemaKey] { if !seen[TestArtifactSchemaKey] || !seen[TestValidatorDecisionSchemaKey] {
t.Fatalf("registered schemas = %v, want %q", keys, DNDSpellsSchemaKey) t.Fatalf("registered schemas = %v, want test schemas", keys)
} }
} }
@@ -78,7 +83,7 @@ func TestResponseSchemaContentIsValidJSON(t *testing.T) {
} }
func TestResponseSchemaJSONIsMutationSafe(t *testing.T) { func TestResponseSchemaJSONIsMutationSafe(t *testing.T) {
for _, key := range []ResponseSchemaKey{DNDSpellsSchemaKey, TestArtifactSchemaKey} { for _, key := range []ResponseSchemaKey{TestArtifactSchemaKey, TestValidatorDecisionSchemaKey} {
t.Run(string(key), func(t *testing.T) { t.Run(string(key), func(t *testing.T) {
first := MustLookupResponseSchema(key) first := MustLookupResponseSchema(key)
first.JSONSchema[0] = '[' first.JSONSchema[0] = '['

View File

@@ -5,6 +5,7 @@ import (
"embed" "embed"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"io/fs"
"path" "path"
"sort" "sort"
"strings" "strings"
@@ -17,7 +18,6 @@ var embeddedAssets embed.FS
const ( const (
SourceBuiltin = "builtin" SourceBuiltin = "builtin"
VersionV1 = "v1" VersionV1 = "v1"
DNDSpellsPromptID = "dnd.spells"
TestGenericPromptID = "test.generic" TestGenericPromptID = "test.generic"
) )
@@ -41,21 +41,23 @@ func (m Metadata) DiagnosticsMap() map[string]any {
} }
} }
type definition struct { // Definition identifies a caller-owned system/user prompt bundle.
id string type Definition struct {
version string PromptID string
embeddedDir string Version string
systemPath string EmbeddedPath string
userPath string SystemPath string
UserPath string
} }
type compiledPrompt struct { // Bundle is a compiled system/user prompt pair.
type Bundle struct {
systemTmpl *template.Template systemTmpl *template.Template
userTmpl *template.Template userTmpl *template.Template
metadata Metadata metadata Metadata
} }
var promptRegistry map[string]compiledPrompt var promptRegistry map[string]*Bundle
var sharedHardening string var sharedHardening string
func init() { func init() {
@@ -65,30 +67,23 @@ func init() {
panic(err) panic(err)
} }
defs := []definition{ defs := []Definition{
{ {
id: DNDSpellsPromptID, PromptID: TestGenericPromptID,
version: VersionV1, Version: VersionV1,
embeddedDir: "assets/dnd/spells", EmbeddedPath: "assets/test/generic",
systemPath: "assets/dnd/spells/system.md", SystemPath: "assets/test/generic/system.md",
userPath: "assets/dnd/spells/user.md", UserPath: "assets/test/generic/user.md",
},
{
id: TestGenericPromptID,
version: VersionV1,
embeddedDir: "assets/test/generic",
systemPath: "assets/test/generic/system.md",
userPath: "assets/test/generic/user.md",
}, },
} }
promptRegistry = make(map[string]compiledPrompt, len(defs)) promptRegistry = make(map[string]*Bundle, len(defs))
for _, def := range defs { for _, def := range defs {
compiled, compileErr := compilePrompt(def) compiled, compileErr := LoadBundle(embeddedAssets, def)
if compileErr != nil { if compileErr != nil {
panic(compileErr) panic(compileErr)
} }
promptRegistry[def.id] = compiled promptRegistry[compiled.metadata.PromptID] = compiled
} }
} }
@@ -138,51 +133,68 @@ func readAsset(assetPath string) (string, error) {
return string(content), nil return string(content), nil
} }
func compilePrompt(def definition) (compiledPrompt, error) { // LoadBundle compiles a system/user prompt bundle from a caller-owned filesystem.
if strings.TrimSpace(def.id) == "" { func LoadBundle(fsys fs.FS, def Definition) (*Bundle, error) {
return compiledPrompt{}, fmt.Errorf("prompt id must not be empty") promptID := strings.TrimSpace(def.PromptID)
version := strings.TrimSpace(def.Version)
embeddedPath := strings.TrimSpace(def.EmbeddedPath)
systemPath := strings.TrimSpace(def.SystemPath)
userPath := strings.TrimSpace(def.UserPath)
if promptID == "" {
return nil, fmt.Errorf("prompt id must not be empty")
} }
if strings.TrimSpace(def.version) == "" { if version == "" {
return compiledPrompt{}, fmt.Errorf("prompt version must not be empty") return nil, fmt.Errorf("prompt version must not be empty")
} }
if strings.TrimSpace(def.embeddedDir) == "" { if embeddedPath == "" {
return compiledPrompt{}, fmt.Errorf("prompt embedded path must not be empty") return nil, fmt.Errorf("prompt embedded path must not be empty")
} }
systemSource, err := readAsset(def.systemPath) systemSource, err := readPromptAsset(fsys, systemPath)
if err != nil { if err != nil {
return compiledPrompt{}, err return nil, err
} }
userSource, err := readAsset(def.userPath) userSource, err := readPromptAsset(fsys, userPath)
if err != nil { if err != nil {
return compiledPrompt{}, err return nil, err
} }
funcs := template.FuncMap{ funcs := template.FuncMap{
"hardening": func() string { return sharedHardening }, "hardening": func() string { return sharedHardening },
} }
systemTmpl, err := template.New(path.Base(def.systemPath)).Option("missingkey=error").Funcs(funcs).Parse(systemSource) systemTmpl, err := template.New(path.Base(systemPath)).Option("missingkey=error").Funcs(funcs).Parse(systemSource)
if err != nil { if err != nil {
return compiledPrompt{}, fmt.Errorf("parse embedded system prompt %q: %w", def.systemPath, err) return nil, fmt.Errorf("parse embedded system prompt %q: %w", systemPath, err)
} }
userTmpl, err := template.New(path.Base(def.userPath)).Option("missingkey=error").Funcs(funcs).Parse(userSource) userTmpl, err := template.New(path.Base(userPath)).Option("missingkey=error").Funcs(funcs).Parse(userSource)
if err != nil { if err != nil {
return compiledPrompt{}, fmt.Errorf("parse embedded user prompt %q: %w", def.userPath, err) return nil, fmt.Errorf("parse embedded user prompt %q: %w", userPath, err)
} }
hashInput := systemSource + "\n\n" + userSource hashInput := systemSource + "\n\n" + userSource
hash := sha256.Sum256([]byte(hashInput)) hash := sha256.Sum256([]byte(hashInput))
metadata := Metadata{ metadata := Metadata{
PromptID: strings.TrimSpace(def.id), PromptID: promptID,
PromptVersion: strings.TrimSpace(def.version), PromptVersion: version,
PromptSource: SourceBuiltin, PromptSource: SourceBuiltin,
EmbeddedPath: strings.TrimSpace(def.embeddedDir), EmbeddedPath: embeddedPath,
SHA256: "sha256:" + hex.EncodeToString(hash[:]), SHA256: "sha256:" + hex.EncodeToString(hash[:]),
} }
return compiledPrompt{ return &Bundle{
systemTmpl: systemTmpl, systemTmpl: systemTmpl,
userTmpl: userTmpl, userTmpl: userTmpl,
metadata: metadata, metadata: metadata,
}, nil }, nil
} }
func readPromptAsset(fsys fs.FS, assetPath string) (string, error) {
if strings.TrimSpace(assetPath) == "" {
return "", fmt.Errorf("prompt asset path must not be empty")
}
content, err := fs.ReadFile(fsys, assetPath)
if err != nil {
return "", fmt.Errorf("read embedded prompt asset %q: %w", assetPath, err)
}
return string(content), nil
}

View File

@@ -11,7 +11,6 @@ func TestLookupMetadataSucceedsForRegisteredPrompts(t *testing.T) {
promptID string promptID string
embeddedPath string embeddedPath string
}{ }{
{promptID: DNDSpellsPromptID, embeddedPath: "assets/dnd/spells"},
{promptID: TestGenericPromptID, embeddedPath: "assets/test/generic"}, {promptID: TestGenericPromptID, embeddedPath: "assets/test/generic"},
} }
@@ -59,8 +58,8 @@ func TestMustLookupMetadataPanicsForUnknownPromptID(t *testing.T) {
func TestRegisteredMetadataSortedByPromptID(t *testing.T) { func TestRegisteredMetadataSortedByPromptID(t *testing.T) {
registered := RegisteredMetadata() registered := RegisteredMetadata()
if len(registered) != 2 { if len(registered) != 1 {
t.Fatalf("expected two registered prompts, got %d", len(registered)) t.Fatalf("expected one registered prompt, got %d", len(registered))
} }
ids := make([]string, len(registered)) ids := make([]string, len(registered))
@@ -72,8 +71,8 @@ func TestRegisteredMetadataSortedByPromptID(t *testing.T) {
if !sort.StringsAreSorted(ids) { if !sort.StringsAreSorted(ids) {
t.Fatalf("expected sorted prompt IDs, got %v", ids) t.Fatalf("expected sorted prompt IDs, got %v", ids)
} }
if !seen[DNDSpellsPromptID] { if !seen[TestGenericPromptID] {
t.Fatalf("registered prompt IDs = %v, want %q", ids, DNDSpellsPromptID) t.Fatalf("registered prompt IDs = %v, want %q", ids, TestGenericPromptID)
} }
} }

View File

@@ -13,16 +13,23 @@ func RenderUserSystem(promptID string, data any) (system string, user string, me
if !ok { if !ok {
return "", "", Metadata{}, fmt.Errorf("unknown prompt id %q", promptID) return "", "", Metadata{}, fmt.Errorf("unknown prompt id %q", promptID)
} }
return compiled.RenderUserSystem(data)
}
// RenderUserSystem renders the bundle's system and user prompts.
func (b *Bundle) RenderUserSystem(data any) (system string, user string, metadata Metadata, err error) {
if b == nil {
return "", "", Metadata{}, fmt.Errorf("prompt bundle must not be nil")
}
var systemBuf bytes.Buffer var systemBuf bytes.Buffer
if err := compiled.systemTmpl.Execute(&systemBuf, data); err != nil { if err := b.systemTmpl.Execute(&systemBuf, data); err != nil {
return "", "", Metadata{}, fmt.Errorf("render system prompt %q: %w", trimmedID, err) return "", "", Metadata{}, fmt.Errorf("render system prompt %q: %w", b.metadata.PromptID, err)
} }
var userBuf bytes.Buffer var userBuf bytes.Buffer
if err := compiled.userTmpl.Execute(&userBuf, data); err != nil { if err := b.userTmpl.Execute(&userBuf, data); err != nil {
return "", "", Metadata{}, fmt.Errorf("render user prompt %q: %w", trimmedID, err) return "", "", Metadata{}, fmt.Errorf("render user prompt %q: %w", b.metadata.PromptID, err)
} }
return strings.TrimSpace(systemBuf.String()), strings.TrimSpace(userBuf.String()), compiled.metadata, nil return strings.TrimSpace(systemBuf.String()), strings.TrimSpace(userBuf.String()), b.metadata, nil
} }

View File

@@ -64,47 +64,3 @@ func TestRenderUserSystemIncludesHardeningText(t *testing.T) {
t.Fatalf("expected rendered system prompt to include hardening text: %q", system) 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)
}
}

View File

@@ -0,0 +1,6 @@
package spells
import "embed"
//go:embed assets/prompts/*.md assets/schemas/*.json
var embeddedAssets embed.FS

View File

@@ -9,7 +9,6 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
) )
@@ -80,9 +79,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.ExtractionRequest
if err != nil { if err != nil {
return contracts.ExtractionResult{}, extractorErrorf("render prompt: %w", err) return contracts.ExtractionResult{}, extractorErrorf("render prompt: %w", err)
} }
schema, ok := llm.LookupResponseSchema(llm.DNDSpellsSchemaKey) schema, err := loadResponseSchema()
if !ok { if err != nil {
return contracts.ExtractionResult{}, extractorErrorf("lookup response schema %q", llm.DNDSpellsSchemaKey) return contracts.ExtractionResult{}, extractorErrorf("load response schema %q: %w", ResponseSchemaKey, err)
} }
var response extractionResponse var response extractionResponse

View File

@@ -10,7 +10,6 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
) )
func TestExtractReturnsSpellCandidateFromStructuredOutput(t *testing.T) { func TestExtractReturnsSpellCandidateFromStructuredOutput(t *testing.T) {
@@ -42,7 +41,10 @@ func TestExtractReturnsSpellCandidateFromStructuredOutput(t *testing.T) {
if req.StageName != Key { if req.StageName != Key {
t.Fatalf("StageName = %q, want %q", req.StageName, Key) t.Fatalf("StageName = %q, want %q", req.StageName, Key)
} }
schema := llm.MustLookupResponseSchema(llm.DNDSpellsSchemaKey) schema, err := loadResponseSchema()
if err != nil {
t.Fatalf("loadResponseSchema() error = %v, want nil", err)
}
if req.ResponseSchemaName != schema.Name { if req.ResponseSchemaName != schema.Name {
t.Fatalf("ResponseSchemaName = %q, want %q", req.ResponseSchemaName, schema.Name) t.Fatalf("ResponseSchemaName = %q, want %q", req.ResponseSchemaName, schema.Name)
} }

View File

@@ -28,6 +28,22 @@ type promptMetadata struct {
Value string Value string
} }
var spellsPromptBundle = mustLoadPromptBundle()
func mustLoadPromptBundle() *prompt.Bundle {
bundle, err := prompt.LoadBundle(embeddedAssets, prompt.Definition{
PromptID: PromptID,
Version: SchemaVersion,
EmbeddedPath: "assets/prompts",
SystemPath: "assets/prompts/system.md",
UserPath: "assets/prompts/user.md",
})
if err != nil {
panic(err)
}
return bundle
}
func buildPromptData(req contracts.ExtractionRequest) (promptData, error) { func buildPromptData(req contracts.ExtractionRequest) (promptData, error) {
if req.Source == nil { if req.Source == nil {
return promptData{}, fmt.Errorf("dnd spells prompt: source must not be nil") return promptData{}, fmt.Errorf("dnd spells prompt: source must not be nil")
@@ -58,7 +74,7 @@ func renderPrompt(req contracts.ExtractionRequest) (system string, user string,
if err != nil { if err != nil {
return "", "", prompt.Metadata{}, err return "", "", prompt.Metadata{}, err
} }
system, user, metadata, err = prompt.RenderUserSystem(prompt.DNDSpellsPromptID, data) system, user, metadata, err = spellsPromptBundle.RenderUserSystem(data)
if err != nil { if err != nil {
return "", "", prompt.Metadata{}, fmt.Errorf("dnd spells prompt: %w", err) return "", "", prompt.Metadata{}, fmt.Errorf("dnd spells prompt: %w", err)
} }

View File

@@ -92,8 +92,14 @@ func TestRenderPromptIncludesSourceContext(t *testing.T) {
t.Fatalf("user prompt = %q, want substring %q", user, want) t.Fatalf("user prompt = %q, want substring %q", user, want)
} }
} }
if metadata.PromptID != prompt.DNDSpellsPromptID { if metadata.PromptID != PromptID {
t.Fatalf("metadata.PromptID = %q, want %q", metadata.PromptID, prompt.DNDSpellsPromptID) t.Fatalf("metadata.PromptID = %q, want %q", metadata.PromptID, PromptID)
}
if metadata.PromptVersion != SchemaVersion {
t.Fatalf("metadata.PromptVersion = %q, want %q", metadata.PromptVersion, SchemaVersion)
}
if metadata.EmbeddedPath != "assets/prompts" {
t.Fatalf("metadata.EmbeddedPath = %q, want assets/prompts", metadata.EmbeddedPath)
} }
} }

View File

@@ -0,0 +1,20 @@
package spells
import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
const (
PromptID = "dnd.spells"
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_spells")
ResponseSchemaID = "notarius.dnd.spells"
ResponseSchemaName = "notarius_dnd_spells_v1"
)
func loadResponseSchema() (llm.ResponseSchema, error) {
return llm.LoadResponseSchema(embeddedAssets, llm.ResponseSchemaDefinition{
Key: ResponseSchemaKey,
ID: ResponseSchemaID,
Version: SchemaVersion,
Name: ResponseSchemaName,
AssetPath: "assets/schemas/dnd_spells.v1.json",
})
}

View File

@@ -4,23 +4,24 @@ import (
"encoding/json" "encoding/json"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
) )
func TestLookupResponseSchemaForSpells(t *testing.T) { func TestLoadResponseSchemaForSpells(t *testing.T) {
schema, ok := llm.LookupResponseSchema(llm.DNDSpellsSchemaKey) schema, err := loadResponseSchema()
if !ok { if err != nil {
t.Fatalf("LookupResponseSchema(%q) ok = false, want true", llm.DNDSpellsSchemaKey) t.Fatalf("loadResponseSchema() error = %v, want nil", err)
} }
if schema.ID != "notarius.dnd.spells" { if schema.Key != ResponseSchemaKey {
t.Fatalf("schema.ID = %q, want notarius.dnd.spells", schema.ID) t.Fatalf("schema.Key = %q, want %q", schema.Key, ResponseSchemaKey)
}
if schema.ID != ResponseSchemaID {
t.Fatalf("schema.ID = %q, want %q", schema.ID, ResponseSchemaID)
} }
if schema.Version != SchemaVersion { if schema.Version != SchemaVersion {
t.Fatalf("schema.Version = %q, want %q", schema.Version, SchemaVersion) t.Fatalf("schema.Version = %q, want %q", schema.Version, SchemaVersion)
} }
if schema.Name != "notarius_dnd_spells_v1" { if schema.Name != ResponseSchemaName {
t.Fatalf("schema.Name = %q, want notarius_dnd_spells_v1", schema.Name) t.Fatalf("schema.Name = %q, want %q", schema.Name, ResponseSchemaName)
} }
if !strings.HasPrefix(schema.SHA256, "sha256:") { if !strings.HasPrefix(schema.SHA256, "sha256:") {
t.Fatalf("schema.SHA256 = %q, want sha256 prefix", schema.SHA256) t.Fatalf("schema.SHA256 = %q, want sha256 prefix", schema.SHA256)
@@ -30,12 +31,34 @@ func TestLookupResponseSchemaForSpells(t *testing.T) {
} }
} }
func TestResponseSchemaJSONIsMutationSafe(t *testing.T) {
first, err := loadResponseSchema()
if err != nil {
t.Fatalf("loadResponseSchema() error = %v, want nil", err)
}
first.JSONSchema[0] = '['
second, err := loadResponseSchema()
if err != nil {
t.Fatalf("loadResponseSchema() error = %v, want nil", err)
}
if !json.Valid(second.JSONSchema) {
t.Fatalf("schema JSON was mutated: %s", second.JSONSchema)
}
if len(second.JSONSchema) > 0 && second.JSONSchema[0] == '[' {
t.Fatalf("schema JSON did not use defensive copy")
}
}
func TestResponseSchemaDiagnosticsOmitRawSchema(t *testing.T) { func TestResponseSchemaDiagnosticsOmitRawSchema(t *testing.T) {
schema := llm.MustLookupResponseSchema(llm.DNDSpellsSchemaKey) schema, err := loadResponseSchema()
if err != nil {
t.Fatalf("loadResponseSchema() error = %v, want nil", err)
}
diagnostics := schema.DiagnosticsMap() diagnostics := schema.DiagnosticsMap()
if diagnostics["key"] != llm.DNDSpellsSchemaKey { if diagnostics["key"] != ResponseSchemaKey {
t.Fatalf("diagnostics[key] = %#v, want %q", diagnostics["key"], llm.DNDSpellsSchemaKey) t.Fatalf("diagnostics[key] = %#v, want %q", diagnostics["key"], ResponseSchemaKey)
} }
for _, key := range []string{"id", "version", "name", "sha256"} { for _, key := range []string{"id", "version", "name", "sha256"} {
if diagnostics[key] == "" { if diagnostics[key] == "" {
@@ -45,4 +68,7 @@ func TestResponseSchemaDiagnosticsOmitRawSchema(t *testing.T) {
if _, ok := diagnostics["json_schema"]; ok { if _, ok := diagnostics["json_schema"]; ok {
t.Fatalf("diagnostics should omit raw schema content: %#v", diagnostics) t.Fatalf("diagnostics should omit raw schema content: %#v", diagnostics)
} }
if _, ok := diagnostics["JSONSchema"]; ok {
t.Fatalf("diagnostics should omit raw schema content: %#v", diagnostics)
}
} }