// Package npcinteractions normalizes merged D&D NPC interaction candidates. package npcinteractions import ( "context" "fmt" "sort" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" interactionmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcinteractions" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" ) const ( Key = "dnd/npc-interactions" normalizationPolicy = "dnd.npc_interactions.normalize.v2" NormalizationPolicy = normalizationPolicy ReasonCodeNameCanonicalized = "npc_interaction_name_canonicalized" ReasonCodeSourceRefsNormalized = "source_references_normalized" ReasonCodeInteractionsReordered = "npc_interactions_reordered" ReasonCodeDuplicateCollapsed = "duplicate_npc_interaction_collapsed" ReasonCodeWarningsOmitted = "npc_interaction_normalization_warnings_omitted" ) const ( NPCRegistryReferenceSlot = npcregistry.ReferenceSlot NPCRegistryMaxBytes = npcregistry.MaxBytes ) var requiredCapabilities = []string{"merged"} var providedCapabilities = []string{"normalized"} var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{ Glossary: "Optional campaign glossary reference material used only for interaction disambiguation.", Party: "Optional party roster reference material used only for interaction disambiguation.", Players: "Optional player list reference material used only for interaction disambiguation.", Roster: "Deprecated alias for party roster reference material used only for interaction disambiguation.", } var _ contracts.Normalizer[dnd.NPCInteractionList] = (*Normalizer)(nil) var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil) type Options struct{} type Normalizer struct { npcResolver *npcregistry.Resolver } func New(_ Options, references ...contracts.ReferenceSet) (*Normalizer, error) { if len(references) > 1 { return nil, normalizerErrorf("at most one reference set may be supplied") } var referenceSet contracts.ReferenceSet if len(references) == 1 { referenceSet = references[0] } resolver, err := npcregistry.NewResolver(referenceSet) if err != nil { return nil, normalizerErrorf("prepare NPC registry: %w", err) } return &Normalizer{npcResolver: resolver}, nil } func (n *Normalizer) Key() string { return Key } func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return referenceSlots() } func (n *Normalizer) ManifestMetadata() map[string]any { if n == nil || n.npcResolver == nil { return nil } metadata := map[string]any{ "normalization_policy": normalizationPolicy, "identity_policy": identity.Policy, } seeded := n.npcResolver.Seeded() if seeded.Bound() { metadata["npc_registry_digest"] = seeded.Digest() metadata["npc_count"] = seeded.Count() } return metadata } func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { if n == nil || n.npcResolver == nil { return nil } return []pipeline.CheckpointFingerprint{ {Name: "normalization_policy", Value: normalizationPolicy}, {Name: "identity_policy", Value: identity.Policy}, {Name: "npc_registry", Value: n.npcResolver.Seeded().ProjectionDigest()}, } } func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCInteractionList]) (contracts.TypedNormalizeResult[dnd.NPCInteractionList], error) { if n == nil || n.npcResolver == nil { return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("normalizer must not be nil") } if ctx == nil { return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("context must not be nil") } if err := ctx.Err(); err != nil { return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("context error before normalize: %w", err) } registry, err := n.npcResolver.Resolve(req.References) if err != nil { return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("resolve NPC registry: %w", err) } if !registry.Bound() { return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("NPC registry reference is required") } index := source.NewDocumentIndex(req.Source) order := shared.NewSourceRefOrderFromIndex(index) value, warnings := normalizeList(req.MergeOutput.Value, index, order, registry) return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{Value: value, Warnings: warnings}, nil } type normalizedRecord struct { interaction dnd.NPCInteraction inputIndex int } type nameCanonicalization struct { from string to string } func normalizeList(input dnd.NPCInteractionList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCInteractionList, []contracts.Warning) { if input.Interactions == nil { return dnd.NPCInteractionList{}, nil } records := make([]normalizedRecord, len(input.Interactions)) warnings := make([]contracts.Warning, 0) for index, inputInteraction := range input.Interactions { interaction, nameChange, refsChanged := normalizeInteraction(inputInteraction, order, registry) records[index] = normalizedRecord{interaction: interaction, inputIndex: index} if nameChange != nil { warnings = append(warnings, contracts.Warning{ Scope: interactionScope(index), ReasonCode: ReasonCodeNameCanonicalized, Message: fmt.Sprintf("input index %d: NPC name canonicalized from %s to %s", index, diagnostics.Quote(nameChange.from), diagnostics.Quote(nameChange.to)), }) } if refsChanged { warnings = append(warnings, contracts.Warning{ Scope: interactionScope(index), ReasonCode: ReasonCodeSourceRefsNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputInteraction.SourceRefs), len(interaction.SourceRefs)), }) } } sort.SliceStable(records, func(left, right int) bool { return interactionmodel.Less(order, records[left].interaction, records[right].interaction) }) for position, record := range records { if position == record.inputIndex { continue } warnings = append(warnings, contracts.Warning{ Scope: interactionScope(record.inputIndex), ReasonCode: ReasonCodeInteractionsReordered, Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology", record.inputIndex, position), }) } output, duplicateWarnings := collapseDuplicates(records, documentIndex) warnings = append(warnings, duplicateWarnings...) return dnd.NPCInteractionList{Interactions: output}, diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted) } func normalizeInteraction(input dnd.NPCInteraction, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCInteraction, *nameCanonicalization, bool) { output := cloneInteraction(input) if canonical, ok := registry.Lookup(identity.NormalizeDisplay(input.Name)); ok { output.Name = canonical.Name } var nameChange *nameCanonicalization if input.Name != output.Name { nameChange = &nameCanonicalization{from: input.Name, to: output.Name} } output.SourceRefs = order.Canonicalize(input.SourceRefs) return output, nameChange, !interactionmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs) } func cloneInteraction(input dnd.NPCInteraction) dnd.NPCInteraction { output := input if input.SourceRefs != nil { output.SourceRefs = append([]source.SourceRef(nil), input.SourceRefs...) } return output } type duplicateGroup struct { retainedIndex int removed []int } func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.NPCInteraction, []contracts.Warning) { if len(records) == 0 { return make([]dnd.NPCInteraction, 0), nil } keep := make([]bool, len(records)) groups := make([]duplicateGroup, 0) groupByKey := make(map[string]int) for index, record := range records { if !interactionmodel.ValidSourceRefs(documentIndex, record.interaction.SourceRefs) { keep[index] = true continue } key := interactionmodel.ExactIdentity(record.interaction) groupIndex, exists := groupByKey[key] if !exists { groupByKey[key] = len(groups) groups = append(groups, duplicateGroup{retainedIndex: record.inputIndex}) keep[index] = true continue } groups[groupIndex].removed = append(groups[groupIndex].removed, record.inputIndex) } output := make([]dnd.NPCInteraction, 0, len(records)) for index, record := range records { if keep[index] { output = append(output, cloneInteraction(record.interaction)) } } warnings := make([]contracts.Warning, 0) for _, group := range groups { if len(group.removed) != 0 { warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed)) } } return output, warnings } func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { issues := make([]string, len(removed)) for index, removedIndex := range removed { issues[index] = fmt.Sprintf("removed input index %d", removedIndex) } return contracts.Warning{ Scope: interactionScope(retainedIndex), ReasonCode: ReasonCodeDuplicateCollapsed, Message: diagnostics.Aggregate( fmt.Sprintf("duplicate NPC interaction collapsed; retained input index %d", retainedIndex), issues), } } func interactionScope(index int) string { return fmt.Sprintf("interactions[%d]", index) } func referenceSlots() []contracts.ReferenceSlot { slots := shared.ReferenceSlots(referenceSlotDescriptions) slots = append(slots, contracts.ReferenceSlot{ Name: NPCRegistryReferenceSlot, Description: "Required normalized NPC registry used only for interaction identity grounding, never as interaction evidence.", Required: true, AcceptedMediaTypes: []string{"application/json"}, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, MaxBytes: NPCRegistryMaxBytes, }) sort.Slice(slots, func(left, right int) bool { return slots[left].Name < slots[right].Name }) return slots } func ModuleSpec() pipeline.ModuleSpec { return pipeline.ModuleSpec{ Key: Key, Stage: pipeline.StageNormalize, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), ArtifactKind: dnd.NPCInteractionListKind, ReferenceSlots: referenceSlots(), } } func Register(registry *pipeline.NormalizerRegistry) error { return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.NPCInteractionList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err } return New(options, request.References) }) } func DecodeOptions(options map[string]any) (Options, error) { if err := pipeline.RejectUnknownOptions(options); err != nil { return Options{}, normalizerErrorf("%w", err) } return Options{}, nil } func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err } func normalizerErrorf(format string, args ...any) error { return fmt.Errorf("dnd NPC interactions normalizer: "+format, args...) }