// Package registry resolves normalized NPC artifacts into immutable grounding // data for D&D extraction modules. package registry import ( "crypto/sha256" "encoding/hex" "fmt" "mime" "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" 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/shared/diagnostics" ) const ( ReferenceSlot = "npcs" MaxBytes = 1048576 emptyPrompt = `{"npcs":[]}` ) // Registry is an immutable, validated NPC registry prepared for prompt // grounding. All accessors return defensive copies. type Registry struct { bound bool list dnd.NPCList canonical []byte digest string promptInput contracts.LLMInputMaterial lookupByKey map[string]int } // Resolve prepares the optional NPC registry reference. An absent slot // produces the exact empty prompt input and no semantic registry identity. func Resolve(references contracts.ReferenceSet) (*Registry, error) { slot, ok := references.Slots[ReferenceSlot] if !ok { content := []byte(emptyPrompt) return &Registry{ list: dnd.NPCList{NPCs: []dnd.NPC{}}, canonical: append([]byte(nil), content...), promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, "", ""), lookupByKey: map[string]int{}, }, nil } if len(slot.Items) != 1 { return nil, fmt.Errorf("reference slot %q must contain exactly one item", ReferenceSlot) } item := slot.Items[0] mediaType, _, err := mime.ParseMediaType(item.MediaType) if err != nil { return nil, fmt.Errorf("reference slot %q item media type is invalid", ReferenceSlot) } if !strings.EqualFold(mediaType, npccodec.MediaType) { return nil, fmt.Errorf("reference slot %q item media type must be %s", ReferenceSlot, npccodec.MediaType) } if len(item.Content) > MaxBytes { return nil, fmt.Errorf("reference slot %q item is %d bytes, limit %d", ReferenceSlot, len(item.Content), MaxBytes) } codec := npccodec.New() value, err := codec.Decode(item.Content) if err != nil { return nil, fmt.Errorf("decode NPC registry: invalid approved NPC JSON") } if issues := identity.ValidateList(value); len(issues) > 0 { return nil, fmt.Errorf("%s", formatIdentityIssues(issues)) } content, err := codec.Encode(value) if err != nil { return nil, fmt.Errorf("encode canonical NPC registry: approved NPC value could not be encoded") } list := cloneNPCList(value) lookupByKey := make(map[string]int, len(list.NPCs)*2) for index, npc := range list.NPCs { lookupByKey[identity.ComparisonKey(npc.Name)] = index for _, alias := range npc.Aliases { lookupByKey[identity.ComparisonKey(alias)] = index } } digest := semanticDigest(content) return &Registry{ bound: true, list: list, canonical: append([]byte(nil), content...), digest: digest, promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, digest, ""), lookupByKey: lookupByKey, }, nil } // New is an alias for Resolve for callers constructing a prepared registry. func New(references contracts.ReferenceSet) (*Registry, error) { return Resolve(references) } // Bound reports whether an NPC reference was supplied and validated. func (r *Registry) Bound() bool { return r != nil && r.bound } // NPCs returns a defensive copy of the validated NPC records. func (r *Registry) NPCs() []dnd.NPC { if r == nil { return nil } return cloneNPCs(r.list.NPCs) } // List returns a defensive copy of the validated NPC list. func (r *Registry) List() dnd.NPCList { if r == nil { return dnd.NPCList{} } return cloneNPCList(r.list) } // CanonicalBytes returns a defensive copy of the canonical durable JSON. func (r *Registry) CanonicalBytes() []byte { if r == nil { return nil } return append([]byte(nil), r.canonical...) } // Digest returns the semantic SHA-256 digest of the canonical JSON, or an // empty string when the registry is unbound. func (r *Registry) Digest() string { if r == nil { return "" } return r.digest } // Count returns the number of validated NPC records. func (r *Registry) Count() int { if r == nil { return 0 } return len(r.list.NPCs) } // PromptInput returns the canonical registry as a content-safe prompt input. // Reference provenance is deliberately omitted. func (r *Registry) PromptInput() contracts.LLMInputMaterial { if r == nil { return contracts.LLMInputMaterial{} } return r.promptInput.Clone() } // Lookup returns the canonical NPC for an exact canonical-name or alias match // under the NPC identity comparison policy. func (r *Registry) Lookup(value string) (dnd.NPC, bool) { if r == nil { return dnd.NPC{}, false } index, ok := r.lookupByKey[identity.ComparisonKey(value)] if !ok { return dnd.NPC{}, false } return cloneNPC(r.list.NPCs[index]), true } func semanticDigest(content []byte) string { sum := sha256.Sum256(content) return "sha256:" + hex.EncodeToString(sum[:]) } func formatIdentityIssues(issues []identity.Issue) string { parts := make([]string, len(issues)) for index, issue := range issues { location := fmt.Sprintf("record %d", issue.RecordIndex) if issue.AliasIndex >= 0 { location += fmt.Sprintf(" alias %d", issue.AliasIndex) } parts[index] = fmt.Sprintf("%s at %s", issue.Code, location) } return diagnostics.Aggregate("validate NPC registry identity", parts) } func cloneNPCList(value dnd.NPCList) dnd.NPCList { return dnd.NPCList{NPCs: cloneNPCs(value.NPCs)} } func cloneNPCs(values []dnd.NPC) []dnd.NPC { if values == nil { return nil } cloned := make([]dnd.NPC, len(values)) for index, value := range values { cloned[index] = cloneNPC(value) } return cloned } func cloneNPC(value dnd.NPC) dnd.NPC { value.Aliases = append([]string(nil), value.Aliases...) value.Relationships = append([]dnd.NPCRelationship(nil), value.Relationships...) value.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...) return value }