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

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