Move D&D spell assets into extractor module
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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] = '['
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
6
internal/modules/extract/dnd/spells/assets.go
Normal file
6
internal/modules/extract/dnd/spells/assets.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package spells
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed assets/prompts/*.md assets/schemas/*.json
|
||||||
|
var embeddedAssets embed.FS
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
20
internal/modules/extract/dnd/spells/schema.go
Normal file
20
internal/modules/extract/dnd/spells/schema.go
Normal 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user