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

298 lines
11 KiB
Go

// Package enemyevents prepares validated D&D combat grounding for enemy-event
// extraction.
package enemyevents
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"mime"
"sort"
"strings"
"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"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
sceneregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/scenedescriptions/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const (
NPCRegistryReferenceSlot = npcregistry.ReferenceSlot
SceneDescriptionReferenceSlot = sceneregistry.ReferenceSlot
CombatTurnReferenceSlot = "combat_turns"
NPCInteractionReferenceSlot = "npc_interactions"
ReferenceMaxBytes = 1048576
)
var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
Glossary: "Optional campaign glossary reference material used only for disambiguation.",
Party: "Optional party roster reference material used only for disambiguation.",
Players: "Optional player list reference material used only for disambiguation.",
Roster: "Deprecated alias for party roster reference material used only for disambiguation.",
}
func referenceSlots() []contracts.ReferenceSlot {
slots := shared.ReferenceSlots(referenceSlotDescriptions)
slots = append(slots,
contracts.ReferenceSlot{
Name: NPCRegistryReferenceSlot,
Description: "Required normalized NPC registry used only for enemy-subject grounding, never as event evidence.",
Required: true,
AcceptedMediaTypes: []string{combatturncodec.MediaType},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
MaxBytes: ReferenceMaxBytes,
},
contracts.ReferenceSlot{
Name: SceneDescriptionReferenceSlot,
Description: "Required scene descriptions used only to determine exact combat eligibility, never as event evidence.",
Required: true,
AcceptedMediaTypes: []string{combatturncodec.MediaType},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.SceneDescriptionListKind},
MaxBytes: ReferenceMaxBytes,
},
contracts.ReferenceSlot{
Name: CombatTurnReferenceSlot,
Description: "Required combat-turn artifact used only as source-free enemy-event grounding.",
Required: true,
AcceptedMediaTypes: []string{combatturncodec.MediaType},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.CombatTurnListKind},
MaxBytes: ReferenceMaxBytes,
},
contracts.ReferenceSlot{
Name: NPCInteractionReferenceSlot,
Description: "Required NPC-interaction artifact used only as source-free enemy-event grounding.",
Required: true,
AcceptedMediaTypes: []string{interactioncodec.MediaType},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCInteractionListKind},
MaxBytes: ReferenceMaxBytes,
},
)
sort.Slice(slots, func(left, right int) bool { return slots[left].Name < slots[right].Name })
return contracts.CloneReferenceSlots(slots)
}
// groundingResolver retains only validated, compact construction-time views.
// Per-operation generated references are decoded when supplied and never
// become static metadata.
type groundingResolver struct {
npcs *npcregistry.Resolver
scenes *sceneregistry.Resolver
combatTurns *contracts.LLMInputMaterial
npcInteractions *contracts.LLMInputMaterial
}
type grounding struct {
npcInput contracts.LLMInputMaterial
combatTurnInput contracts.LLMInputMaterial
npcInteractionInput contracts.LLMInputMaterial
sceneEligibilityView *sceneregistry.Registry
}
func newGroundingResolver(references contracts.ReferenceSet) (*groundingResolver, error) {
npcs, err := npcregistry.NewResolver(references)
if err != nil {
return nil, fmt.Errorf("prepare NPC registry grounding: %w", err)
}
scenes, err := sceneregistry.NewResolver(references)
if err != nil {
return nil, fmt.Errorf("prepare scene eligibility: %w", err)
}
combatTurns, err := prepareCombatTurnInput(references)
if err != nil {
return nil, err
}
npcInteractions, err := prepareNPCInteractionInput(references)
if err != nil {
return nil, err
}
return &groundingResolver{
npcs: npcs,
scenes: scenes,
combatTurns: combatTurns,
npcInteractions: npcInteractions,
}, nil
}
func (r *groundingResolver) Resolve(references contracts.ReferenceSet) (grounding, error) {
if r == nil {
return grounding{}, fmt.Errorf("grounding resolver must not be nil")
}
npcs, err := r.npcs.Resolve(references)
if err != nil {
return grounding{}, fmt.Errorf("resolve NPC registry grounding: %w", err)
}
if !npcs.Bound() {
return grounding{}, fmt.Errorf("NPC registry reference is required")
}
scenes, err := r.resolveScenes(references)
if err != nil {
return grounding{}, err
}
combatTurns, err := resolveInput(references, CombatTurnReferenceSlot, r.combatTurns, prepareCombatTurnInput)
if err != nil {
return grounding{}, err
}
npcInteractions, err := resolveInput(references, NPCInteractionReferenceSlot, r.npcInteractions, prepareNPCInteractionInput)
if err != nil {
return grounding{}, err
}
return grounding{
npcInput: npcs.PromptInput(),
combatTurnInput: combatTurns,
npcInteractionInput: npcInteractions,
sceneEligibilityView: scenes,
}, nil
}
func (r *groundingResolver) SceneMatch(references contracts.ReferenceSet, chunk *source.Chunk) (sceneregistry.ChunkMatch, error) {
if r == nil {
return sceneregistry.ChunkMatch{}, fmt.Errorf("grounding resolver must not be nil")
}
scenes, err := r.resolveScenes(references)
if err != nil {
return sceneregistry.ChunkMatch{}, err
}
return scenes.Match(chunk), nil
}
func (r *groundingResolver) resolveScenes(references contracts.ReferenceSet) (*sceneregistry.Registry, error) {
scenes, err := r.scenes.Resolve(references)
if err != nil {
return nil, fmt.Errorf("resolve scene eligibility: %w", err)
}
if !scenes.Bound() {
return nil, fmt.Errorf("scene descriptions reference is required")
}
return scenes, nil
}
func resolveInput(references contracts.ReferenceSet, slot string, seeded *contracts.LLMInputMaterial, prepare func(contracts.ReferenceSet) (*contracts.LLMInputMaterial, error)) (contracts.LLMInputMaterial, error) {
if _, ok := references.Slots[slot]; ok {
value, err := prepare(references)
if err != nil {
return contracts.LLMInputMaterial{}, err
}
if value == nil {
return contracts.LLMInputMaterial{}, fmt.Errorf("%s reference is required", slot)
}
return value.Clone(), nil
}
if seeded == nil {
return contracts.LLMInputMaterial{}, fmt.Errorf("%s reference is required", slot)
}
return seeded.Clone(), nil
}
func (g grounding) PromptInputs() contracts.LLMInputSet {
return contracts.LLMInputSet{
NPCRegistryReferenceSlot: g.npcInput.Clone(),
CombatTurnReferenceSlot: g.combatTurnInput.Clone(),
NPCInteractionReferenceSlot: g.npcInteractionInput.Clone(),
}
}
func (g grounding) SceneMatch(chunk *source.Chunk) sceneregistry.ChunkMatch {
return g.sceneEligibilityView.Match(chunk)
}
func prepareCombatTurnInput(references contracts.ReferenceSet) (*contracts.LLMInputMaterial, error) {
item, ok, err := referenceItem(references, CombatTurnReferenceSlot, combatturncodec.MediaType)
if err != nil || !ok {
return nil, err
}
value, err := combatturncodec.New().Decode(item.Content)
if err != nil {
return nil, fmt.Errorf("decode combat-turn grounding: invalid approved combat-turn JSON")
}
content, err := json.Marshal(struct {
CombatTurns []combatTurnProjection `json:"combat_turns"`
}{CombatTurns: projectCombatTurns(value.CombatTurns)})
if err != nil {
return nil, fmt.Errorf("encode combat-turn grounding: %w", err)
}
return newPromptInput(CombatTurnReferenceSlot, content), nil
}
func prepareNPCInteractionInput(references contracts.ReferenceSet) (*contracts.LLMInputMaterial, error) {
item, ok, err := referenceItem(references, NPCInteractionReferenceSlot, interactioncodec.MediaType)
if err != nil || !ok {
return nil, err
}
value, err := interactioncodec.New().Decode(item.Content)
if err != nil {
return nil, fmt.Errorf("decode NPC-interaction grounding: invalid approved NPC-interaction JSON")
}
content, err := json.Marshal(struct {
Interactions []npcInteractionProjection `json:"npc_interactions"`
}{Interactions: projectNPCInteractions(value.Interactions)})
if err != nil {
return nil, fmt.Errorf("encode NPC-interaction grounding: %w", err)
}
return newPromptInput(NPCInteractionReferenceSlot, content), nil
}
func referenceItem(references contracts.ReferenceSet, slotName, expectedMediaType string) (contracts.ReferenceItem, bool, error) {
slot, ok := references.Slots[slotName]
if !ok {
return contracts.ReferenceItem{}, false, nil
}
if len(slot.Items) != 1 {
return contracts.ReferenceItem{}, false, fmt.Errorf("reference slot %q must contain exactly one item", slotName)
}
item := slot.Items[0]
mediaType, _, err := mime.ParseMediaType(item.MediaType)
if err != nil {
return contracts.ReferenceItem{}, false, fmt.Errorf("reference slot %q item media type is invalid", slotName)
}
if !strings.EqualFold(mediaType, expectedMediaType) {
return contracts.ReferenceItem{}, false, fmt.Errorf("reference slot %q item media type must be %s", slotName, expectedMediaType)
}
if len(item.Content) > ReferenceMaxBytes {
return contracts.ReferenceItem{}, false, fmt.Errorf("reference slot %q item is %d bytes, limit %d", slotName, len(item.Content), ReferenceMaxBytes)
}
return item, true, nil
}
type combatTurnProjection struct {
Actor string `json:"actor"`
TurnKind dnd.CombatTurnKind `json:"turn_kind"`
}
func projectCombatTurns(turns []dnd.CombatTurn) []combatTurnProjection {
if turns == nil {
return nil
}
projection := make([]combatTurnProjection, len(turns))
for index, turn := range turns {
projection[index] = combatTurnProjection{Actor: turn.Actor, TurnKind: turn.TurnKind}
}
return projection
}
type npcInteractionProjection struct {
Name string `json:"name"`
Kind dnd.NPCInteractionKind `json:"kind"`
}
func projectNPCInteractions(interactions []dnd.NPCInteraction) []npcInteractionProjection {
projection := make([]npcInteractionProjection, 0, len(interactions))
for _, interaction := range interactions {
if interaction.Kind == dnd.NPCInteractionKindCombatOpponent {
projection = append(projection, npcInteractionProjection{Name: interaction.Name, Kind: interaction.Kind})
}
}
return projection
}
func newPromptInput(name string, content []byte) *contracts.LLMInputMaterial {
sum := sha256.Sum256(content)
material := contracts.NewLLMInputMaterial(name, combatturncodec.MediaType, content, "sha256:"+hex.EncodeToString(sum[:]), "")
return &material
}