Project spell aliases into extraction prompts
This commit is contained in:
@@ -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" }}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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[:])
|
||||
|
||||
@@ -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":[]}`))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user