298 lines
11 KiB
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
|
|
}
|