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

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

View File

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

View File

@@ -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[:])

View File

@@ -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":[]}`))

View File

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

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