Project spell aliases into extraction prompts

This commit is contained in:
2026-08-09 02:17:15 +00:00
parent 8d9c9e7c87
commit a705ba74a1
7 changed files with 186 additions and 18 deletions

View File

@@ -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

View File

@@ -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",