Share the D&D NPC registry and prompt grounding
This commit is contained in:
120
internal/modules/dnd/extract/spells/npc_registry_wiring_test.go
Normal file
120
internal/modules/dnd/extract/spells/npc_registry_wiring_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package spells
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
|
||||
)
|
||||
|
||||
func TestSpellExtractorUsesExactUnboundNPCPromptAndOmitsRegistryIdentity(t *testing.T) {
|
||||
extractor := newExtractor(t, &fakeSpellsLLMClient{})
|
||||
metadata := extractor.ManifestMetadata()
|
||||
if metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil {
|
||||
t.Fatalf("unbound extractor metadata = %#v, want no NPC registry fields", metadata)
|
||||
}
|
||||
for _, fingerprint := range extractor.CheckpointFingerprints() {
|
||||
if fingerprint.Name == "npc_registry" {
|
||||
t.Fatalf("unbound checkpoint fingerprints = %#v, want no NPC registry fingerprint", extractor.CheckpointFingerprints())
|
||||
}
|
||||
}
|
||||
|
||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
|
||||
extractor = newExtractor(t, client)
|
||||
if _, err := extractor.Extract(context.Background(), extractionRequest()); err != nil {
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
input := client.requests[0].Inputs[NPCRegistryReferenceSlot]
|
||||
if input.Name != NPCRegistryReferenceSlot || input.MediaType != npccodec.MediaType || input.Digest != "" || input.OriginURI != "" || string(input.Content) != `{"npcs":[]}` {
|
||||
t.Fatalf("NPC prompt input = %#v, want exact empty registry material", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpellExtractorPreservesSemanticNPCRegistryFingerprintAndPromptWiring(t *testing.T) {
|
||||
value := registryFixture()
|
||||
canonical, err := npccodec.New().Encode(value)
|
||||
if err != nil {
|
||||
t.Fatalf("encode NPC registry: %v", err)
|
||||
}
|
||||
pretty, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalIndent() error = %v", err)
|
||||
}
|
||||
first := newExtractor(t, &fakeSpellsLLMClient{}, spellNPCRegistryReference(canonical, "file:///one.json"))
|
||||
second := newExtractor(t, &fakeSpellsLLMClient{}, spellNPCRegistryReference(pretty, "file:///two.json"))
|
||||
firstFingerprints := checkpointFingerprintMap(first.CheckpointFingerprints())
|
||||
secondFingerprints := checkpointFingerprintMap(second.CheckpointFingerprints())
|
||||
if firstFingerprints["npc_registry"] == "" || firstFingerprints["npc_registry"] != secondFingerprints["npc_registry"] {
|
||||
t.Fatalf("semantic NPC fingerprints = %#v and %#v, want same npc_registry value", firstFingerprints, secondFingerprints)
|
||||
}
|
||||
metadata := first.ManifestMetadata()
|
||||
if metadata["npc_registry_digest"] != firstFingerprints["npc_registry"] || metadata["npc_count"] != 1 {
|
||||
t.Fatalf("NPC registry metadata = %#v, want digest and count only", metadata)
|
||||
}
|
||||
|
||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
|
||||
extractor := newExtractor(t, client, spellNPCRegistryReference(append([]byte("\n"), canonical...), "file:///npc-session.json"))
|
||||
if _, err := extractor.Extract(context.Background(), extractionRequest()); err != nil {
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
input := client.requests[0].Inputs[NPCRegistryReferenceSlot]
|
||||
if input.Name != NPCRegistryReferenceSlot || input.MediaType != npccodec.MediaType || input.Digest != firstFingerprints["npc_registry"] || input.OriginURI != "" {
|
||||
t.Fatalf("NPC prompt input metadata = %#v, want semantic metadata without provenance", input)
|
||||
}
|
||||
if !bytes.Equal(input.Content, canonical) {
|
||||
t.Fatalf("NPC prompt input = %s, want canonical JSON %s", input.Content, canonical)
|
||||
}
|
||||
encoded, err := json.Marshal(map[string]any{"metadata": metadata, "fingerprints": firstFingerprints})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal metadata: %v", err)
|
||||
}
|
||||
for _, forbidden := range []string{"Mira Thorn", "The Greencloak", "one.json", "two.json"} {
|
||||
if strings.Contains(string(encoded), forbidden) {
|
||||
t.Fatalf("metadata or fingerprints leaked %q: %s", forbidden, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func registryFixture() dnd.NPCList {
|
||||
return dnd.NPCList{NPCs: []dnd.NPC{{
|
||||
ID: identity.DeriveID("Mira Thorn"),
|
||||
Name: "Mira Thorn",
|
||||
Aliases: []string{"The Greencloak"},
|
||||
Description: "A guarded ranger who watches the northern road.",
|
||||
Relationships: []dnd.NPCRelationship{{
|
||||
Target: "Captain Vale", Relationship: "reports to",
|
||||
}},
|
||||
SourceRefs: []source.SourceRef{{SourceID: "npc-session", StartUnitID: 41, EndUnitID: 43}},
|
||||
}}}
|
||||
}
|
||||
|
||||
func spellNPCRegistryReference(content []byte, origin string) contracts.ReferenceSet {
|
||||
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
registry.ReferenceSlot: {
|
||||
Slot: contracts.ReferenceSlot{Name: registry.ReferenceSlot, AcceptedMediaTypes: []string{npccodec.MediaType}, MaxBytes: registry.MaxBytes},
|
||||
Items: []contracts.ReferenceItem{{
|
||||
SlotName: registry.ReferenceSlot,
|
||||
MediaType: npccodec.MediaType,
|
||||
Content: append([]byte(nil), content...),
|
||||
Origin: contracts.ReferenceOrigin{Type: "file", URI: origin},
|
||||
}},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func checkpointFingerprintMap(values []pipeline.CheckpointFingerprint) map[string]string {
|
||||
result := make(map[string]string, len(values))
|
||||
for _, value := range values {
|
||||
result[value.Name] = value.Value
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user