// 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) == 0 { 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 }