diff --git a/docs/config.md b/docs/config.md index 2b9948b..9a0c0f7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -296,6 +296,7 @@ production validators do not call the LLM and must not set `llm_profile`. | `generic/valid_json` | deterministic | Rejects payloads that are not syntactically valid JSON. | | `generic/valid_json_schema` | deterministic | Rejects invalid JSON or JSON that does not conform to the module response schema. | | `extract/dnd/spells/shape` | deterministic | Rejects malformed D&D spell-list artifacts. | +| `extract/dnd/spells/catalog` | deterministic | Rejects spell-list artifacts containing names outside the effective SRD and overlay catalog. | | `extract/dnd/spells/source_refs` | deterministic | Rejects missing or invalid D&D spell source references. | | `extract/dnd/spells/source_relatedness` | deterministic | Emits warnings when a spell name is not found near its cited source text. | @@ -306,6 +307,7 @@ validators: - generic/valid_json - generic/valid_json_schema - extract/dnd/spells/shape + - extract/dnd/spells/catalog - extract/dnd/spells/source_refs - extract/dnd/spells/source_relatedness ``` diff --git a/docs/internal/modules.md b/docs/internal/modules.md index a23431c..b6525d3 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -211,8 +211,12 @@ codec bytes according to its target context. Neither validator calls the LLM. ## D&D Spell Validators -All three validators receive `dnd.SpellList` directly. The shape validator -rejects missing or empty spell fields and empty reference lists. The +All four validators receive `dnd.SpellList` directly. The shape validator +rejects missing or empty spell fields and empty reference lists. The catalog +validator defers when shape is invalid, then checks every non-empty spell name +against the immutable effective SRD and overlay catalog. It accepts normalized +canonical names and aliases without rewriting the artifact; unknown names +reject the complete result with bounded, stable index/name diagnostics. The source-reference validator applies generic source-reference validation to every cited range. The relatedness validator warns when a case-insensitive spell name is absent from all cited source text. diff --git a/internal/cli/production_contract_test.go b/internal/cli/production_contract_test.go index 8c3ecb6..e8c9859 100644 --- a/internal/cli/production_contract_test.go +++ b/internal/cli/production_contract_test.go @@ -38,6 +38,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop"}) assertProductionContains(t, "outputs", registries.Outputs.RegisteredKeys(), []string{"json"}) assertProductionContains(t, "validators", registries.Validators.RegisteredKeys(), []string{ + "extract/dnd/spells/catalog", "extract/dnd/spells/shape", "extract/dnd/spells/source_refs", "extract/dnd/spells/source_relatedness", @@ -54,6 +55,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/spells/shape"), + pipeline.Binding("extract/dnd/spells/catalog"), pipeline.Binding("extract/dnd/spells/source_refs"), pipeline.Binding("extract/dnd/spells/source_relatedness"), } @@ -124,6 +126,29 @@ func TestProductionPromptAssetsPrepareWithoutProviderCredentials(t *testing.T) { } } +func TestProductionSpellValidatorsPrepareFromMaterializedCatalog(t *testing.T) { + components := productionTestComponents(t) + configPath := repositoryPath("examples", "dnd-spells-production.config.yml") + effective, err := loadMaintainedExample(t, configPath).Resolve(resolveInputForMaintainedExample(components, "dnd-session")) + if err != nil { + t.Fatalf("resolve production spell configuration: %v", err) + } + materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{ + ConfigPath: configPath, + WorkingDir: filepath.Dir(configPath), + }) + if err != nil { + t.Fatalf("materialize production spell references: %v", err) + } + items := materialized.ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items + if len(items) != 1 || items[0].MediaType != "application/json" || len(items[0].Content) == 0 { + t.Fatalf("materialized spell catalog items = %#v, want one JSON item", items) + } + if _, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil { + t.Fatalf("prepare production spell pipeline from materialized catalog: %v", err) + } +} + func TestProductionLLMClientFactoriesBuildOfflineRuntime(t *testing.T) { components := productionTestComponents(t) factories := []struct { diff --git a/internal/cli/spell_catalog_retry_contract_test.go b/internal/cli/spell_catalog_retry_contract_test.go new file mode 100644 index 0000000..748cdaf --- /dev/null +++ b/internal/cli/spell_catalog_retry_contract_test.go @@ -0,0 +1,161 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/config" + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" +) + +func TestProductionSpellCatalogValidationRetries(t *testing.T) { + const retries = 2 + tests := []struct { + name string + responses []string + wantCalls int + wantRejected bool + wantSpell string + wantWarningCode string + }{ + { + name: "unknown spell remains rejected after exhaustion", + responses: []string{ + productionSpellResponse("Unknown Spell"), + productionSpellResponse("Unknown Spell"), + productionSpellResponse("Unknown Spell"), + }, + wantCalls: retries + 1, + wantRejected: true, + }, + { + name: "overlay spell becomes valid on retry", + responses: []string{ + productionSpellResponse("Unknown Spell"), + productionSpellResponse("Aegis of Emberfall"), + }, + wantCalls: 2, + wantSpell: "Aegis of Emberfall", + wantWarningCode: "spell_not_near_source", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + components := productionTestComponents(t) + configPath := repositoryPath("examples", "dnd-spells-production.config.yml") + cfg := loadMaintainedExample(t, configPath) + effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-session", Catalog: catalogFromRegistries(components.registries)}) + if err != nil { + t.Fatalf("resolve production configuration: %v", err) + } + materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{ + ConfigPath: configPath, + WorkingDir: repositoryPath("examples"), + }) + if err != nil { + t.Fatalf("materialize production references: %v", err) + } + materialized.ArtifactLanes[0].Extract.Retries = retries + + llmClient := &catalogRetryLLMClient{responses: tt.responses} + prepared, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: llmClient}) + if err != nil { + t.Fatalf("prepare production pipeline: %v", err) + } + output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{ + Prepared: prepared, + RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"), + ChunkCacheMode: pipeline.ChunkCacheBypass, + }) + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + if calls := llmClient.CallCount(); calls > retries+1 || calls != tt.wantCalls { + t.Fatalf("LLM calls = %d, want %d and no more than %d", calls, tt.wantCalls, retries+1) + } + + if tt.wantRejected { + if len(output.Rejected) != 1 || len(output.NormalizeOutputs) != 0 { + t.Fatalf("rejected = %#v normalized = %#v, want one nonfatal rejection and no merge output", output.Rejected, output.NormalizeOutputs) + } + rejection := output.Rejected[0] + if rejection.ReasonCode != "unknown_spell" || rejection.AttemptCount != retries+1 { + t.Fatalf("rejection = %#v, want exhausted unknown-spell rejection", rejection) + } + if len(output.Warnings) != 0 { + t.Fatalf("warnings = %#v, want no warnings from rejected attempts", output.Warnings) + } + return + } + + if len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 { + t.Fatalf("rejected = %#v normalized = %#v, want only accepted output", output.Rejected, output.NormalizeOutputs) + } + var value dnd.SpellList + if err := json.Unmarshal(output.NormalizeOutputs[0].Artifact.Content, &value); err != nil { + t.Fatalf("decode normalized spell list: %v", err) + } + if len(value.SpellCasts) != 1 || value.SpellCasts[0].Spell != tt.wantSpell { + t.Fatalf("normalized spell list = %#v, want accepted overlay spell", value) + } + if len(output.Warnings) != 1 || output.Warnings[0].ReasonCode != tt.wantWarningCode { + t.Fatalf("warnings = %#v, want only accepted-attempt warning", output.Warnings) + } + }) + } +} + +type catalogRetryLLMClient struct { + mu sync.Mutex + responses []string + calls int +} + +func (client *catalogRetryLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) { + if err := ctx.Err(); err != nil { + return contracts.StructuredCompletionResponse{}, err + } + if req.PromptID != spells.PromptID { + return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID) + } + client.mu.Lock() + index := client.calls + client.calls++ + client.mu.Unlock() + if index >= len(client.responses) { + return contracts.StructuredCompletionResponse{}, fmt.Errorf("missing fake response %d", index) + } + content := []byte(client.responses[index]) + if err := json.Unmarshal(content, out); err != nil { + return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate fake structured target: %w", err) + } + return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "deterministic", ProfileID: req.ProfileID}, nil +} + +func (client *catalogRetryLLMClient) CallCount() int { + client.mu.Lock() + defer client.mu.Unlock() + return client.calls +} + +func productionSpellResponse(name string) string { + content, err := json.Marshal(dnd.SpellList{SpellCasts: []dnd.SpellCast{{ + Caster: "Aria", + Spell: name, + Effect: "The spell takes effect.", + NarrativeDescription: "Aria casts the spell.", + SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}}, + }}}) + if err != nil { + panic(err) + } + return string(content) +} diff --git a/internal/modules/dnd/register/register.go b/internal/modules/dnd/register/register.go index 7a5156b..9c961ee 100644 --- a/internal/modules/dnd/register/register.go +++ b/internal/modules/dnd/register/register.go @@ -10,6 +10,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes" spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" + spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/catalog" spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape" spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs" spellrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_relatedness" @@ -39,6 +40,7 @@ func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error { }}, {name: "spell-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind) }}, {name: "spell shape validator", register: func() error { return spellshape.Register(registries.Validators) }}, + {name: "spell catalog validator", register: func() error { return spellcatalog.Register(registries.Validators) }}, {name: "spell source references validator", register: func() error { return spellsourcerefs.Register(registries.Validators) }}, {name: "spell source relatedness validator", register: func() error { return spellrelatedness.Register(registries.Validators) }}, {name: "spell-list always accept validator", register: func() error { @@ -62,6 +64,7 @@ func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error { pipeline.Binding(validjson.Key), pipeline.Binding(validjsonschema.Key), pipeline.Binding(spellshape.Key), + pipeline.Binding(spellcatalog.Key), pipeline.Binding(spellsourcerefs.Key), pipeline.Binding(spellrelatedness.Key), }, diff --git a/internal/modules/dnd/register/register_test.go b/internal/modules/dnd/register/register_test.go index 816db3b..71fc689 100644 --- a/internal/modules/dnd/register/register_test.go +++ b/internal/modules/dnd/register/register_test.go @@ -24,6 +24,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) { assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells"}) assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind}) assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{ + "extract/dnd/spells/catalog", "extract/dnd/spells/shape", "extract/dnd/spells/source_refs", "extract/dnd/spells/source_relatedness", @@ -34,6 +35,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) { pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/spells/shape"), + pipeline.Binding("extract/dnd/spells/catalog"), pipeline.Binding("extract/dnd/spells/source_refs"), pipeline.Binding("extract/dnd/spells/source_relatedness"), } diff --git a/internal/modules/dnd/validate/spells/catalog/validator.go b/internal/modules/dnd/validate/spells/catalog/validator.go new file mode 100644 index 0000000..09364b4 --- /dev/null +++ b/internal/modules/dnd/validate/spells/catalog/validator.go @@ -0,0 +1,113 @@ +package catalog + +import ( + "context" + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog" + spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape" +) + +const ( + Key = "extract/dnd/spells/catalog" + ReasonCode = "unknown_spell" + maxIssues = 20 +) + +type Options struct{} + +type Validator struct { + catalog spellcatalog.EffectiveCatalog +} + +var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil) + +func New(_ Options, references ...contracts.ReferenceSet) (*Validator, error) { + if len(references) > 1 { + return nil, fmt.Errorf("spell catalog validator accepts at most one reference set") + } + var referenceSet contracts.ReferenceSet + if len(references) == 1 { + referenceSet = references[0] + } + effective, err := spellcatalog.ResolveEffectiveCatalog(referenceSet) + if err != nil { + return nil, err + } + return &Validator{catalog: effective}, nil +} + +func (v *Validator) Name() string { return Key } + +func (v *Validator) ExecutionClass() contracts.ExecutionClass { + return contracts.ExecutionClassDeterministic +} + +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) { + if err := spellshape.Validate(req.Value); err != nil { + return contracts.ValidationResult{Approved: true}, nil + } + + unknown := make([]unknownSpell, 0) + for index, spell := range req.Value.SpellCasts { + name := strings.TrimSpace(spell.Spell) + if name == "" { + continue + } + if _, ok := v.catalog.Lookup(name); !ok { + unknown = append(unknown, unknownSpell{index: index, name: name}) + } + } + if len(unknown) == 0 { + return contracts.ValidationResult{Approved: true}, nil + } + return rejection(unknown), nil +} + +type unknownSpell struct { + index int + name string +} + +func rejection(unknown []unknownSpell) contracts.ValidationResult { + displayed := unknown + if len(displayed) > maxIssues { + displayed = displayed[:maxIssues] + } + issues := make([]string, len(displayed)) + for index, item := range displayed { + issues[index] = fmt.Sprintf("spell_casts[%d].spell %q", item.index, item.name) + } + message := fmt.Sprintf("unknown spell names: %s", strings.Join(issues, ", ")) + if omitted := len(unknown) - len(displayed); omitted > 0 { + message += fmt.Sprintf("; %d additional issue(s) omitted", omitted) + } + return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message} +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.SpellListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.SpellList], error) { + options, err := DecodeOptions(request.Options) + if err != nil { + return nil, err + } + return New(options, request.References) + }) +} + +func DecodeOptions(options map[string]any) (Options, error) { + if err := pipeline.RejectUnknownOptions(options); err != nil { + return Options{}, err + } + return Options{}, nil +} + +func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err } diff --git a/internal/modules/dnd/validate/spells/catalog/validator_test.go b/internal/modules/dnd/validate/spells/catalog/validator_test.go new file mode 100644 index 0000000..ae233cc --- /dev/null +++ b/internal/modules/dnd/validate/spells/catalog/validator_test.go @@ -0,0 +1,176 @@ +package catalog + +import ( + "context" + "fmt" + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + spellreference "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog" +) + +func TestValidatorApprovesCanonicalNormalizedAndOverlayAliasNames(t *testing.T) { + validator, err := New(Options{}, overlayReferences()) + if err != nil { + t.Fatalf("New() error = %v, want nil", err) + } + value := spellList(" cure wounds ", "Emberfall Aegis") + result, err := validator.Validate(context.Background(), validationRequest(value)) + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + if !result.Approved { + t.Fatalf("Validate() = %#v, want approved", result) + } +} + +func TestValidatorRejectsMultipleUnknownCastsInStableOrder(t *testing.T) { + value := spellList("Unknown First", "Cure Wounds", "Unknown Second") + validator, err := New(Options{}) + if err != nil { + t.Fatalf("New() error = %v, want nil", err) + } + result, err := validator.Validate(context.Background(), validationRequest(value)) + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + if result.Approved || result.ReasonCode != ReasonCode { + t.Fatalf("Validate() = %#v, want unknown-spell rejection", result) + } + if want := `spell_casts[0].spell "Unknown First", spell_casts[2].spell "Unknown Second"`; !strings.Contains(result.Message, want) { + t.Fatalf("message = %q, want %q", result.Message, want) + } +} + +func TestValidatorBoundsUnknownCastMessage(t *testing.T) { + value := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, 22)} + for index := range value.SpellCasts { + value.SpellCasts[index] = validCast(fmt.Sprintf("Unknown Spell %02d", index)) + } + validator, err := New(Options{}) + if err != nil { + t.Fatalf("New() error = %v, want nil", err) + } + result, err := validator.Validate(context.Background(), validationRequest(value)) + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + if result.Approved || result.ReasonCode != ReasonCode { + t.Fatalf("Validate() = %#v, want unknown-spell rejection", result) + } + if got := strings.Count(result.Message, "spell_casts["); got != maxIssues { + t.Fatalf("message includes %d issues, want %d: %q", got, maxIssues, result.Message) + } + if !strings.Contains(result.Message, "2 additional issue(s) omitted") || strings.Contains(result.Message, "Unknown Spell 21") { + t.Fatalf("message = %q, want bounded diagnostics", result.Message) + } +} + +func TestValidatorDefersInvalidShape(t *testing.T) { + value := dnd.SpellList{SpellCasts: []dnd.SpellCast{{Spell: "Unknown Spell"}}} + validator, err := New(Options{}) + if err != nil { + t.Fatalf("New() error = %v, want nil", err) + } + result, err := validator.Validate(context.Background(), validationRequest(value)) + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + if !result.Approved || result.ReasonCode != "" || result.Message != "" { + t.Fatalf("Validate() = %#v, want approval without catalog diagnostics", result) + } +} + +func TestValidatorDoesNotMutateArtifactOrCatalog(t *testing.T) { + value := spellList("Cure Wounds") + before := value + validator, err := New(Options{}, overlayReferences()) + if err != nil { + t.Fatalf("New() error = %v, want nil", err) + } + names := validator.catalog.CanonicalNames() + names[0] = "mutated" + result, err := validator.Validate(context.Background(), validationRequest(value)) + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + if !result.Approved || !reflect.DeepEqual(value, before) { + t.Fatalf("Validate() = %#v and value = %#v, want approval without mutation", result, value) + } + if _, ok := validator.catalog.Lookup("Aegis of Emberfall"); !ok { + t.Fatal("catalog lost overlay canonical name after mutating returned names") + } +} + +func TestValidatorUsesStrictEmptyOptions(t *testing.T) { + if _, err := DecodeOptions(nil); err != nil { + t.Fatalf("DecodeOptions(nil) error = %v, want nil", err) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() error = nil, want unknown option error") + } +} + +func TestValidatorSpecAndRegister(t *testing.T) { + spec := Spec() + if spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("Spec() = %#v, want deterministic catalog validator", spec) + } + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + registered, ok := registry.Spec(Key) + if !ok || registered != spec { + t.Fatalf("registry.Spec(%q) = %#v, present = %t; want %#v", Key, registered, ok, spec) + } +} + +func validationRequest(value dnd.SpellList) contracts.TypedValidationRequest[dnd.SpellList] { + return contracts.TypedValidationRequest[dnd.SpellList]{Value: value} +} + +func spellList(names ...string) dnd.SpellList { + value := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, len(names))} + for index, name := range names { + value.SpellCasts[index] = validCast(name) + } + return value +} + +func validCast(name string) dnd.SpellCast { + return dnd.SpellCast{ + Caster: "Aria", + Spell: name, + Effect: "heals an ally", + NarrativeDescription: "Aria restores Borin.", + SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, + } +} + +func overlayReferences() contracts.ReferenceSet { + content := []byte(`{ + "schema_version": "notarius.dnd.spell-catalog-overlay.v1", + "catalogs": [{ + "id": "campaign-spells", + "ruleset": "dnd-5e-2014", + "source": {"title": "Campaign spell names"}, + "spells": [{"name": "Aegis of Emberfall", "aliases": ["Emberfall Aegis"]}] + }] +}`) + return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + spellreference.SpellCatalogReferenceSlot: { + Slot: contracts.ReferenceSlot{Name: spellreference.SpellCatalogReferenceSlot}, + Items: []contracts.ReferenceItem{{ + SlotName: spellreference.SpellCatalogReferenceSlot, + MediaType: "application/json", + Content: content, + }}, + }, + }} +}