From 382ca3fb6c1c4ecd43353888c61d595d649107de Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 3 Jul 2026 23:31:19 +0000 Subject: [PATCH] Add D&D spells response schema --- .../llm/assets/schemas/dnd_spells.v1.json | 64 +++++++++++++++++++ internal/framework/llm/schema_registry.go | 8 +++ .../framework/llm/schema_registry_test.go | 52 +++++++++------ .../modules/extract/dnd/spells/schema_test.go | 48 ++++++++++++++ 4 files changed, 151 insertions(+), 21 deletions(-) create mode 100644 internal/framework/llm/assets/schemas/dnd_spells.v1.json create mode 100644 internal/modules/extract/dnd/spells/schema_test.go diff --git a/internal/framework/llm/assets/schemas/dnd_spells.v1.json b/internal/framework/llm/assets/schemas/dnd_spells.v1.json new file mode 100644 index 0000000..4a3da7d --- /dev/null +++ b/internal/framework/llm/assets/schemas/dnd_spells.v1.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "notarius.dnd.spells", + "type": "object", + "additionalProperties": false, + "required": ["spell_casts"], + "properties": { + "spell_casts": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "caster", + "spell", + "effect", + "narrative_description", + "source_refs" + ], + "properties": { + "caster": { + "type": "string", + "minLength": 1 + }, + "spell": { + "type": "string", + "minLength": 1 + }, + "effect": { + "type": "string", + "minLength": 1 + }, + "narrative_description": { + "type": "string", + "minLength": 1 + }, + "source_refs": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["source_id", "start_unit_id", "end_unit_id"], + "properties": { + "source_id": { + "type": "string", + "minLength": 1 + }, + "start_unit_id": { + "type": "string", + "minLength": 1 + }, + "end_unit_id": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + } + } +} diff --git a/internal/framework/llm/schema_registry.go b/internal/framework/llm/schema_registry.go index 678bd00..c5779b2 100644 --- a/internal/framework/llm/schema_registry.go +++ b/internal/framework/llm/schema_registry.go @@ -17,6 +17,7 @@ var schemaAssets embed.FS type ResponseSchemaKey string const ( + DNDSpellsSchemaKey ResponseSchemaKey = "dnd_spells" TestArtifactSchemaKey ResponseSchemaKey = "test_artifact" TestValidatorDecisionSchemaKey ResponseSchemaKey = "test_validator_decision" @@ -34,6 +35,13 @@ 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", diff --git a/internal/framework/llm/schema_registry_test.go b/internal/framework/llm/schema_registry_test.go index 662660c..35942dd 100644 --- a/internal/framework/llm/schema_registry_test.go +++ b/internal/framework/llm/schema_registry_test.go @@ -7,8 +7,9 @@ import ( "testing" ) -func TestLookupResponseSchemaSucceedsForTestSchemas(t *testing.T) { +func TestLookupResponseSchemaSucceedsForRegisteredSchemas(t *testing.T) { tests := []ResponseSchemaKey{ + DNDSpellsSchemaKey, TestArtifactSchemaKey, TestValidatorDecisionSchemaKey, } @@ -50,17 +51,22 @@ func TestMustLookupResponseSchemaPanicsForUnknownKey(t *testing.T) { func TestRegisteredResponseSchemasSortedByKey(t *testing.T) { schemas := RegisteredResponseSchemas() - if len(schemas) != 2 { - t.Fatalf("expected two schemas, got %d", len(schemas)) + if len(schemas) != 3 { + t.Fatalf("expected three schemas, got %d", len(schemas)) } keys := make([]string, len(schemas)) + seen := make(map[ResponseSchemaKey]bool, len(schemas)) for i, schema := range schemas { keys[i] = string(schema.Key) + seen[schema.Key] = true } if !sort.StringsAreSorted(keys) { t.Fatalf("expected sorted keys, got %v", keys) } + if !seen[DNDSpellsSchemaKey] { + t.Fatalf("registered schemas = %v, want %q", keys, DNDSpellsSchemaKey) + } } func TestResponseSchemaContentIsValidJSON(t *testing.T) { @@ -72,26 +78,30 @@ func TestResponseSchemaContentIsValidJSON(t *testing.T) { } func TestResponseSchemaJSONIsMutationSafe(t *testing.T) { - first := MustLookupResponseSchema(TestArtifactSchemaKey) - first.JSONSchema[0] = '[' + for _, key := range []ResponseSchemaKey{DNDSpellsSchemaKey, TestArtifactSchemaKey} { + t.Run(string(key), func(t *testing.T) { + first := MustLookupResponseSchema(key) + first.JSONSchema[0] = '[' - second := MustLookupResponseSchema(TestArtifactSchemaKey) - 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") - } + second := MustLookupResponseSchema(key) + 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") + } - registered := RegisteredResponseSchemas() - for i := range registered { - if registered[i].Key == TestArtifactSchemaKey { - registered[i].JSONSchema[0] = '[' - } - } - again := MustLookupResponseSchema(TestArtifactSchemaKey) - if !json.Valid(again.JSONSchema) || again.JSONSchema[0] == '[' { - t.Fatalf("registered schema JSON did not use defensive copy") + registered := RegisteredResponseSchemas() + for i := range registered { + if registered[i].Key == key { + registered[i].JSONSchema[0] = '[' + } + } + again := MustLookupResponseSchema(key) + if !json.Valid(again.JSONSchema) || again.JSONSchema[0] == '[' { + t.Fatalf("registered schema JSON did not use defensive copy") + } + }) } } diff --git a/internal/modules/extract/dnd/spells/schema_test.go b/internal/modules/extract/dnd/spells/schema_test.go new file mode 100644 index 0000000..2d8a7aa --- /dev/null +++ b/internal/modules/extract/dnd/spells/schema_test.go @@ -0,0 +1,48 @@ +package spells + +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) + } + if schema.ID != "notarius.dnd.spells" { + t.Fatalf("schema.ID = %q, want notarius.dnd.spells", schema.ID) + } + 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 !strings.HasPrefix(schema.SHA256, "sha256:") { + t.Fatalf("schema.SHA256 = %q, want sha256 prefix", schema.SHA256) + } + if !json.Valid(schema.JSONSchema) { + t.Fatalf("schema.JSONSchema is invalid JSON: %s", schema.JSONSchema) + } +} + +func TestResponseSchemaDiagnosticsOmitRawSchema(t *testing.T) { + schema := llm.MustLookupResponseSchema(llm.DNDSpellsSchemaKey) + diagnostics := schema.DiagnosticsMap() + + if diagnostics["key"] != llm.DNDSpellsSchemaKey { + t.Fatalf("diagnostics[key] = %#v, want %q", diagnostics["key"], llm.DNDSpellsSchemaKey) + } + for _, key := range []string{"id", "version", "name", "sha256"} { + if diagnostics[key] == "" { + t.Fatalf("diagnostics[%q] = %#v, want value", key, diagnostics[key]) + } + } + if _, ok := diagnostics["json_schema"]; ok { + t.Fatalf("diagnostics should omit raw schema content: %#v", diagnostics) + } +}