From a705ba74a1dd1a6fddabce680afefe9d6a6572f8 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 9 Aug 2026 02:17:15 +0000 Subject: [PATCH] Project spell aliases into extraction prompts --- assets/dnd/spells/prompts/spell-catalog.md | 8 +- .../spell_catalog_identity_contract_test.go | 4 +- .../extract/spells/catalog_prompt_input.go | 6 +- .../dnd/extract/spells/extractor_test.go | 88 +++++++++++++++++-- .../dnd/extract/spells/prompt_assets_test.go | 2 +- .../modules/dnd/spells/catalog/effective.go | 35 ++++++++ .../dnd/spells/catalog/effective_test.go | 61 +++++++++++++ 7 files changed, 186 insertions(+), 18 deletions(-) diff --git a/assets/dnd/spells/prompts/spell-catalog.md b/assets/dnd/spells/prompts/spell-catalog.md index 370958e..554b66f 100644 --- a/assets/dnd/spells/prompts/spell-catalog.md +++ b/assets/dnd/spells/prompts/spell-catalog.md @@ -1,6 +1,6 @@ -The canonical spell-name catalog for this extraction is provided below as JSON. -Return spell names using the catalog's canonical spelling exactly. Aliases and -other campaign reference material are not part of this catalog input and must -not be copied into the output as spell names. +The spell catalog for this extraction is provided below as JSON. Each entry +lists a `canonical_name` and its recognized `aliases`. If the transcript uses +an alias, select that entry's `canonical_name`. Return spell names using the +canonical spelling exactly; never return an alias as a spell name. {{ input "spell_catalog" }} diff --git a/internal/cli/spell_catalog_identity_contract_test.go b/internal/cli/spell_catalog_identity_contract_test.go index 0d4f09f..c1d884c 100644 --- a/internal/cli/spell_catalog_identity_contract_test.go +++ b/internal/cli/spell_catalog_identity_contract_test.go @@ -410,8 +410,8 @@ func TestMaintainedProductionOverlayRunAlignsGroundingValidationAndProvenance(t t.Fatalf("spell requests = %d, want one", len(requests)) } catalogInput, ok := requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot] - if !ok || !strings.Contains(string(catalogInput.Content), "Aegis of Emberfall") || strings.Contains(string(catalogInput.Content), "Emberfall Aegis") { - t.Fatalf("spell catalog prompt input = %#v, want canonical overlay name without alias", catalogInput) + if !ok || !strings.Contains(string(catalogInput.Content), `"canonical_name":"Aegis of Emberfall"`) || !strings.Contains(string(catalogInput.Content), `"aliases":["Emberfall Aegis"]`) { + t.Fatalf("spell catalog prompt input = %#v, want canonical overlay name and recognition alias", catalogInput) } artifact := readProductionJSON[dnd.SpellList](t, filepath.Join(runRoot, "lanes", "spells.json")) if len(artifact.SpellCasts) != 1 || artifact.SpellCasts[0].Spell != "Aegis of Emberfall" { diff --git a/internal/modules/dnd/extract/spells/catalog_prompt_input.go b/internal/modules/dnd/extract/spells/catalog_prompt_input.go index f39bf67..023391a 100644 --- a/internal/modules/dnd/extract/spells/catalog_prompt_input.go +++ b/internal/modules/dnd/extract/spells/catalog_prompt_input.go @@ -12,10 +12,10 @@ import ( func newCatalogPromptInput(effective spellcatalog.EffectiveCatalog) (contracts.LLMInputMaterial, error) { content, err := json.Marshal(struct { - SpellNames []string `json:"spell_names"` - }{SpellNames: effective.CanonicalNames()}) + Spells []spellcatalog.PromptSpell `json:"spells"` + }{Spells: effective.PromptSpells()}) if err != nil { - return contracts.LLMInputMaterial{}, fmt.Errorf("encode canonical spell names: %w", err) + return contracts.LLMInputMaterial{}, fmt.Errorf("encode spell recognition catalog: %w", err) } sum := sha256.Sum256(content) digest := "sha256:" + hex.EncodeToString(sum[:]) diff --git a/internal/modules/dnd/extract/spells/extractor_test.go b/internal/modules/dnd/extract/spells/extractor_test.go index 9942933..3148af7 100644 --- a/internal/modules/dnd/extract/spells/extractor_test.go +++ b/internal/modules/dnd/extract/spells/extractor_test.go @@ -66,7 +66,7 @@ func TestExtractReturnsCanonicalSpellListFromPrivateResponse(t *testing.T) { t.Fatalf("catalog prompt input metadata = %#v", catalogInput) } var catalogPayload struct { - SpellNames []string `json:"spell_names"` + Spells []spellcatalog.PromptSpell `json:"spells"` } if err := json.Unmarshal(catalogInput.Content, &catalogPayload); err != nil { t.Fatalf("decode catalog prompt input: %v", err) @@ -80,24 +80,42 @@ func TestExtractReturnsCanonicalSpellListFromPrivateResponse(t *testing.T) { wantNames = append(wantNames, spell.Name) } sort.Strings(wantNames) - if !reflect.DeepEqual(catalogPayload.SpellNames, wantNames) || !sort.StringsAreSorted(catalogPayload.SpellNames) { - t.Fatalf("catalog prompt names = %d entries, want sorted base catalog", len(catalogPayload.SpellNames)) + gotNames := make([]string, len(catalogPayload.Spells)) + for index, spell := range catalogPayload.Spells { + gotNames[index] = spell.CanonicalName + if !sort.StringsAreSorted(spell.Aliases) { + t.Fatalf("catalog prompt aliases for %q are not sorted: %#v", spell.CanonicalName, spell.Aliases) + } + } + if !reflect.DeepEqual(gotNames, wantNames) || !sort.StringsAreSorted(gotNames) { + t.Fatalf("catalog prompt names = %d entries, want sorted base catalog", len(gotNames)) } } -func TestExtractPromptUsesCanonicalOverlayNamesWithoutAliasesOrMetadata(t *testing.T) { +func TestExtractPromptProjectsCanonicalNamesAndAliasesWithoutMetadata(t *testing.T) { client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}} if _, err := newExtractor(t, client, overlaySpellCatalogReference()).Extract(context.Background(), extractionRequest()); err != nil { t.Fatalf("Extract() error = %v, want nil", err) } input := client.requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot] content := string(input.Content) - for _, expected := range []string{"Aegis of Emberfall", `"spell_names"`} { - if !strings.Contains(content, expected) { - t.Fatalf("catalog prompt input = %q, want %q", content, expected) + var payload struct { + Spells []spellcatalog.PromptSpell `json:"spells"` + } + if err := json.Unmarshal(input.Content, &payload); err != nil { + t.Fatalf("decode catalog prompt input: %v", err) + } + var aegis *spellcatalog.PromptSpell + for index := range payload.Spells { + if payload.Spells[index].CanonicalName == "Aegis of Emberfall" { + aegis = &payload.Spells[index] + break } } - for _, forbidden := range []string{"Emberfall Aegis", "Private campaign source", "file:///private-source.json", "private"} { + if aegis == nil || !reflect.DeepEqual(aegis.Aliases, []string{"Emberfall Aegis"}) { + t.Fatalf("Aegis prompt projection = %#v, want canonical name and alias", aegis) + } + for _, forbidden := range []string{"Private campaign source", "file:///private-source.json", "private", "license", "ruleset", "provenance"} { if strings.Contains(content, forbidden) { t.Fatalf("catalog prompt input leaked %q: %s", forbidden, content) } @@ -143,6 +161,60 @@ func TestExtractPromptUsesCanonicalOverlayNamesWithoutAliasesOrMetadata(t *testi } } +func TestExtractUsesAliasRecognitionToRequestCanonicalSpellNames(t *testing.T) { + client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{{ + Caster: "Aria", + Spell: "Aegis of Emberfall", + SourceRefs: responseSourceRefs(1, 1), + }}}} + request := extractionRequest() + request.Chunk.Content = []byte(`{"segments":[{"id":1,"text":"Aria invokes Emberfall Aegis."}]}`) + request.SourceInput = spellChunkInput(request.Chunk) + + result, err := newExtractor(t, client, overlaySpellCatalogReference()).Extract(context.Background(), request) + if err != nil { + t.Fatalf("Extract() error = %v, want nil", err) + } + if got := result.Value.SpellCasts; len(got) != 1 || got[0].Spell != "Aegis of Emberfall" { + t.Fatalf("spell casts = %#v, want canonical spell name", got) + } + input := client.requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot] + if !strings.Contains(string(input.Content), `"aliases":["Emberfall Aegis"]`) { + t.Fatalf("catalog prompt input = %s, want transcript alias recognition", input.Content) + } +} + +func TestExtractAliasOnlyCatalogChangesPromptMaterialAndCheckpointFingerprint(t *testing.T) { + aliasReference := spellCatalogReference(`{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.example","ruleset":"dnd-5e-2014","source":{"title":"Private campaign source","version":"1","url":"file:///private-source.json","license":"private"},"spells":[{"name":"Cure Wounds","aliases":["Campaign Woundweave"]}]}]}`) + baseClient := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}} + aliasClient := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}} + baseExtractor := newExtractor(t, baseClient) + aliasExtractor := newExtractor(t, aliasClient, aliasReference) + if _, err := baseExtractor.Extract(context.Background(), extractionRequest()); err != nil { + t.Fatalf("base Extract() error = %v", err) + } + if _, err := aliasExtractor.Extract(context.Background(), extractionRequest()); err != nil { + t.Fatalf("alias Extract() error = %v", err) + } + + baseInput := baseClient.requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot] + aliasInput := aliasClient.requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot] + if baseInput.Digest == aliasInput.Digest || string(baseInput.Content) == string(aliasInput.Content) { + t.Fatalf("alias-only catalog did not change prompt material: %q / %q", baseInput.Digest, aliasInput.Digest) + } + if !strings.Contains(string(aliasInput.Content), "Campaign Woundweave") { + t.Fatalf("alias prompt input = %s, want alias recognition", aliasInput.Content) + } + baseFingerprints := checkpointFingerprintMap(baseExtractor.CheckpointFingerprints()) + aliasFingerprints := checkpointFingerprintMap(aliasExtractor.CheckpointFingerprints()) + if baseFingerprints["effective_catalog"] == aliasFingerprints["effective_catalog"] { + t.Fatalf("effective catalog fingerprint did not change: %#v", aliasFingerprints) + } + if baseExtractor.ManifestMetadata()["prompt_id"] != aliasExtractor.ManifestMetadata()["prompt_id"] || baseExtractor.ManifestMetadata()["prompt_version"] != aliasExtractor.ManifestMetadata()["prompt_version"] { + t.Fatal("catalog-only change altered prompt identity") + } +} + func TestNewRejectsMalformedCatalogBeforeLLMCall(t *testing.T) { client := &fakeSpellsLLMClient{} _, err := New(client, Options{}, spellCatalogReference(`{"schema_version":"notarius.dnd.spell-catalog-overlay.v2","catalogs":[]}`)) diff --git a/internal/modules/dnd/extract/spells/prompt_assets_test.go b/internal/modules/dnd/extract/spells/prompt_assets_test.go index 720235a..07d9b55 100644 --- a/internal/modules/dnd/extract/spells/prompt_assets_test.go +++ b/internal/modules/dnd/extract/spells/prompt_assets_test.go @@ -107,7 +107,7 @@ func prepareSpellsPrompt(t *testing.T, transcript []byte, players string, party ProfileID: "spell-test-profile", Inputs: map[string]promptkit.ArtifactRef{ "transcript": promptkit.InlineWithURI("file:///session.json", string(transcript)), - "spell_catalog": promptkit.Inline(`{"spell_names":["spell-catalog-sentinel"]}`), + "spell_catalog": promptkit.Inline(`{"spells":[{"canonical_name":"spell-catalog-sentinel","aliases":["spell-alias-sentinel"]}]}`), "npc_registry": promptkit.Inline(`{"npcs":[{"name":"spell-npc-sentinel"}]}`), "players": promptkit.Inline(players), "party": promptkit.Inline(party), diff --git a/internal/modules/dnd/spells/catalog/effective.go b/internal/modules/dnd/spells/catalog/effective.go index 7e9ac90..56e6d1d 100644 --- a/internal/modules/dnd/spells/catalog/effective.go +++ b/internal/modules/dnd/spells/catalog/effective.go @@ -26,10 +26,18 @@ type EffectiveCatalog struct { ruleset string overlayIDs []string canonicalNames []string + promptSpells []PromptSpell lookup map[string]string digest string } +// PromptSpell is the recognition-only catalog entry supplied to extraction +// prompts. It intentionally contains no catalog provenance or source data. +type PromptSpell struct { + CanonicalName string `json:"canonical_name"` + Aliases []string `json:"aliases"` +} + func (c EffectiveCatalog) BaseID() string { return c.baseID } func (c EffectiveCatalog) Ruleset() string { return c.ruleset } func (c EffectiveCatalog) Digest() string { return c.digest } @@ -41,6 +49,22 @@ func (c EffectiveCatalog) CanonicalNames() []string { return append([]string(nil), c.canonicalNames...) } +// PromptSpells returns canonical names and their recognized aliases in +// deterministic canonical-name order. The result is safe for callers to +// modify. +func (c EffectiveCatalog) PromptSpells() []PromptSpell { + out := make([]PromptSpell, len(c.promptSpells)) + for index, spell := range c.promptSpells { + aliases := make([]string, len(spell.Aliases)) + copy(aliases, spell.Aliases) + out[index] = PromptSpell{ + CanonicalName: spell.CanonicalName, + Aliases: aliases, + } + } + return out +} + // Lookup matches canonical names and aliases after applying the same // normalization used by the embedded catalog. The returned string is the // established canonical display name. @@ -254,6 +278,16 @@ func composeEffectiveCatalog(base Catalog, overlays []overlayCatalog) (Effective canonicalNames = append(canonicalNames, spell.name) } sort.Strings(canonicalNames) + promptSpells := make([]PromptSpell, len(canonicalNames)) + for index, name := range canonicalNames { + spell := builder.spells[name] + aliases := make([]string, 0, len(spell.aliases)) + for _, alias := range spell.aliases { + aliases = append(aliases, alias) + } + sort.Strings(aliases) + promptSpells[index] = PromptSpell{CanonicalName: spell.name, Aliases: aliases} + } digest, err := effectiveDigest(base, overlays, builder, canonicalNames) if err != nil { @@ -264,6 +298,7 @@ func composeEffectiveCatalog(base Catalog, overlays []overlayCatalog) (Effective ruleset: base.Ruleset(), overlayIDs: overlayIDs, canonicalNames: canonicalNames, + promptSpells: promptSpells, lookup: cloneStringMap(builder.lookup), digest: digest, }, nil diff --git a/internal/modules/dnd/spells/catalog/effective_test.go b/internal/modules/dnd/spells/catalog/effective_test.go index 5397b05..e59c0eb 100644 --- a/internal/modules/dnd/spells/catalog/effective_test.go +++ b/internal/modules/dnd/spells/catalog/effective_test.go @@ -79,6 +79,67 @@ func TestResolveEffectiveCatalogAddsAndAugmentsSpells(t *testing.T) { } } +func TestEffectiveCatalogPromptSpellsIncludeSortedAliasesWithoutProvenance(t *testing.T) { + overlay := testOverlayJSON(t, testOverlayCatalog( + "campaign.example", + testOverlaySpell("Aegis of Emberfall", "Z Emberfall", "Emberfall Aegis", "emberfall aegis"), + testOverlaySpell("Cure Wounds", "Healing Touch"), + )) + effective, err := ResolveEffectiveCatalog(overlayReference([]byte(overlay), "application/json")) + if err != nil { + t.Fatal(err) + } + + spells := effective.PromptSpells() + if len(spells) != len(effective.CanonicalNames()) { + t.Fatalf("prompt spell count = %d, want %d", len(spells), len(effective.CanonicalNames())) + } + for index := 1; index < len(spells); index++ { + if spells[index-1].CanonicalName > spells[index].CanonicalName { + t.Fatalf("prompt spells are not sorted: %q before %q", spells[index-1].CanonicalName, spells[index].CanonicalName) + } + } + + aliases := make(map[string][]string, len(spells)) + for _, spell := range spells { + aliases[spell.CanonicalName] = spell.Aliases + if !sort.StringsAreSorted(spell.Aliases) { + t.Fatalf("aliases for %q are not sorted: %#v", spell.CanonicalName, spell.Aliases) + } + } + if got := aliases["Aegis of Emberfall"]; !reflect.DeepEqual(got, []string{"Emberfall Aegis", "Z Emberfall"}) { + t.Fatalf("Aegis aliases = %#v, want normalized aliases once", got) + } + if got := aliases["Cure Wounds"]; !reflect.DeepEqual(got, []string{"Healing Touch"}) { + t.Fatalf("Cure Wounds aliases = %#v", got) + } + + encoded, err := json.Marshal(spells) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), `"aliases":null`) { + t.Fatalf("prompt projection encoded missing aliases as null: %s", encoded) + } + for _, forbidden := range []string{"campaign.example", "spells", "source", "license", "ruleset", "provenance"} { + if strings.Contains(string(encoded), forbidden) { + t.Fatalf("prompt projection leaked %q: %s", forbidden, encoded) + } + } + + for index := range spells { + if spells[index].CanonicalName == "Aegis of Emberfall" { + spells[index].Aliases[0] = "changed" + break + } + } + for _, spell := range effective.PromptSpells() { + if spell.CanonicalName == "Aegis of Emberfall" && spell.Aliases[0] == "changed" { + t.Fatal("effective catalog exposed mutable prompt projection storage") + } + } +} + func TestResolveEffectiveCatalogTreatsRepeatedAliasesAsIdempotent(t *testing.T) { single := testOverlayJSON(t, testOverlayCatalog( "campaign.example",