diff --git a/internal/framework/llm/schema_registry.go b/internal/framework/llm/schema_registry.go index c5779b2..d4f8d25 100644 --- a/internal/framework/llm/schema_registry.go +++ b/internal/framework/llm/schema_registry.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io/fs" "sort" "strings" ) @@ -17,13 +18,21 @@ var schemaAssets embed.FS type ResponseSchemaKey string const ( - DNDSpellsSchemaKey ResponseSchemaKey = "dnd_spells" TestArtifactSchemaKey ResponseSchemaKey = "test_artifact" TestValidatorDecisionSchemaKey ResponseSchemaKey = "test_validator_decision" 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. type ResponseSchema struct { Key ResponseSchemaKey `json:"key"` @@ -35,27 +44,20 @@ type ResponseSchema struct { } var responseSchemaRegistry = map[ResponseSchemaKey]ResponseSchema{ - DNDSpellsSchemaKey: mustLoadResponseSchema( - DNDSpellsSchemaKey, - "notarius.dnd.spells", - schemaVersionV1, - "notarius_dnd_spells_v1", - "assets/schemas/dnd_spells.v1.json", - ), - TestArtifactSchemaKey: mustLoadResponseSchema( - TestArtifactSchemaKey, - "notarius.test_artifact", - schemaVersionV1, - "notarius_test_artifact_v1", - "assets/schemas/test_artifact.v1.json", - ), - TestValidatorDecisionSchemaKey: mustLoadResponseSchema( - TestValidatorDecisionSchemaKey, - "notarius.test_validator_decision", - schemaVersionV1, - "notarius_test_validator_decision_v1", - "assets/schemas/test_validator_decision.v1.json", - ), + TestArtifactSchemaKey: mustLoadResponseSchema(schemaAssets, ResponseSchemaDefinition{ + Key: TestArtifactSchemaKey, + ID: "notarius.test_artifact", + Version: schemaVersionV1, + Name: "notarius_test_artifact_v1", + AssetPath: "assets/schemas/test_artifact.v1.json", + }), + TestValidatorDecisionSchemaKey: mustLoadResponseSchema(schemaAssets, ResponseSchemaDefinition{ + Key: TestValidatorDecisionSchemaKey, + ID: "notarius.test_validator_decision", + Version: schemaVersionV1, + Name: "notarius_test_validator_decision_v1", + AssetPath: "assets/schemas/test_validator_decision.v1.json", + }), } // RegisteredResponseSchemas returns all registered response schemas sorted by key. @@ -102,40 +104,35 @@ func (s ResponseSchema) DiagnosticsMap() map[string]any { } } -func mustLoadResponseSchema( - key ResponseSchemaKey, - id string, - version string, - name string, - path string, -) ResponseSchema { - key = ResponseSchemaKey(strings.TrimSpace(string(key))) - id = strings.TrimSpace(id) - version = strings.TrimSpace(version) - name = strings.TrimSpace(name) - path = strings.TrimSpace(path) +// LoadResponseSchema loads a structured response schema from a caller-owned filesystem. +func LoadResponseSchema(fsys fs.FS, def ResponseSchemaDefinition) (ResponseSchema, error) { + key := ResponseSchemaKey(strings.TrimSpace(string(def.Key))) + id := strings.TrimSpace(def.ID) + version := strings.TrimSpace(def.Version) + name := strings.TrimSpace(def.Name) + path := strings.TrimSpace(def.AssetPath) if key == "" { - panic("response schema key must not be empty") + return ResponseSchema{}, fmt.Errorf("response schema key must not be empty") } if id == "" { - panic("response schema id must not be empty") + return ResponseSchema{}, fmt.Errorf("response schema id must not be empty") } if version == "" { - panic("response schema version must not be empty") + return ResponseSchema{}, fmt.Errorf("response schema version must not be empty") } if name == "" { - panic("response schema name must not be empty") + return ResponseSchema{}, fmt.Errorf("response schema name must not be empty") } 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 { - 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) { - 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) @@ -146,7 +143,15 @@ func mustLoadResponseSchema( Name: name, JSONSchema: append(json.RawMessage(nil), rawSchema...), 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 { diff --git a/internal/framework/llm/schema_registry_test.go b/internal/framework/llm/schema_registry_test.go index 35942dd..076b8bc 100644 --- a/internal/framework/llm/schema_registry_test.go +++ b/internal/framework/llm/schema_registry_test.go @@ -9,7 +9,6 @@ import ( func TestLookupResponseSchemaSucceedsForRegisteredSchemas(t *testing.T) { tests := []ResponseSchemaKey{ - DNDSpellsSchemaKey, TestArtifactSchemaKey, 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) { defer func() { if recover() == nil { @@ -51,8 +56,8 @@ func TestMustLookupResponseSchemaPanicsForUnknownKey(t *testing.T) { func TestRegisteredResponseSchemasSortedByKey(t *testing.T) { schemas := RegisteredResponseSchemas() - if len(schemas) != 3 { - t.Fatalf("expected three schemas, got %d", len(schemas)) + if len(schemas) != 2 { + t.Fatalf("expected two schemas, got %d", len(schemas)) } keys := make([]string, len(schemas)) @@ -64,8 +69,8 @@ func TestRegisteredResponseSchemasSortedByKey(t *testing.T) { if !sort.StringsAreSorted(keys) { t.Fatalf("expected sorted keys, got %v", keys) } - if !seen[DNDSpellsSchemaKey] { - t.Fatalf("registered schemas = %v, want %q", keys, DNDSpellsSchemaKey) + if !seen[TestArtifactSchemaKey] || !seen[TestValidatorDecisionSchemaKey] { + t.Fatalf("registered schemas = %v, want test schemas", keys) } } @@ -78,7 +83,7 @@ func TestResponseSchemaContentIsValidJSON(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) { first := MustLookupResponseSchema(key) first.JSONSchema[0] = '[' diff --git a/internal/framework/prompt/registry.go b/internal/framework/prompt/registry.go index f67e03c..530a6a4 100644 --- a/internal/framework/prompt/registry.go +++ b/internal/framework/prompt/registry.go @@ -5,6 +5,7 @@ import ( "embed" "encoding/hex" "fmt" + "io/fs" "path" "sort" "strings" @@ -17,7 +18,6 @@ var embeddedAssets embed.FS const ( SourceBuiltin = "builtin" VersionV1 = "v1" - DNDSpellsPromptID = "dnd.spells" TestGenericPromptID = "test.generic" ) @@ -41,21 +41,23 @@ func (m Metadata) DiagnosticsMap() map[string]any { } } -type definition struct { - id string - version string - embeddedDir string - systemPath string - userPath string +// Definition identifies a caller-owned system/user prompt bundle. +type Definition struct { + PromptID string + Version string + EmbeddedPath string + SystemPath string + UserPath string } -type compiledPrompt struct { +// Bundle is a compiled system/user prompt pair. +type Bundle struct { systemTmpl *template.Template userTmpl *template.Template metadata Metadata } -var promptRegistry map[string]compiledPrompt +var promptRegistry map[string]*Bundle var sharedHardening string func init() { @@ -65,30 +67,23 @@ func init() { panic(err) } - defs := []definition{ + 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, - embeddedDir: "assets/test/generic", - systemPath: "assets/test/generic/system.md", - userPath: "assets/test/generic/user.md", + PromptID: TestGenericPromptID, + Version: VersionV1, + EmbeddedPath: "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 { - compiled, compileErr := compilePrompt(def) + compiled, compileErr := LoadBundle(embeddedAssets, def) if compileErr != nil { 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 } -func compilePrompt(def definition) (compiledPrompt, error) { - if strings.TrimSpace(def.id) == "" { - return compiledPrompt{}, fmt.Errorf("prompt id must not be empty") +// LoadBundle compiles a system/user prompt bundle from a caller-owned filesystem. +func LoadBundle(fsys fs.FS, def Definition) (*Bundle, error) { + 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) == "" { - return compiledPrompt{}, fmt.Errorf("prompt version must not be empty") + if version == "" { + return nil, fmt.Errorf("prompt version must not be empty") } - if strings.TrimSpace(def.embeddedDir) == "" { - return compiledPrompt{}, fmt.Errorf("prompt embedded path must not be empty") + if embeddedPath == "" { + return nil, fmt.Errorf("prompt embedded path must not be empty") } - systemSource, err := readAsset(def.systemPath) + systemSource, err := readPromptAsset(fsys, systemPath) if err != nil { - return compiledPrompt{}, err + return nil, err } - userSource, err := readAsset(def.userPath) + userSource, err := readPromptAsset(fsys, userPath) if err != nil { - return compiledPrompt{}, err + return nil, err } funcs := template.FuncMap{ "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 { - 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 { - 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 hash := sha256.Sum256([]byte(hashInput)) metadata := Metadata{ - PromptID: strings.TrimSpace(def.id), - PromptVersion: strings.TrimSpace(def.version), + PromptID: promptID, + PromptVersion: version, PromptSource: SourceBuiltin, - EmbeddedPath: strings.TrimSpace(def.embeddedDir), + EmbeddedPath: embeddedPath, SHA256: "sha256:" + hex.EncodeToString(hash[:]), } - return compiledPrompt{ + return &Bundle{ systemTmpl: systemTmpl, userTmpl: userTmpl, metadata: metadata, }, 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 +} diff --git a/internal/framework/prompt/registry_test.go b/internal/framework/prompt/registry_test.go index 5da24c9..35af7fb 100644 --- a/internal/framework/prompt/registry_test.go +++ b/internal/framework/prompt/registry_test.go @@ -11,7 +11,6 @@ func TestLookupMetadataSucceedsForRegisteredPrompts(t *testing.T) { promptID string embeddedPath string }{ - {promptID: DNDSpellsPromptID, embeddedPath: "assets/dnd/spells"}, {promptID: TestGenericPromptID, embeddedPath: "assets/test/generic"}, } @@ -59,8 +58,8 @@ func TestMustLookupMetadataPanicsForUnknownPromptID(t *testing.T) { func TestRegisteredMetadataSortedByPromptID(t *testing.T) { registered := RegisteredMetadata() - if len(registered) != 2 { - t.Fatalf("expected two registered prompts, got %d", len(registered)) + if len(registered) != 1 { + t.Fatalf("expected one registered prompt, got %d", len(registered)) } ids := make([]string, len(registered)) @@ -72,8 +71,8 @@ func TestRegisteredMetadataSortedByPromptID(t *testing.T) { 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) + if !seen[TestGenericPromptID] { + t.Fatalf("registered prompt IDs = %v, want %q", ids, TestGenericPromptID) } } diff --git a/internal/framework/prompt/render.go b/internal/framework/prompt/render.go index 40e220e..67d1343 100644 --- a/internal/framework/prompt/render.go +++ b/internal/framework/prompt/render.go @@ -13,16 +13,23 @@ func RenderUserSystem(promptID string, data any) (system string, user string, me if !ok { 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 - if err := compiled.systemTmpl.Execute(&systemBuf, data); err != nil { - return "", "", Metadata{}, fmt.Errorf("render system prompt %q: %w", trimmedID, err) + if err := b.systemTmpl.Execute(&systemBuf, data); err != nil { + return "", "", Metadata{}, fmt.Errorf("render system prompt %q: %w", b.metadata.PromptID, err) } var userBuf bytes.Buffer - if err := compiled.userTmpl.Execute(&userBuf, data); err != nil { - return "", "", Metadata{}, fmt.Errorf("render user prompt %q: %w", trimmedID, err) + if err := b.userTmpl.Execute(&userBuf, data); err != nil { + 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 } diff --git a/internal/framework/prompt/render_test.go b/internal/framework/prompt/render_test.go index af6bebb..cda4deb 100644 --- a/internal/framework/prompt/render_test.go +++ b/internal/framework/prompt/render_test.go @@ -64,47 +64,3 @@ 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/assets.go b/internal/modules/extract/dnd/spells/assets.go new file mode 100644 index 0000000..22e668e --- /dev/null +++ b/internal/modules/extract/dnd/spells/assets.go @@ -0,0 +1,6 @@ +package spells + +import "embed" + +//go:embed assets/prompts/*.md assets/schemas/*.json +var embeddedAssets embed.FS diff --git a/internal/framework/prompt/assets/dnd/spells/system.md b/internal/modules/extract/dnd/spells/assets/prompts/system.md similarity index 100% rename from internal/framework/prompt/assets/dnd/spells/system.md rename to internal/modules/extract/dnd/spells/assets/prompts/system.md diff --git a/internal/framework/prompt/assets/dnd/spells/user.md b/internal/modules/extract/dnd/spells/assets/prompts/user.md similarity index 100% rename from internal/framework/prompt/assets/dnd/spells/user.md rename to internal/modules/extract/dnd/spells/assets/prompts/user.md diff --git a/internal/framework/llm/assets/schemas/dnd_spells.v1.json b/internal/modules/extract/dnd/spells/assets/schemas/dnd_spells.v1.json similarity index 100% rename from internal/framework/llm/assets/schemas/dnd_spells.v1.json rename to internal/modules/extract/dnd/spells/assets/schemas/dnd_spells.v1.json diff --git a/internal/modules/extract/dnd/spells/extractor.go b/internal/modules/extract/dnd/spells/extractor.go index ea0f766..6e4c3d3 100644 --- a/internal/modules/extract/dnd/spells/extractor.go +++ b/internal/modules/extract/dnd/spells/extractor.go @@ -9,7 +9,6 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" - "gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "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 { return contracts.ExtractionResult{}, extractorErrorf("render prompt: %w", err) } - schema, ok := llm.LookupResponseSchema(llm.DNDSpellsSchemaKey) - if !ok { - return contracts.ExtractionResult{}, extractorErrorf("lookup response schema %q", llm.DNDSpellsSchemaKey) + schema, err := loadResponseSchema() + if err != nil { + return contracts.ExtractionResult{}, extractorErrorf("load response schema %q: %w", ResponseSchemaKey, err) } var response extractionResponse diff --git a/internal/modules/extract/dnd/spells/extractor_test.go b/internal/modules/extract/dnd/spells/extractor_test.go index 08c4e6e..74ec9a1 100644 --- a/internal/modules/extract/dnd/spells/extractor_test.go +++ b/internal/modules/extract/dnd/spells/extractor_test.go @@ -10,7 +10,6 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" - "gitea.maximumdirect.net/eric/notarius/internal/framework/llm" ) func TestExtractReturnsSpellCandidateFromStructuredOutput(t *testing.T) { @@ -42,7 +41,10 @@ func TestExtractReturnsSpellCandidateFromStructuredOutput(t *testing.T) { if 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 { t.Fatalf("ResponseSchemaName = %q, want %q", req.ResponseSchemaName, schema.Name) } diff --git a/internal/modules/extract/dnd/spells/prompt.go b/internal/modules/extract/dnd/spells/prompt.go index ee30419..7b66473 100644 --- a/internal/modules/extract/dnd/spells/prompt.go +++ b/internal/modules/extract/dnd/spells/prompt.go @@ -28,6 +28,22 @@ type promptMetadata struct { 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) { if req.Source == 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 { return "", "", prompt.Metadata{}, err } - system, user, metadata, err = prompt.RenderUserSystem(prompt.DNDSpellsPromptID, data) + system, user, metadata, err = spellsPromptBundle.RenderUserSystem(data) if err != nil { return "", "", prompt.Metadata{}, fmt.Errorf("dnd spells prompt: %w", err) } diff --git a/internal/modules/extract/dnd/spells/prompt_test.go b/internal/modules/extract/dnd/spells/prompt_test.go index fd91d77..4bfea5b 100644 --- a/internal/modules/extract/dnd/spells/prompt_test.go +++ b/internal/modules/extract/dnd/spells/prompt_test.go @@ -92,8 +92,14 @@ func TestRenderPromptIncludesSourceContext(t *testing.T) { 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) + if metadata.PromptID != PromptID { + 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) } } diff --git a/internal/modules/extract/dnd/spells/schema.go b/internal/modules/extract/dnd/spells/schema.go new file mode 100644 index 0000000..022820f --- /dev/null +++ b/internal/modules/extract/dnd/spells/schema.go @@ -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", + }) +} diff --git a/internal/modules/extract/dnd/spells/schema_test.go b/internal/modules/extract/dnd/spells/schema_test.go index 2d8a7aa..3955a2c 100644 --- a/internal/modules/extract/dnd/spells/schema_test.go +++ b/internal/modules/extract/dnd/spells/schema_test.go @@ -4,23 +4,24 @@ import ( "encoding/json" "strings" "testing" - - "gitea.maximumdirect.net/eric/notarius/internal/framework/llm" ) -func TestLookupResponseSchemaForSpells(t *testing.T) { - schema, ok := llm.LookupResponseSchema(llm.DNDSpellsSchemaKey) - if !ok { - t.Fatalf("LookupResponseSchema(%q) ok = false, want true", llm.DNDSpellsSchemaKey) +func TestLoadResponseSchemaForSpells(t *testing.T) { + schema, err := loadResponseSchema() + if err != nil { + t.Fatalf("loadResponseSchema() error = %v, want nil", err) } - if schema.ID != "notarius.dnd.spells" { - t.Fatalf("schema.ID = %q, want notarius.dnd.spells", schema.ID) + if schema.Key != ResponseSchemaKey { + 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 { t.Fatalf("schema.Version = %q, want %q", schema.Version, SchemaVersion) } - if schema.Name != "notarius_dnd_spells_v1" { - t.Fatalf("schema.Name = %q, want notarius_dnd_spells_v1", schema.Name) + if schema.Name != ResponseSchemaName { + t.Fatalf("schema.Name = %q, want %q", schema.Name, ResponseSchemaName) } if !strings.HasPrefix(schema.SHA256, "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) { - schema := llm.MustLookupResponseSchema(llm.DNDSpellsSchemaKey) + schema, err := loadResponseSchema() + if err != nil { + t.Fatalf("loadResponseSchema() error = %v, want nil", err) + } diagnostics := schema.DiagnosticsMap() - if diagnostics["key"] != llm.DNDSpellsSchemaKey { - t.Fatalf("diagnostics[key] = %#v, want %q", diagnostics["key"], llm.DNDSpellsSchemaKey) + if diagnostics["key"] != ResponseSchemaKey { + t.Fatalf("diagnostics[key] = %#v, want %q", diagnostics["key"], ResponseSchemaKey) } for _, key := range []string{"id", "version", "name", "sha256"} { if diagnostics[key] == "" { @@ -45,4 +68,7 @@ func TestResponseSchemaDiagnosticsOmitRawSchema(t *testing.T) { if _, ok := diagnostics["json_schema"]; ok { 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) + } }