2026-08-05 18:38:22 +00:00
// Package npcregistry normalizes merged D&D non-player character records.
package npcregistry
2026-07-21 02:36:59 +00:00
import (
"context"
"fmt"
"reflect"
2026-07-26 01:36:32 +00:00
"sort"
2026-07-21 02:36:59 +00:00
"strconv"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
2026-08-09 16:38:43 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
2026-07-21 02:36:59 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
2026-07-24 14:20:10 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
2026-07-21 04:34:56 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
2026-07-21 02:36:59 +00:00
)
const (
2026-08-09 16:38:43 +00:00
Key = "dnd/npc-registry"
PromptID = "dnd.npc_registry.normalize"
PromptVersion = "v1"
2026-08-28 18:25:00 +00:00
normalizationPolicy = "dnd.npc_registry.normalize.v6"
2026-08-09 16:38:43 +00:00
NormalizationPolicy = normalizationPolicy
2026-07-26 01:36:32 +00:00
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
ReasonCodeNPCIDRecomputed = "npc_id_recomputed"
ReasonCodeSourceReferencesNormalized = "source_references_normalized"
ReasonCodeDuplicateNPCCollapsed = "duplicate_npc_collapsed"
ReasonCodeNPCSemanticProposalInvalid = "npc_semantic_proposal_invalid"
ReasonCodeNPCSemanticReconciliationExhausted = "npc_semantic_reconciliation_exhausted"
2026-07-21 02:36:59 +00:00
)
var requiredCapabilities = [ ] string { "merged" }
var providedCapabilities = [ ] string { "normalized" }
2026-08-05 18:25:04 +00:00
var _ contracts . Normalizer [ dnd . NPCRegistry ] = ( * Normalizer ) ( nil )
2026-07-21 02:36:59 +00:00
var _ contracts . ManifestMetadataProvider = ( * Normalizer ) ( nil )
var _ pipeline . CheckpointFingerprintProvider = ( * Normalizer ) ( nil )
type Options struct { }
2026-07-26 01:36:32 +00:00
type Normalizer struct {
2026-08-09 16:38:43 +00:00
engine * semanticreconcile . Engine
2026-07-26 01:36:32 +00:00
}
func New ( llmClient contracts . StructuredLLMClient , _ Options ) ( * Normalizer , error ) {
if llmClient == nil {
return nil , normalizerErrorf ( "LLM client must not be nil" )
}
2026-07-28 16:42:41 +00:00
promptSHA , err := promptAssetMetadata ( )
2026-07-26 01:36:32 +00:00
if err != nil {
return nil , normalizerErrorf ( "load prompt metadata: %w" , err )
}
2026-08-09 16:38:43 +00:00
engine , err := semanticreconcile . NewEngine ( llmClient , semanticreconcile . PromptSpec {
ID : PromptID , Version : PromptVersion , SHA256 : promptSHA ,
} , semanticreconcile . DefaultLimits ( ) )
2026-07-26 01:36:32 +00:00
if err != nil {
2026-08-09 16:38:43 +00:00
return nil , normalizerErrorf ( "construct semantic reconciliation engine: %w" , err )
2026-07-26 01:36:32 +00:00
}
2026-08-09 16:38:43 +00:00
return & Normalizer { engine : engine } , nil
2026-07-26 01:36:32 +00:00
}
2026-07-22 18:51:26 +00:00
func ( n * Normalizer ) Key ( ) string { return Key }
2026-07-21 02:36:59 +00:00
func ( n * Normalizer ) ReferenceSlots ( ) [ ] contracts . ReferenceSlot { return nil }
func ( n * Normalizer ) ManifestMetadata ( ) map [ string ] any {
2026-08-09 16:38:43 +00:00
if n == nil || n . engine == nil {
2026-07-21 02:36:59 +00:00
return nil
}
2026-08-09 16:38:43 +00:00
metadata := n . engine . ManifestMetadata ( )
metadata [ "identity_policy" ] = identity . Policy
metadata [ "normalization_policy" ] = normalizationPolicy
return metadata
2026-07-21 02:36:59 +00:00
}
func ( n * Normalizer ) CheckpointFingerprints ( ) [ ] pipeline . CheckpointFingerprint {
2026-08-09 16:38:43 +00:00
if n == nil || n . engine == nil {
2026-07-21 02:36:59 +00:00
return nil
}
2026-08-09 16:38:43 +00:00
fingerprints := n . engine . CheckpointFingerprints ( )
return append ( fingerprints ,
pipeline . CheckpointFingerprint { Name : "identity_policy" , Value : identity . Policy } ,
pipeline . CheckpointFingerprint { Name : "normalization_policy" , Value : normalizationPolicy } ,
)
2026-07-21 02:36:59 +00:00
}
2026-08-05 18:25:04 +00:00
func ( n * Normalizer ) Normalize ( ctx context . Context , req contracts . TypedNormalizeRequest [ dnd . NPCRegistry ] ) ( contracts . TypedNormalizeResult [ dnd . NPCRegistry ] , error ) {
2026-07-21 02:36:59 +00:00
if n == nil {
2026-08-05 18:25:04 +00:00
return contracts . TypedNormalizeResult [ dnd . NPCRegistry ] { } , normalizerErrorf ( "normalizer must not be nil" )
2026-07-21 02:36:59 +00:00
}
2026-08-09 16:38:43 +00:00
if n . engine == nil {
return contracts . TypedNormalizeResult [ dnd . NPCRegistry ] { } , normalizerErrorf ( "semantic reconciliation engine must not be nil" )
2026-07-26 01:36:32 +00:00
}
2026-07-21 02:36:59 +00:00
if ctx == nil {
2026-08-05 18:25:04 +00:00
return contracts . TypedNormalizeResult [ dnd . NPCRegistry ] { } , normalizerErrorf ( "context must not be nil" )
2026-07-21 02:36:59 +00:00
}
if err := ctx . Err ( ) ; err != nil {
2026-08-05 18:25:04 +00:00
return contracts . TypedNormalizeResult [ dnd . NPCRegistry ] { } , normalizerErrorf ( "context error before normalize: %w" , err )
2026-07-21 02:36:59 +00:00
}
2026-08-09 18:30:32 +00:00
if req . Source == nil {
return contracts . TypedNormalizeResult [ dnd . NPCRegistry ] { } , normalizerErrorf ( "source document must not be nil" )
}
2026-07-26 01:36:32 +00:00
2026-07-24 14:20:10 +00:00
order := shared . NewSourceRefOrder ( req . Source )
2026-08-27 15:57:29 +00:00
records , findings := preprocessRecords ( req . MergeOutput . Value , order )
2026-07-26 01:36:32 +00:00
deterministic := recordList ( records )
2026-08-09 18:30:32 +00:00
if len ( records ) < 2 {
2026-08-27 15:57:29 +00:00
return normalizationResult ( deterministic , findings , nil )
2026-07-26 01:36:32 +00:00
}
2026-08-09 16:38:43 +00:00
candidates , envelopes , err := reconciliationInputs ( records )
if err != nil {
return contracts . TypedNormalizeResult [ dnd . NPCRegistry ] { } , normalizerErrorf ( "prepare semantic reconciliation inputs: %w" , err )
}
reconciliation , err := n . engine . Reconcile ( ctx , semanticreconcile . Request {
StageName : Key , Source : req . Source , Candidates : candidates ,
2026-08-27 00:08:05 +00:00
ProfileID : req . LLMProfile , SessionID : req . SessionID , StructuredOutputRepairAttempts : req . StructuredOutputRepairAttempts , Correction : req . Correction ,
2026-08-09 16:38:43 +00:00
} )
if err != nil {
return contracts . TypedNormalizeResult [ dnd . NPCRegistry ] { } , normalizerErrorf ( "reconcile semantic duplicates: %w" , err )
}
switch reconciliation . Disposition ( ) {
case semanticreconcile . SkippedInsufficientCandidates :
2026-08-27 15:57:29 +00:00
return normalizationResult ( deterministic , findings , nil )
2026-08-09 16:38:43 +00:00
case semanticreconcile . SkippedLimitExceeded :
2026-08-28 18:25:00 +00:00
return fallbackResult ( deterministic , findings , semanticFallbackFinding ( - 1 , "the candidate set exceeded the configured semantic-reconciliation limits" ) )
2026-08-09 16:38:43 +00:00
case semanticreconcile . RetryableInvalidStructuredOutput :
2026-08-27 15:57:29 +00:00
return n . invalidStructuredResult ( deterministic , findings )
2026-08-09 16:38:43 +00:00
case semanticreconcile . Complete , semanticreconcile . RetryableDiscardedProposalGroups :
default :
return contracts . TypedNormalizeResult [ dnd . NPCRegistry ] { } , normalizerErrorf ( "unknown semantic reconciliation disposition %d" , reconciliation . Disposition ( ) )
2026-07-26 01:36:32 +00:00
}
2026-08-27 15:57:29 +00:00
applied , semanticFindings , err := applyReconciliationPlan ( reconciliation . Plan ( ) , records , envelopes , order )
2026-08-09 16:38:43 +00:00
if err != nil {
return contracts . TypedNormalizeResult [ dnd . NPCRegistry ] { } , normalizerErrorf ( "apply semantic reconciliation plan: %w" , err )
}
2026-08-27 15:57:29 +00:00
findings = append ( findings , semanticFindings ... )
2026-08-09 16:38:43 +00:00
if reconciliation . Disposition ( ) == semanticreconcile . Complete {
2026-08-27 15:57:29 +00:00
return normalizationResult ( recordList ( applied ) , findings , reconciliation . ModelCandidate ( ) )
2026-07-26 01:36:32 +00:00
}
2026-08-27 15:57:29 +00:00
return retryResult ( recordList ( applied ) , findings , reconciliation )
2026-07-26 01:36:32 +00:00
}
2026-08-27 16:40:17 +00:00
func ( n * Normalizer ) invalidStructuredResult ( value dnd . NPCRegistry , findings [ ] diagnostics . Finding ) ( contracts . TypedNormalizeResult [ dnd . NPCRegistry ] , error ) {
2026-08-28 18:25:00 +00:00
return retryResultWithFallback ( value , findings , nil , "" , "semantic proposal requires retry: invalid structured output" , semanticFallbackFinding ( - 1 , "the final model response did not satisfy the structured-output contract" ) )
2026-07-26 01:36:32 +00:00
}
2026-08-27 16:40:17 +00:00
func retryResult ( value dnd . NPCRegistry , findings [ ] diagnostics . Finding , reconciliation semanticreconcile . Result ) ( contracts . TypedNormalizeResult [ dnd . NPCRegistry ] , error ) {
2026-08-28 18:25:00 +00:00
issues := reconciliation . Issues ( )
2026-08-29 03:16:24 +00:00
correctionDetails , err := reconciliation . CorrectionDetails ( )
2026-08-28 18:25:00 +00:00
if err != nil {
return contracts . TypedNormalizeResult [ dnd . NPCRegistry ] { } , normalizerErrorf ( "build semantic correction details: %w" , err )
}
2026-08-29 03:16:24 +00:00
correctionGuidance , err := reconciliation . CorrectionGuidance ( )
2026-08-28 18:25:00 +00:00
if err != nil {
return contracts . TypedNormalizeResult [ dnd . NPCRegistry ] { } , normalizerErrorf ( "build semantic correction guidance: %w" , err )
}
return retryResultWithFallback ( value , findings , reconciliation . ModelCandidate ( ) , correctionGuidance , diagnostics . Aggregate ( "semantic proposal requires retry" , semanticreconcile . IssueDetails ( issues ) ) , semanticFallbackFinding ( reconciliation . DiscardedGroupCount ( ) , correctionDetails ... ) )
2026-07-26 01:36:32 +00:00
}
2026-08-28 18:25:00 +00:00
func semanticFallbackFinding ( discardedGroups int , details ... string ) diagnostics . Finding {
message := "semantic reconciliation could not be completed; all affected candidates were preserved separately"
2026-07-26 01:36:32 +00:00
if discardedGroups >= 0 {
2026-08-28 18:25:00 +00:00
message = fmt . Sprintf ( "%d proposal group(s) omitted from the final semantic proposal; all affected candidates were preserved separately" , discardedGroups )
}
if len ( details ) > 0 {
message = diagnostics . Aggregate ( message , details )
2026-07-26 01:36:32 +00:00
}
2026-08-27 16:40:17 +00:00
return diagnostics . Finding { Scope : "npcs" , ReasonCode : ReasonCodeNPCSemanticReconciliationExhausted , Message : message }
2026-07-26 01:36:32 +00:00
}
2026-08-27 16:40:17 +00:00
func normalizationResult ( value dnd . NPCRegistry , findings [ ] diagnostics . Finding , candidate * contracts . ModelCandidate ) ( contracts . TypedNormalizeResult [ dnd . NPCRegistry ] , error ) {
2026-08-27 15:57:29 +00:00
diagnosticGroups , err := diagnostics . Collect ( findings , contracts . DiagnosticDispositionObservation , contracts . DiagnosticCategoryNormalization )
if err != nil {
return contracts . TypedNormalizeResult [ dnd . NPCRegistry ] { } , normalizerErrorf ( "collect normalization diagnostics: %w" , err )
}
return contracts . TypedNormalizeResult [ dnd . NPCRegistry ] { Value : value , Diagnostics : diagnosticGroups , ModelCandidate : candidate } , nil
2026-07-26 01:36:32 +00:00
}
2026-08-27 16:40:17 +00:00
func fallbackResult ( value dnd . NPCRegistry , findings [ ] diagnostics . Finding , fallback diagnostics . Finding ) ( contracts . TypedNormalizeResult [ dnd . NPCRegistry ] , error ) {
2026-08-27 15:57:29 +00:00
result , err := normalizationResult ( value , findings , nil )
if err != nil {
return contracts . TypedNormalizeResult [ dnd . NPCRegistry ] { } , err
2026-07-26 01:36:32 +00:00
}
2026-08-27 16:40:17 +00:00
fallbackGroups , err := diagnostics . Collect ( [ ] diagnostics . Finding { fallback } , contracts . DiagnosticDispositionWarning , contracts . DiagnosticCategoryFallback )
2026-08-27 15:57:29 +00:00
if err != nil {
return contracts . TypedNormalizeResult [ dnd . NPCRegistry ] { } , normalizerErrorf ( "collect fallback diagnostic: %w" , err )
2026-07-26 01:36:32 +00:00
}
2026-08-27 15:57:29 +00:00
result . Diagnostics = append ( result . Diagnostics , fallbackGroups ... )
return result , nil
2026-07-21 02:36:59 +00:00
}
2026-08-28 18:25:00 +00:00
func retryResultWithFallback ( value dnd . NPCRegistry , findings [ ] diagnostics . Finding , candidate * contracts . ModelCandidate , correctionGuidance , message string , fallback diagnostics . Finding ) ( contracts . TypedNormalizeResult [ dnd . NPCRegistry ] , error ) {
2026-08-27 15:57:29 +00:00
result , err := normalizationResult ( value , findings , candidate )
if err != nil {
return contracts . TypedNormalizeResult [ dnd . NPCRegistry ] { } , err
}
2026-08-27 16:40:17 +00:00
fallbackGroups , err := diagnostics . Collect ( [ ] diagnostics . Finding { fallback } , contracts . DiagnosticDispositionWarning , contracts . DiagnosticCategoryFallback )
2026-08-27 15:57:29 +00:00
if err != nil {
return contracts . TypedNormalizeResult [ dnd . NPCRegistry ] { } , normalizerErrorf ( "collect fallback diagnostic: %w" , err )
}
2026-08-28 18:25:00 +00:00
result . Retry = & contracts . NormalizeRetry { ReasonCode : ReasonCodeNPCSemanticProposalInvalid , Message : message , CorrectionGuidance : correctionGuidance , FallbackDiagnostics : fallbackGroups }
2026-08-27 15:57:29 +00:00
return result , nil
2026-08-09 16:38:43 +00:00
}
2026-07-21 02:36:59 +00:00
type normalizedRecord struct {
2026-07-26 01:36:32 +00:00
npc dnd . NPC
inputIndexes [ ] int
earliest int
2026-07-21 02:36:59 +00:00
}
2026-08-27 16:40:17 +00:00
func preprocessRecords ( input dnd . NPCRegistry , order shared . SourceRefOrder ) ( [ ] normalizedRecord , [ ] diagnostics . Finding ) {
2026-07-21 02:36:59 +00:00
if input . NPCs == nil {
2026-07-26 01:36:32 +00:00
return nil , nil
2026-07-21 02:36:59 +00:00
}
records := make ( [ ] normalizedRecord , len ( input . NPCs ) )
2026-08-27 18:41:59 +00:00
findings := make ( [ ] diagnostics . Finding , 0 )
2026-07-21 02:36:59 +00:00
for index , inputNPC := range input . NPCs {
2026-07-24 14:20:10 +00:00
npc , fieldsChanged , referencesChanged := normalizeRecord ( inputNPC , order )
2026-07-26 01:36:32 +00:00
records [ index ] = normalizedRecord { npc : npc , inputIndexes : [ ] int { index } , earliest : index }
2026-07-21 02:36:59 +00:00
if fieldsChanged {
2026-08-27 18:41:59 +00:00
findings = append ( findings , diagnostics . Finding { Scope : npcScope ( index ) , ReasonCode : ReasonCodeNPCFieldsNormalized , Message : fmt . Sprintf ( "input index %d: NPC name normalized for %s" , index , diagnostics . Quote ( inputNPC . Name ) ) } )
2026-07-21 02:36:59 +00:00
}
if referencesChanged {
2026-08-27 18:41:59 +00:00
findings = append ( findings , diagnostics . Finding { Scope : npcScope ( index ) , ReasonCode : ReasonCodeSourceReferencesNormalized , Message : fmt . Sprintf ( "input index %d: source references normalized (original count %d, final count %d)" , index , len ( inputNPC . SourceRefs ) , len ( npc . SourceRefs ) ) } )
2026-07-21 02:36:59 +00:00
}
if inputNPC . ID != npc . ID {
2026-08-27 18:41:59 +00:00
findings = append ( findings , diagnostics . Finding { Scope : npcScope ( index ) , ReasonCode : ReasonCodeNPCIDRecomputed , Message : fmt . Sprintf ( "input index %d: NPC ID recomputed from %s" , index , diagnostics . Quote ( npc . Name ) ) } )
2026-07-21 02:36:59 +00:00
}
}
2026-07-22 18:51:26 +00:00
groups := canonicalNameGroups ( records )
2026-07-26 01:36:32 +00:00
output := make ( [ ] normalizedRecord , 0 , len ( groups ) )
2026-07-22 18:51:26 +00:00
for _ , members := range groups {
2026-07-24 14:20:10 +00:00
consolidated , referencesChanged := consolidate ( records , members , order )
2026-07-26 01:36:32 +00:00
output = append ( output , consolidated )
retainedIndex := consolidated . earliest
2026-07-22 18:51:26 +00:00
if referencesChanged {
2026-08-27 18:41:59 +00:00
findings = append ( findings , diagnostics . Finding { Scope : npcScope ( retainedIndex ) , ReasonCode : ReasonCodeSourceReferencesNormalized , Message : fmt . Sprintf ( "input index %d: source references normalized during identity consolidation (final count %d)" , retainedIndex , len ( consolidated . npc . SourceRefs ) ) } )
2026-07-21 02:36:59 +00:00
}
if len ( members ) > 1 {
2026-08-27 18:41:59 +00:00
findings = append ( findings , duplicateFinding ( retainedIndex , memberInputIndexes ( records , members [ 1 : ] ) ) )
2026-07-21 02:36:59 +00:00
}
}
2026-08-27 18:41:59 +00:00
return output , findings
2026-07-21 02:36:59 +00:00
}
2026-07-24 14:20:10 +00:00
func normalizeRecord ( input dnd . NPC , order shared . SourceRefOrder ) ( dnd . NPC , bool , bool ) {
2026-07-21 02:36:59 +00:00
output := cloneNPC ( input )
output . Name = identity . NormalizeDisplay ( input . Name )
2026-07-25 13:01:31 +00:00
output . SourceRefs = order . Canonicalize ( input . SourceRefs )
2026-07-21 02:36:59 +00:00
output . ID = identity . DeriveID ( output . Name )
2026-07-22 18:51:26 +00:00
return output , input . Name != output . Name , ! reflect . DeepEqual ( input . SourceRefs , output . SourceRefs )
2026-07-21 02:36:59 +00:00
}
func cloneNPC ( input dnd . NPC ) dnd . NPC {
2026-07-22 18:51:26 +00:00
input . SourceRefs = cloneSourceRefs ( input . SourceRefs )
return input
}
func canonicalNameGroups ( records [ ] normalizedRecord ) [ ] [ ] int {
groups := make ( [ ] [ ] int , 0 , len ( records ) )
ownerByKey := make ( map [ string ] int , len ( records ) )
for index , record := range records {
key := identity . ComparisonKey ( record . npc . Name )
if key != "" {
if groupIndex , ok := ownerByKey [ key ] ; ok {
groups [ groupIndex ] = append ( groups [ groupIndex ] , index )
continue
2026-07-21 02:36:59 +00:00
}
2026-07-22 18:51:26 +00:00
ownerByKey [ key ] = len ( groups )
2026-07-21 02:36:59 +00:00
}
2026-07-22 18:51:26 +00:00
groups = append ( groups , [ ] int { index } )
2026-07-21 02:36:59 +00:00
}
2026-07-22 18:51:26 +00:00
return groups
2026-07-21 02:36:59 +00:00
}
2026-07-26 01:36:32 +00:00
func consolidate ( records [ ] normalizedRecord , members [ ] int , order shared . SourceRefOrder ) ( normalizedRecord , bool ) {
output := cloneRecord ( records [ members [ 0 ] ] )
originalRefs := cloneSourceRefs ( output . npc . SourceRefs )
2026-07-21 02:36:59 +00:00
for _ , member := range members [ 1 : ] {
2026-07-26 01:36:32 +00:00
output . npc . SourceRefs = append ( output . npc . SourceRefs , records [ member ] . npc . SourceRefs ... )
output . inputIndexes = append ( output . inputIndexes , records [ member ] . inputIndexes ... )
if records [ member ] . earliest < output . earliest {
output . earliest = records [ member ] . earliest
}
2026-07-21 02:36:59 +00:00
}
2026-07-26 01:36:32 +00:00
output . inputIndexes = sortedUniqueIndexes ( output . inputIndexes )
output . npc . SourceRefs = order . Canonicalize ( output . npc . SourceRefs )
output . npc . ID = identity . DeriveID ( output . npc . Name )
return output , ! reflect . DeepEqual ( originalRefs , output . npc . SourceRefs )
}
func cloneRecord ( input normalizedRecord ) normalizedRecord {
input . npc = cloneNPC ( input . npc )
input . inputIndexes = append ( [ ] int ( nil ) , input . inputIndexes ... )
return input
}
func memberInputIndexes ( records [ ] normalizedRecord , members [ ] int ) [ ] int {
indexes := make ( [ ] int , 0 , len ( members ) )
for _ , member := range members {
indexes = append ( indexes , records [ member ] . inputIndexes ... )
}
return sortedUniqueIndexes ( indexes )
}
func sortedUniqueIndexes ( indexes [ ] int ) [ ] int {
if len ( indexes ) == 0 {
return nil
}
out := append ( [ ] int ( nil ) , indexes ... )
sort . Ints ( out )
write := 1
for _ , index := range out [ 1 : ] {
if index != out [ write - 1 ] {
out [ write ] = index
write ++
}
}
return out [ : write ]
}
func recordValues ( records [ ] normalizedRecord ) [ ] dnd . NPC {
if records == nil {
return nil
}
values := make ( [ ] dnd . NPC , len ( records ) )
for index , record := range records {
values [ index ] = cloneNPC ( record . npc )
}
return values
}
2026-08-05 18:25:04 +00:00
func recordList ( records [ ] normalizedRecord ) dnd . NPCRegistry {
2026-07-26 01:36:32 +00:00
if records == nil {
2026-08-05 18:25:04 +00:00
return dnd . NPCRegistry { }
2026-07-26 01:36:32 +00:00
}
2026-08-05 18:25:04 +00:00
return dnd . NPCRegistry { NPCs : recordValues ( records ) }
2026-07-21 02:36:59 +00:00
}
2026-07-22 18:51:26 +00:00
func cloneSourceRefs ( input [ ] source . SourceRef ) [ ] source . SourceRef {
2026-07-21 02:36:59 +00:00
if input == nil {
return nil
}
2026-07-22 18:51:26 +00:00
return append ( [ ] source . SourceRef ( nil ) , input ... )
2026-07-21 02:36:59 +00:00
}
2026-08-27 18:41:59 +00:00
func duplicateFinding ( retainedIndex int , removed [ ] int ) diagnostics . Finding {
2026-07-21 02:36:59 +00:00
const maxDisplayedIndices = 20
displayed := removed
if len ( displayed ) > maxDisplayedIndices {
displayed = displayed [ : maxDisplayedIndices ]
}
indices := make ( [ ] string , len ( displayed ) )
for index , removedIndex := range displayed {
indices [ index ] = strconv . Itoa ( removedIndex )
}
message := fmt . Sprintf ( "retained input index %d; removed input indices [%s]" , retainedIndex , strings . Join ( indices , ", " ) )
if omitted := len ( removed ) - len ( displayed ) ; omitted > 0 {
message += fmt . Sprintf ( "; %d additional removed input indices omitted" , omitted )
}
2026-08-27 16:40:17 +00:00
return diagnostics . Finding { Scope : npcScope ( retainedIndex ) , ReasonCode : ReasonCodeDuplicateNPCCollapsed , Message : message }
2026-07-21 02:36:59 +00:00
}
func npcScope ( index int ) string { return fmt . Sprintf ( "npcs[%d]" , index ) }
func ModuleSpec ( ) pipeline . ModuleSpec {
2026-08-27 00:08:05 +00:00
return pipeline . ModuleSpec { Key : Key , Stage : pipeline . StageNormalize , ExecutionClass : contracts . ExecutionClassLLMBacked , CorrectionProtocol : contracts . CorrectionProtocolSingleResponseV1 , Requires : append ( [ ] string ( nil ) , requiredCapabilities ... ) , Provides : append ( [ ] string ( nil ) , providedCapabilities ... ) , ArtifactKind : dnd . NPCRegistryKind }
2026-07-21 02:36:59 +00:00
}
func Register ( registry * pipeline . NormalizerRegistry ) error {
2026-08-05 18:25:04 +00:00
return pipeline . RegisterNormalizerBuilder ( registry , ModuleSpec ( ) , validateOptions , func ( request pipeline . BuildRequest ) ( contracts . Normalizer [ dnd . NPCRegistry ] , error ) {
2026-07-21 02:36:59 +00:00
options , err := DecodeOptions ( request . Options )
if err != nil {
return nil , err
}
2026-07-26 01:36:32 +00:00
return New ( request . Dependencies . LLM , options )
2026-07-21 02:36:59 +00:00
} )
}
2026-07-22 18:51:26 +00:00
func validateOptions ( options map [ string ] any ) error { _ , err := DecodeOptions ( options ) ; return err }
2026-07-21 02:36:59 +00:00
func DecodeOptions ( options map [ string ] any ) ( Options , error ) {
if err := pipeline . RejectUnknownOptions ( options ) ; err != nil {
return Options { } , normalizerErrorf ( "%w" , err )
}
return Options { } , nil
}
func normalizerErrorf ( format string , args ... any ) error {
2026-08-05 18:38:22 +00:00
return fmt . Errorf ( "dnd npc registry normalizer: " + format , args ... )
2026-07-21 02:36:59 +00:00
}