Files
notarius/internal/modules/dnd/extract/enemyevents/grounding_test.go

259 lines
11 KiB
Go

package enemyevents
import (
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
combatturncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
scenecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
sceneregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/scenedescriptions/registry"
)
func TestReferenceSlotsDescribeRequiredTypedArtifacts(t *testing.T) {
slots := referenceSlots()
if len(slots) != 8 {
t.Fatalf("ReferenceSlots() count = %d, want 8", len(slots))
}
byName := make(map[string]contracts.ReferenceSlot, len(slots))
for index, slot := range slots {
if index > 0 && slots[index-1].Name > slot.Name {
t.Fatalf("ReferenceSlots() is not sorted: %#v", slots)
}
byName[slot.Name] = slot
}
for _, want := range []struct {
name string
kind contracts.ArtifactKind
}{
{NPCRegistryReferenceSlot, dnd.NPCListKind},
{SceneDescriptionReferenceSlot, dnd.SceneDescriptionListKind},
{CombatTurnReferenceSlot, dnd.CombatTurnListKind},
{NPCInteractionReferenceSlot, dnd.NPCInteractionListKind},
} {
slot, ok := byName[want.name]
if !ok || !slot.Required || slot.MaxBytes != ReferenceMaxBytes || len(slot.AcceptedMediaTypes) != 1 || slot.AcceptedMediaTypes[0] != "application/json" || len(slot.AcceptedArtifactKinds) != 1 || slot.AcceptedArtifactKinds[0] != want.kind {
t.Fatalf("slot %q = %#v", want.name, slot)
}
}
slots[0].AcceptedMediaTypes[0] = "changed"
if referenceSlots()[0].AcceptedMediaTypes[0] == "changed" {
t.Fatal("ReferenceSlots() returned caller-owned storage")
}
}
func TestGroundingProducesExactSourceFreePromptInputs(t *testing.T) {
resolver, err := newGroundingResolver(groundingReferences(t, "Ashfang", dnd.SceneKindCombat))
if err != nil {
t.Fatal(err)
}
resolved, err := resolver.Resolve(contracts.ReferenceSet{})
if err != nil {
t.Fatal(err)
}
inputs := resolved.PromptInputs()
want := map[string]string{
NPCRegistryReferenceSlot: `{"npcs":[{"name":"Ashfang"}]}`,
CombatTurnReferenceSlot: `{"combat_turns":[{"actor":"Ashfang","turn_kind":"turn"},{"actor":"Aria","turn_kind":"reaction"}]}`,
NPCInteractionReferenceSlot: `{"npc_interactions":[{"name":"Ashfang","kind":"combat_opponent"}]}`,
}
if len(inputs) != len(want) {
t.Fatalf("PromptInputs() = %#v", inputs)
}
for name, content := range want {
input, ok := inputs[name]
if !ok || string(input.Content) != content || input.Digest != digest([]byte(content)) || input.OriginURI != "" || input.MediaType != "application/json" {
t.Fatalf("PromptInputs()[%q] = %#v, want %q", name, input, content)
}
for _, forbidden := range []string{"source_ref", "npc-ashfang", "origin", "summary", "combat-session"} {
if strings.Contains(string(input.Content), forbidden) {
t.Fatalf("PromptInputs()[%q] leaked %q: %s", name, forbidden, input.Content)
}
}
}
if _, ok := inputs[SceneDescriptionReferenceSlot]; ok {
t.Fatal("scene descriptions were rendered as prompt grounding")
}
inputs[CombatTurnReferenceSlot] = contracts.NewLLMInputMaterial("changed", "text/plain", []byte("changed"), "", "")
if next := resolved.PromptInputs()[CombatTurnReferenceSlot]; string(next.Content) != want[CombatTurnReferenceSlot] {
t.Fatal("PromptInputs() did not return a defensive copy")
}
}
func TestGroundingResolvesGeneratedReferencesAndSceneEligibility(t *testing.T) {
prepared := groundingReferences(t, "Ashfang", dnd.SceneKindCombat)
resolver, err := newGroundingResolver(prepared)
if err != nil {
t.Fatal(err)
}
generated := groundingReferences(t, "Grimjaw", dnd.SceneKindNarrative)
resolved, err := resolver.Resolve(generated)
if err != nil {
t.Fatal(err)
}
if got := string(resolved.PromptInputs()[NPCRegistryReferenceSlot].Content); got != `{"npcs":[{"name":"Grimjaw"}]}` {
t.Fatalf("generated NPC projection = %s", got)
}
if resolved.SceneMatch(combatChunk()) != (sceneregistry.ChunkMatch{State: sceneregistry.MatchExact, Kind: dnd.SceneKindNarrative}) {
t.Fatalf("generated scene match = %#v", resolved.SceneMatch(combatChunk()))
}
if resolved.SceneMatch(&source.Chunk{ID: "other", Ref: combatChunk().Ref}).State != sceneregistry.MatchMissing {
t.Fatal("missing scene was not reported")
}
mismatched := combatChunk()
mismatched.Ref.EndUnitID++
if resolved.SceneMatch(mismatched).State != sceneregistry.MatchMismatched {
t.Fatal("mismatched scene was not reported")
}
static, err := resolver.Resolve(contracts.ReferenceSet{})
if err != nil {
t.Fatal(err)
}
if got := string(static.PromptInputs()[NPCRegistryReferenceSlot].Content); got != `{"npcs":[{"name":"Ashfang"}]}` {
t.Fatalf("static NPC projection changed after generated resolution: %s", got)
}
if static.SceneMatch(combatChunk()) != (sceneregistry.ChunkMatch{State: sceneregistry.MatchExact, Kind: dnd.SceneKindCombat}) {
t.Fatalf("static scene match = %#v", static.SceneMatch(combatChunk()))
}
}
func TestGroundingRejectsMissingAndInvalidReferences(t *testing.T) {
valid := groundingReferences(t, "Ashfang", dnd.SceneKindCombat)
for _, test := range []struct {
slot string
want string
}{
{NPCRegistryReferenceSlot, "NPC registry"},
{SceneDescriptionReferenceSlot, "scene descriptions"},
{CombatTurnReferenceSlot, CombatTurnReferenceSlot},
{NPCInteractionReferenceSlot, NPCInteractionReferenceSlot},
} {
t.Run("missing "+test.slot, func(t *testing.T) {
resolver, err := newGroundingResolver(withoutSlot(valid, test.slot))
if err != nil {
t.Fatal(err)
}
if _, err := resolver.Resolve(contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Resolve() error = %v, want missing %q reference", err, test.slot)
}
})
}
tests := []struct {
name string
set contracts.ReferenceSet
}{
{"multiple items", replaceSlot(valid, CombatTurnReferenceSlot, contracts.ResolvedReferenceSlot{Items: []contracts.ReferenceItem{{MediaType: "application/json"}, {MediaType: "application/json"}}})},
{"malformed durable content", replaceItem(valid, NPCInteractionReferenceSlot, contracts.ReferenceItem{MediaType: "application/json", Content: []byte(`{}`)})},
{"wrong media type", replaceItem(valid, CombatTurnReferenceSlot, contracts.ReferenceItem{MediaType: "text/plain", Content: valid.Slots[CombatTurnReferenceSlot].Items[0].Content})},
{"oversize", replaceItem(valid, CombatTurnReferenceSlot, contracts.ReferenceItem{MediaType: "application/json", Content: make([]byte, ReferenceMaxBytes+1)})},
}
resolver, err := newGroundingResolver(valid)
if err != nil {
t.Fatal(err)
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := resolver.Resolve(test.set); err == nil {
t.Fatal("Resolve() error = nil")
}
})
}
}
func groundingReferences(t *testing.T, enemy string, sceneKind dnd.SceneKind) contracts.ReferenceSet {
t.Helper()
npcContent, err := npccodec.New().Encode(dnd.NPCList{NPCs: []dnd.NPC{{
ID: identity.DeriveID(enemy),
Name: enemy,
SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}},
}}})
if err != nil {
t.Fatal(err)
}
sceneContent, err := scenecodec.New().Encode(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{
ID: "combat-scene",
SourceRef: source.SourceRef{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 4},
Kind: sceneKind,
Title: "Combat session",
Summary: "A detailed scene summary that must not reach the prompt.",
}}})
if err != nil {
t.Fatal(err)
}
turnContent, err := combatturncodec.New().Encode(dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{
{Actor: enemy, TurnKind: dnd.CombatTurnKindTurn, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}},
{Actor: "Aria", TurnKind: dnd.CombatTurnKindReaction, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 2, EndUnitID: 2}}},
}})
if err != nil {
t.Fatal(err)
}
interactionContent, err := interactioncodec.New().Encode(dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
{Name: enemy, Kind: dnd.NPCInteractionKindCombatOpponent, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}},
{Name: "Aria", Kind: dnd.NPCInteractionKindCombatAlly, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 2, EndUnitID: 2}}},
}})
if err != nil {
t.Fatal(err)
}
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
NPCRegistryReferenceSlot: {Items: []contracts.ReferenceItem{newReferenceItem(NPCRegistryReferenceSlot, npcContent)}},
SceneDescriptionReferenceSlot: {Items: []contracts.ReferenceItem{newReferenceItem(SceneDescriptionReferenceSlot, sceneContent)}},
CombatTurnReferenceSlot: {Items: []contracts.ReferenceItem{newReferenceItem(CombatTurnReferenceSlot, turnContent)}},
NPCInteractionReferenceSlot: {Items: []contracts.ReferenceItem{newReferenceItem(NPCInteractionReferenceSlot, interactionContent)}},
}}
}
func newReferenceItem(slot string, content []byte) contracts.ReferenceItem {
return contracts.ReferenceItem{
SlotName: slot,
MediaType: "application/json",
Content: append([]byte(nil), content...),
Digest: digest(content),
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///private/reference.json"},
Producer: contracts.ReferenceProducer{PipelineID: "prior", StepID: "extract", LaneID: "lane", ModuleKey: "dnd/example"},
}
}
func withoutSlot(set contracts.ReferenceSet, name string) contracts.ReferenceSet {
cloned := contracts.ReferenceSet{Slots: make(map[string]contracts.ResolvedReferenceSlot, len(set.Slots)-1)}
for slotName, slot := range set.Slots {
if slotName != name {
cloned.Slots[slotName] = slot
}
}
return cloned
}
func replaceSlot(set contracts.ReferenceSet, name string, value contracts.ResolvedReferenceSlot) contracts.ReferenceSet {
cloned := contracts.ReferenceSet{Slots: make(map[string]contracts.ResolvedReferenceSlot, len(set.Slots))}
for slotName, slot := range set.Slots {
cloned.Slots[slotName] = slot
}
cloned.Slots[name] = value
return cloned
}
func replaceItem(set contracts.ReferenceSet, name string, item contracts.ReferenceItem) contracts.ReferenceSet {
return replaceSlot(set, name, contracts.ResolvedReferenceSlot{Items: []contracts.ReferenceItem{item}})
}
func combatChunk() *source.Chunk {
return &source.Chunk{ID: "combat-scene", Ref: source.SourceRef{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 4}}
}
func digest(content []byte) string {
sum := sha256.Sum256(content)
return "sha256:" + hex.EncodeToString(sum[:])
}