Move NPC registry to canonical namespace

This commit is contained in:
2026-08-05 18:38:22 +00:00
parent 9653e06297
commit 3f4a1f2647
94 changed files with 320 additions and 320 deletions

View File

@@ -0,0 +1,389 @@
// Package npcregistry normalizes merged D&D non-player character records.
package npcregistry
import (
"context"
"errors"
"fmt"
"reflect"
"sort"
"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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
)
const (
Key = "dnd/npc-registry"
PromptID = "dnd.npc_registry.normalize"
normalizationPolicy = "dnd.npc_registry.normalize.v3"
semanticContextPolicy = "dnd.entity_reconcile.context.v1"
NormalizationPolicy = normalizationPolicy
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"
ReasonCodeNPCNormalizationWarningsOmitted = "npc_normalization_warnings_omitted"
)
var requiredCapabilities = []string{"merged"}
var providedCapabilities = []string{"normalized"}
var _ contracts.Normalizer[dnd.NPCRegistry] = (*Normalizer)(nil)
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
type Options struct{}
type Normalizer struct {
llm contracts.StructuredLLMClient
promptSHA string
responseSchemaSHA string
}
func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error) {
if llmClient == nil {
return nil, normalizerErrorf("LLM client must not be nil")
}
promptSHA, err := promptAssetMetadata()
if err != nil {
return nil, normalizerErrorf("load prompt metadata: %w", err)
}
responseSchema, err := entityreconcile.LoadResponseSchema()
if err != nil {
return nil, normalizerErrorf("load response schema: %w", err)
}
return &Normalizer{llm: llmClient, promptSHA: promptSHA, responseSchemaSHA: responseSchema.SHA256}, nil
}
func (n *Normalizer) Key() string { return Key }
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (n *Normalizer) ManifestMetadata() map[string]any {
if n == nil {
return nil
}
return map[string]any{
"prompt_id": PromptID,
"prompt_version": entityreconcile.SchemaVersion,
"prompt_sha256": n.promptSHA,
"response_schema_key": string(entityreconcile.ResponseSchemaKey),
"response_schema_id": entityreconcile.ResponseSchemaID,
"response_schema_name": entityreconcile.ResponseSchemaName,
"response_schema_version": entityreconcile.SchemaVersion,
"response_schema_sha256": n.responseSchemaSHA,
"identity_policy": identity.Policy,
"normalization_policy": normalizationPolicy,
"semantic_context_policy": semanticContextPolicy,
"semantic_context_radius": semanticContextRadius,
}
}
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if n == nil {
return nil
}
return []pipeline.CheckpointFingerprint{
{Name: "prompt", Value: n.promptSHA},
{Name: "response_schema", Value: n.responseSchemaSHA},
{Name: "identity_policy", Value: identity.Policy},
{Name: "normalization_policy", Value: normalizationPolicy},
{Name: "semantic_context_policy", Value: fmt.Sprintf("%s:%d", semanticContextPolicy, semanticContextRadius)},
}
}
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCRegistry]) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) {
if n == nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("normalizer must not be nil")
}
if n.llm == nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("LLM client must not be nil")
}
if ctx == nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("context must not be nil")
}
if err := ctx.Err(); err != nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("context error before normalize: %w", err)
}
order := shared.NewSourceRefOrder(req.Source)
records, warnings := preprocessRecords(req.MergeOutput.Value, order)
deterministic := recordList(records)
materials, ready, err := entityreconcile.BuildContext(req.Source, reconciliationCandidates(records), semanticContextRadius)
if err != nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("build semantic context: %w", err)
}
if !ready {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
}
var response entityreconcile.ProposalResponse
if _, err := n.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: Key, PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion,
ProfileID: req.LLMProfile, SessionID: req.SessionID,
Inputs: contracts.LLMInputSet{"candidates": materials.Candidates, "transcript": materials.Transcript},
}, &response); err != nil {
if errors.Is(err, contracts.ErrInvalidStructuredOutput) {
return n.invalidStructuredResult(deterministic, warnings), nil
}
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("complete structured output: %w", err)
}
assessment := materials.Assess(response)
applied, semanticWarnings := applySafeGroups(records, reconciliationGroups(assessment, materials.CandidateKeys()), order)
warnings = append(warnings, semanticWarnings...)
if assessment.DiscardedGroups() == 0 {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: recordList(applied), Warnings: limitWarnings(warnings)}, nil
}
return retryResult(recordList(applied), warnings, assessment), nil
}
func (n *Normalizer) invalidStructuredResult(value dnd.NPCRegistry, warnings []contracts.Warning) contracts.TypedNormalizeResult[dnd.NPCRegistry] {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{
Value: value,
Warnings: limitWarningsForRetry(warnings),
Retry: &contracts.NormalizeRetry{
ReasonCode: ReasonCodeNPCSemanticProposalInvalid,
Message: "semantic proposal requires retry: invalid structured output",
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(-1)},
},
}
}
func retryResult(value dnd.NPCRegistry, warnings []contracts.Warning, assessment entityreconcile.Assessment) contracts.TypedNormalizeResult[dnd.NPCRegistry] {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{
Value: value,
Warnings: limitWarningsForRetry(warnings),
Retry: &contracts.NormalizeRetry{
ReasonCode: ReasonCodeNPCSemanticProposalInvalid,
Message: diagnostics.Aggregate("semantic proposal requires retry", reconciliationIssues(assessment)),
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(assessment.DiscardedGroups())},
},
}
}
func semanticFallbackWarning(discardedGroups int) contracts.Warning {
message := "semantic proposal could not be applied"
if discardedGroups >= 0 {
message = fmt.Sprintf("%d proposal group(s) omitted after semantic proposal retry exhaustion", discardedGroups)
}
return contracts.Warning{Scope: "npcs", ReasonCode: ReasonCodeNPCSemanticReconciliationExhausted, Message: message}
}
func limitWarnings(warnings []contracts.Warning) []contracts.Warning {
return diagnostics.LimitWarnings(warnings, "npcs", ReasonCodeNPCNormalizationWarningsOmitted)
}
func limitWarningsForRetry(warnings []contracts.Warning) []contracts.Warning {
if warnings == nil {
return nil
}
if len(warnings) < diagnostics.MaxWarnings {
return append([]contracts.Warning(nil), warnings...)
}
displayed := diagnostics.MaxWarnings - 2
bounded := append([]contracts.Warning(nil), warnings[:displayed]...)
return append(bounded, contracts.Warning{
Scope: "npcs", ReasonCode: ReasonCodeNPCNormalizationWarningsOmitted,
Message: fmt.Sprintf("%d additional warning(s) omitted", len(warnings)-displayed),
})
}
type normalizedRecord struct {
npc dnd.NPC
inputIndexes []int
earliest int
}
func preprocessRecords(input dnd.NPCRegistry, order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning) {
if input.NPCs == nil {
return nil, nil
}
records := make([]normalizedRecord, len(input.NPCs))
warnings := make([]contracts.Warning, 0)
for index, inputNPC := range input.NPCs {
npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC, order)
records[index] = normalizedRecord{npc: npc, inputIndexes: []int{index}, earliest: index}
if fieldsChanged {
warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeNPCFieldsNormalized, Message: fmt.Sprintf("input index %d: NPC name normalized for %s", index, diagnostics.Quote(inputNPC.Name))})
}
if referencesChanged {
warnings = append(warnings, contracts.Warning{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))})
}
if inputNPC.ID != npc.ID {
warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeNPCIDRecomputed, Message: fmt.Sprintf("input index %d: NPC ID recomputed from %s", index, diagnostics.Quote(npc.Name))})
}
}
groups := canonicalNameGroups(records)
output := make([]normalizedRecord, 0, len(groups))
for _, members := range groups {
consolidated, referencesChanged := consolidate(records, members, order)
output = append(output, consolidated)
retainedIndex := consolidated.earliest
if referencesChanged {
warnings = append(warnings, contracts.Warning{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))})
}
if len(members) > 1 {
warnings = append(warnings, duplicateWarning(retainedIndex, memberInputIndexes(records, members[1:])))
}
}
return output, warnings
}
func normalizeRecord(input dnd.NPC, order shared.SourceRefOrder) (dnd.NPC, bool, bool) {
output := cloneNPC(input)
output.Name = identity.NormalizeDisplay(input.Name)
output.SourceRefs = order.Canonicalize(input.SourceRefs)
output.ID = identity.DeriveID(output.Name)
return output, input.Name != output.Name, !reflect.DeepEqual(input.SourceRefs, output.SourceRefs)
}
func cloneNPC(input dnd.NPC) dnd.NPC {
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
}
ownerByKey[key] = len(groups)
}
groups = append(groups, []int{index})
}
return groups
}
func consolidate(records []normalizedRecord, members []int, order shared.SourceRefOrder) (normalizedRecord, bool) {
output := cloneRecord(records[members[0]])
originalRefs := cloneSourceRefs(output.npc.SourceRefs)
for _, member := range members[1:] {
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
}
}
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
}
func recordList(records []normalizedRecord) dnd.NPCRegistry {
if records == nil {
return dnd.NPCRegistry{}
}
return dnd.NPCRegistry{NPCs: recordValues(records)}
}
func cloneSourceRefs(input []source.SourceRef) []source.SourceRef {
if input == nil {
return nil
}
return append([]source.SourceRef(nil), input...)
}
func duplicateWarning(retainedIndex int, removed []int) contracts.Warning {
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)
}
return contracts.Warning{Scope: npcScope(retainedIndex), ReasonCode: ReasonCodeDuplicateNPCCollapsed, Message: message}
}
func npcScope(index int) string { return fmt.Sprintf("npcs[%d]", index) }
func ModuleSpec() pipeline.ModuleSpec {
return pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageNormalize, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), ArtifactKind: dnd.NPCRegistryKind}
}
func Register(registry *pipeline.NormalizerRegistry) error {
return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.NPCRegistry], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(request.Dependencies.LLM, options)
})
}
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
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 {
return fmt.Errorf("dnd npc registry normalizer: "+format, args...)
}

View File

@@ -0,0 +1,191 @@
package npcregistry
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
)
func TestModuleContractAndIdentity(t *testing.T) {
if _, err := DecodeOptions(nil); err != nil {
t.Fatalf("DecodeOptions(nil) error = %v, want nil", err)
}
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
t.Fatal("DecodeOptions() accepted unknown option")
}
want := pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageNormalize, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: dnd.NPCRegistryKind}
if got := ModuleSpec(); !reflect.DeepEqual(got, want) {
t.Fatalf("ModuleSpec() = %#v, want %#v", got, want)
}
registry := pipeline.NewNormalizerRegistry()
if err := Register(registry); err != nil {
t.Fatalf("Register() error = %v", err)
}
if _, err := New(nil, Options{}); err == nil {
t.Fatal("New(nil, Options{}) error = nil, want nil client rejection")
}
normalizer := newNormalizer(t, &recordingNPCNormalizerClient{})
if metadata := normalizer.ManifestMetadata(); metadata["identity_policy"] != identity.Policy || metadata["normalization_policy"] != normalizationPolicy || metadata["prompt_id"] != PromptID || metadata["response_schema_key"] != string(entityreconcile.ResponseSchemaKey) || metadata["response_schema_id"] != entityreconcile.ResponseSchemaID || metadata["response_schema_name"] != entityreconcile.ResponseSchemaName || metadata["semantic_context_policy"] != semanticContextPolicy || metadata["semantic_context_radius"] != semanticContextRadius {
t.Fatalf("metadata = %#v", metadata)
}
wantFingerprints := []pipeline.CheckpointFingerprint{{Name: "prompt", Value: normalizer.promptSHA}, {Name: "response_schema", Value: normalizer.responseSchemaSHA}, {Name: "identity_policy", Value: identity.Policy}, {Name: "normalization_policy", Value: normalizationPolicy}, {Name: "semantic_context_policy", Value: semanticContextPolicy + ":2"}}
if got := normalizer.CheckpointFingerprints(); !reflect.DeepEqual(got, wantFingerprints) {
t.Fatalf("fingerprints = %#v, want %#v", got, wantFingerprints)
}
}
func TestNormalizeNamesEvidenceAndIDs(t *testing.T) {
input := dnd.NPCRegistry{NPCs: []dnd.NPC{{
ID: "wrong", Name: " Lady\tAsh ", SourceRefs: []source.SourceRef{
{SourceID: "b", StartUnitID: 2, EndUnitID: 3},
{SourceID: "a", StartUnitID: 4, EndUnitID: 4},
{SourceID: "b", StartUnitID: 2, EndUnitID: 3},
},
}}}
result, err := newNormalizer(t, &recordingNPCNormalizerClient{}).Normalize(context.Background(), normalizeRequest(input))
if err != nil {
t.Fatalf("Normalize() error = %v", err)
}
want := dnd.NPC{ID: identity.DeriveID("Lady Ash"), Name: "Lady Ash", SourceRefs: []source.SourceRef{{SourceID: "a", StartUnitID: 4, EndUnitID: 4}, {SourceID: "b", StartUnitID: 2, EndUnitID: 3}}}
if !reflect.DeepEqual(result.Value.NPCs[0], want) {
t.Fatalf("NPC = %#v, want %#v", result.Value.NPCs[0], want)
}
for _, reason := range []string{ReasonCodeNPCFieldsNormalized, ReasonCodeSourceReferencesNormalized, ReasonCodeNPCIDRecomputed} {
if !hasWarning(result.Warnings, reason, "npcs[0]") {
t.Fatalf("warnings = %#v, want %s", result.Warnings, reason)
}
}
}
func TestNormalizeOrdersEvidenceBySourceDocumentPosition(t *testing.T) {
doc := &source.SourceDocument{ID: "source", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
input := dnd.NPCRegistry{NPCs: []dnd.NPC{{Name: "Lady Ash", SourceRefs: []source.SourceRef{
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
}}}}
result, err := newNormalizer(t, &recordingNPCNormalizerClient{}).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil {
t.Fatalf("Normalize() error = %v", err)
}
got := result.Value.NPCs[0].SourceRefs
if got[0].StartUnitID != 30 || got[1].StartUnitID != 10 || got[2].StartUnitID != 999 {
t.Fatalf("source refs = %#v, want source-document order followed by invalid reference", got)
}
}
func TestNormalizeConsolidatesCanonicalNamesOnlyAndUnionsEvidence(t *testing.T) {
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
{Name: " Captain Vale ", SourceRefs: []source.SourceRef{{SourceID: "a", StartUnitID: 1, EndUnitID: 1}}},
{Name: "captain vale", SourceRefs: []source.SourceRef{{SourceID: "b", StartUnitID: 2, EndUnitID: 2}}},
{Name: "The Captain", SourceRefs: []source.SourceRef{{SourceID: "c", StartUnitID: 3, EndUnitID: 3}}},
}}
result, err := newNormalizer(t, &recordingNPCNormalizerClient{}).Normalize(context.Background(), normalizeRequest(input))
if err != nil {
t.Fatalf("Normalize() error = %v", err)
}
if len(result.Value.NPCs) != 2 || result.Value.NPCs[0].Name != "Captain Vale" || result.Value.NPCs[1].Name != "The Captain" {
t.Fatalf("NPCs = %#v, want name-only stable consolidation", result.Value.NPCs)
}
if refs := result.Value.NPCs[0].SourceRefs; len(refs) != 2 || refs[0].SourceID != "a" || refs[1].SourceID != "b" {
t.Fatalf("source refs = %#v, want evidence union", refs)
}
if !hasWarning(result.Warnings, ReasonCodeDuplicateNPCCollapsed, "npcs[0]") {
t.Fatalf("warnings = %#v, want duplicate collapse", result.Warnings)
}
}
func TestNormalizePreservesInvalidCandidatesAndDoesNotAliasInput(t *testing.T) {
input := dnd.NPCRegistry{NPCs: []dnd.NPC{{Name: " ", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 0, EndUnitID: -1}}}}}
before := dnd.NPCRegistry{NPCs: []dnd.NPC{{Name: " ", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 0, EndUnitID: -1}}}}}
result, err := newNormalizer(t, &recordingNPCNormalizerClient{}).Normalize(context.Background(), normalizeRequest(input))
if err != nil || !reflect.DeepEqual(input, before) {
t.Fatalf("Normalize() = %#v, %v; input mutated to %#v", result, err, input)
}
if result.Value.NPCs[0].Name != "" || result.Value.NPCs[0].SourceRefs[0].EndUnitID != -1 {
t.Fatalf("candidate = %#v, want invalid semantics preserved", result.Value.NPCs[0])
}
result.Value.NPCs[0].SourceRefs[0].SourceID = "changed"
if input.NPCs[0].SourceRefs[0].SourceID != "source" {
t.Fatal("output aliases input evidence")
}
}
func TestNormalizeHandlesNilAndCanceledCalls(t *testing.T) {
normalizer := newNormalizer(t, &recordingNPCNormalizerClient{})
result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCRegistry{NPCs: nil}))
if err != nil || result.Value.NPCs != nil {
t.Fatalf("nil list result = %#v, error = %v", result.Value, err)
}
canceled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := normalizer.Normalize(canceled, normalizeRequest(dnd.NPCRegistry{})); err == nil || !strings.Contains(err.Error(), "context error") {
t.Fatalf("canceled Normalize() error = %v", err)
}
}
type recordingNPCNormalizerClient struct {
response string
responses []string
err error
requests []contracts.StructuredCompletionRequest
}
func (c *recordingNPCNormalizerClient) CompleteStructured(_ context.Context, request contracts.StructuredCompletionRequest, output any) (contracts.StructuredCompletionResponse, error) {
c.requests = append(c.requests, request)
if c.err != nil {
return contracts.StructuredCompletionResponse{}, c.err
}
response := c.response
if len(c.responses) > 0 {
responseIndex := len(c.requests) - 1
if responseIndex >= len(c.responses) {
responseIndex = len(c.responses) - 1
}
response = c.responses[responseIndex]
}
if response == "" {
response = `{"duplicate_groups":[]}`
}
if err := json.Unmarshal([]byte(response), output); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: json.RawMessage(response)}, nil
}
func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {
t.Helper()
normalizer, err := New(client, Options{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
return normalizer
}
func normalizeRequest(value dnd.NPCRegistry) contracts.TypedNormalizeRequest[dnd.NPCRegistry] {
return contracts.TypedNormalizeRequest[dnd.NPCRegistry]{MergeOutput: contracts.MergeArtifact[dnd.NPCRegistry]{Value: value}}
}
func normalizeRequestWithSource(value dnd.NPCRegistry, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.NPCRegistry] {
request := normalizeRequest(value)
request.Source = doc
return request
}
func hasWarning(warnings []contracts.Warning, reason, scope string) bool {
for _, warning := range warnings {
if warning.ReasonCode == reason && warning.Scope == scope {
return true
}
}
return false
}

View File

@@ -0,0 +1,66 @@
package npcregistry
import (
"fmt"
"io/fs"
"sync"
rootassets "gitea.maximumdirect.net/eric/notarius/assets"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const promptAssetRoot = "assets/prompts"
var promptAssetManifest = shared.PromptAssetManifest{
ModuleDir: PromptID,
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"},
{Name: "instructions.md", Path: "prompts/instructions.md"},
{Name: "candidates.md", Path: "prompts/candidates.md"},
},
SharedFiles: []string{
"common-dnd-system.md",
"common-dnd-entity-reconciliation.md",
"common-dnd-transcript-windows.md",
},
}
func moduleAssetFS() (fs.FS, error) {
assets, err := fs.Sub(rootassets.FS(), "dnd/npc-registry/normalize")
if err != nil {
return nil, fmt.Errorf("scope NPC normalization assets: %w", err)
}
return assets, nil
}
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
assets, err := moduleAssetFS()
if err != nil {
return err
}
promptFS, err := promptAssetManifest.PromptFS(assets)
if err != nil {
return fmt.Errorf("prepare NPC normalization prompt assets: %w", err)
}
return registry.RegisterPromptFS(promptFS, promptAssetRoot)
}
func promptAssetMetadata() (string, error) {
promptAssetHashOnce.Do(func() {
assets, err := moduleAssetFS()
if err != nil {
promptAssetHashErr = err
return
}
promptAssetHash, promptAssetHashErr = promptAssetManifest.Hash(assets)
})
return promptAssetHash, promptAssetHashErr
}
var (
promptAssetHashOnce sync.Once
promptAssetHash string
promptAssetHashErr error
)

View File

@@ -0,0 +1,76 @@
package npcregistry
import (
"context"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
"gitea.maximumdirect.net/eric/promptkit"
)
func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) {
if promptHash, err := promptAssetMetadata(); err != nil || promptHash == "" {
t.Fatalf("promptAssetMetadata() = %q, %v; want prompt fingerprint", promptHash, err)
}
registry := llm.NewAssetRegistry()
if err := entityreconcile.RegisterSchemaAssets(registry); err != nil {
t.Fatalf("RegisterSchemaAssets() error = %v", err)
}
if err := RegisterPromptAssets(registry); err != nil {
t.Fatalf("RegisterPromptAssets() error = %v", err)
}
options, err := registry.PromptKitOptions()
if err != nil {
t.Fatalf("PromptKitOptions() error = %v", err)
}
options = append(options, promptkit.WithProfiles(promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
ID: "normalize-test-profile", Endpoint: "http://127.0.0.1:1/v1", Model: "normalize-test-model",
})))
engine, err := promptkit.NewEngine(promptkit.Config{Timeout: time.Second}, options...)
if err != nil {
t.Fatalf("NewEngine() error = %v", err)
}
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "normalize-test-profile",
Inputs: map[string]promptkit.ArtifactRef{
"candidates": promptkit.Inline(`{"candidates":[{"key":"candidate-000001","name":"Mira","source_refs":[]}]}`),
"transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`),
},
})
if err != nil {
t.Fatalf("Prepare() error = %v", err)
}
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_entity_reconcile_llm.v1.json" {
t.Fatalf("prepared prompt = %#v, want normalization prompt identity and schema", prepared)
}
if prepared.Messages[0].Role != "system" {
t.Fatalf("initial message role = %q, want system", prepared.Messages[0].Role)
}
for _, index := range []int{2, 4} {
if cache := prepared.Messages[index].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral {
t.Errorf("message %d cache control = %#v, want ephemeral", index, cache)
}
}
for _, index := range []int{0, 1, 3} {
if cache := prepared.Messages[index].CacheControl; cache != nil {
t.Errorf("message %d cache control = %#v, want nil", index, cache)
}
}
if !strings.Contains(prepared.Messages[3].Content, `"Mira"`) || strings.Contains(prepared.Messages[3].Content, `"windows"`) {
t.Fatalf("candidate message = %q, want only rendered candidates", prepared.Messages[3].Content)
}
if !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, `"Mira"`) {
t.Fatalf("transcript message = %q, want only rendered transcript", prepared.Messages[4].Content)
}
for index, message := range prepared.Messages {
if index != 3 && strings.Contains(message.Content, `"Mira"`) {
t.Errorf("message %d unexpectedly rendered candidate input", index)
}
if index != 4 && strings.Contains(message.Content, `"windows"`) {
t.Errorf("message %d unexpectedly rendered transcript input", index)
}
}
}

View File

@@ -0,0 +1,122 @@
package npcregistry
import (
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
)
const semanticContextRadius = 2
type safeReconciliationGroup struct {
members []int
canonical int
}
func reconciliationCandidates(records []normalizedRecord) []entityreconcile.Candidate {
candidates := make([]entityreconcile.Candidate, len(records))
for index, record := range records {
candidates[index] = entityreconcile.Candidate{
Name: record.npc.Name,
SourceRefs: cloneSourceRefs(record.npc.SourceRefs),
}
}
return candidates
}
func reconciliationGroups(assessment entityreconcile.Assessment, candidateKeys []string) []safeReconciliationGroup {
positions := make(map[string]int, len(candidateKeys))
for index, key := range candidateKeys {
positions[key] = index
}
safeGroups := assessment.SafeGroups()
groups := make([]safeReconciliationGroup, 0, len(safeGroups))
for _, group := range safeGroups {
members := group.Members()
memberPositions := make([]int, len(members))
valid := true
for index, key := range members {
position, ok := positions[key]
if !ok {
valid = false
break
}
memberPositions[index] = position
}
canonical, ok := positions[group.Canonical()]
if !valid || !ok {
continue
}
groups = append(groups, safeReconciliationGroup{members: memberPositions, canonical: canonical})
}
return groups
}
func reconciliationIssues(assessment entityreconcile.Assessment) []string {
issues := assessment.Issues()
details := make([]string, len(issues))
for index, issue := range issues {
details[index] = fmt.Sprintf("group %d: %s", issue.GroupIndex, issue.Category)
}
return details
}
func applySafeGroups(records []normalizedRecord, groups []safeReconciliationGroup, order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning) {
byMember := make(map[int]safeReconciliationGroup, len(groups)*2)
for _, group := range groups {
for _, member := range group.members {
byMember[member] = group
}
}
output := make([]normalizedRecord, 0, len(records)-len(groups))
warnings := make([]contracts.Warning, 0, len(groups))
for index, record := range records {
group, grouped := byMember[index]
if !grouped {
output = append(output, cloneRecord(record))
continue
}
if group.members[0] != index {
continue
}
consolidated := consolidateSemanticGroup(records, group, order)
output = append(output, consolidated)
warnings = append(warnings, semanticDuplicateWarning(consolidated, records[group.canonical]))
}
return output, warnings
}
func consolidateSemanticGroup(records []normalizedRecord, group safeReconciliationGroup, order shared.SourceRefOrder) normalizedRecord {
output := cloneRecord(records[group.members[0]])
output.npc.Name = records[group.canonical].npc.Name
for _, member := range group.members[1:] {
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
}
}
output.inputIndexes = sortedUniqueIndexes(output.inputIndexes)
output.npc.SourceRefs = order.Canonicalize(output.npc.SourceRefs)
output.npc.ID = identity.DeriveID(output.npc.Name)
return output
}
func semanticDuplicateWarning(record normalizedRecord, canonical normalizedRecord) contracts.Warning {
details := make([]string, 0, len(record.inputIndexes)+1)
for _, inputIndex := range record.inputIndexes {
details = append(details, fmt.Sprintf("input index %d", inputIndex))
}
if canonical.earliest != record.earliest {
details = append(details, fmt.Sprintf("canonical display name from input index %d", canonical.earliest))
}
return contracts.Warning{
Scope: npcScope(record.earliest),
ReasonCode: ReasonCodeDuplicateNPCCollapsed,
Message: diagnostics.Aggregate("semantic duplicate consolidation", details),
}
}

View File

@@ -0,0 +1,244 @@
package npcregistry
import (
"context"
"errors"
"math"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
)
func TestNormalizeSkipsSemanticCompletionWithoutTwoEligibleCandidates(t *testing.T) {
client := &recordingNPCNormalizerClient{}
normalizer := newNormalizer(t, client)
doc := semanticDocument()
input := dnd.NPCRegistry{NPCs: []dnd.NPC{{Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}}}
result, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || len(client.requests) != 0 || result.Retry != nil {
t.Fatalf("Normalize() = %#v, %v; calls = %d, want deterministic no-call result", result, err, len(client.requests))
}
if result.Value.NPCs[0].Name != "Mira Thorn" {
t.Fatalf("NPCs = %#v, want deterministic record", result.Value.NPCs)
}
}
func TestNormalizeAppliesSafeProposalAndUsesPrivateInputs(t *testing.T) {
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`}
normalizer := newNormalizer(t, client)
doc := semanticDocument()
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
{ID: "npc:sha256:short", Name: "Mira", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
{ID: "npc:sha256:long", Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
{ID: "npc:sha256:captain", Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}}
before := cloneNPCRegistry(input)
request := normalizeRequestWithSource(input, doc)
request.LLMProfile = "normalizer-profile"
request.SessionID = "normalizer-session"
result, err := normalizer.Normalize(context.Background(), request)
if err != nil || result.Retry != nil {
t.Fatalf("Normalize() = %#v, %v; want accepted semantic result", result, err)
}
if !reflect.DeepEqual(input, before) {
t.Fatalf("Normalize() mutated input to %#v", input)
}
if len(result.Value.NPCs) != 2 || result.Value.NPCs[0].Name != "Mira Thorn" || result.Value.NPCs[1].Name != "Captain Vale" {
t.Fatalf("NPCs = %#v, want canonical record at earliest position", result.Value.NPCs)
}
merged := result.Value.NPCs[0]
if merged.ID != identity.DeriveID("Mira Thorn") || !reflect.DeepEqual(merged.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}) {
t.Fatalf("merged NPC = %#v, want canonical ID and original evidence union", merged)
}
if !hasWarning(result.Warnings, ReasonCodeDuplicateNPCCollapsed, "npcs[0]") {
t.Fatalf("warnings = %#v, want semantic collapse warning", result.Warnings)
}
if len(client.requests) != 1 {
t.Fatalf("completion calls = %d, want one", len(client.requests))
}
completion := client.requests[0]
if completion.StageName != Key || completion.PromptID != PromptID || completion.PromptVersion != entityreconcile.SchemaVersion || completion.ProfileID != request.LLMProfile || completion.SessionID != request.SessionID || len(completion.Inputs) != 2 {
t.Fatalf("completion request = %#v, want normalize request identity and exactly two inputs", completion)
}
encoded := string(completion.Inputs["candidates"].Content) + string(completion.Inputs["transcript"].Content)
if strings.Contains(encoded, "npc:sha256:") || strings.Contains(encoded, doc.ID) {
t.Fatalf("completion inputs leaked private identifiers: %s", encoded)
}
}
func TestNormalizeUnsafeProposalReturnsSafeRetryFallback(t *testing.T) {
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"},{"members":["candidate-000003","unknown"],"canonical":"candidate-000003"}]}`}
normalizer := newNormalizer(t, client)
doc := semanticDocument()
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
{Name: "Mira", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
{Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
{Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}}
result, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry == nil {
t.Fatalf("Normalize() = %#v, %v; want retryable safe fallback", result, err)
}
if result.Retry.ReasonCode != ReasonCodeNPCSemanticProposalInvalid || !strings.Contains(result.Retry.Message, "group 1: member_unknown") {
t.Fatalf("retry = %#v, want bounded invalid-proposal diagnostics", result.Retry)
}
if len(result.Value.NPCs) != 2 || result.Value.NPCs[0].Name != "Mira Thorn" || result.Value.NPCs[1].Name != "Captain Vale" {
t.Fatalf("fallback NPCs = %#v, want independently safe group applied", result.Value.NPCs)
}
if len(result.Retry.FallbackWarnings) != 1 || result.Retry.FallbackWarnings[0].ReasonCode != ReasonCodeNPCSemanticReconciliationExhausted || !strings.Contains(result.Retry.FallbackWarnings[0].Message, "1 proposal group") {
t.Fatalf("fallback warnings = %#v, want exact omitted-group warning", result.Retry.FallbackWarnings)
}
}
func TestNormalizeInvalidStructuredOutputAndOperationalErrorsRemainDistinct(t *testing.T) {
doc := semanticDocument()
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
{Name: "Mira", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
{Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
}}
invalid := newNormalizer(t, &recordingNPCNormalizerClient{err: contracts.ErrInvalidStructuredOutput})
result, err := invalid.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry == nil || result.Retry.ReasonCode != ReasonCodeNPCSemanticProposalInvalid || len(result.Value.NPCs) != 2 {
t.Fatalf("invalid structured result = %#v, %v; want deterministic retry fallback", result, err)
}
operational := errors.New("provider unavailable")
if _, err := newNormalizer(t, &recordingNPCNormalizerClient{err: operational}).Normalize(context.Background(), normalizeRequestWithSource(input, doc)); !errors.Is(err, operational) || errors.Is(err, contracts.ErrInvalidStructuredOutput) {
t.Fatalf("operational completion error = %v, want ordinary error", err)
}
}
func TestNormalizeRedactsContextMaterialFailures(t *testing.T) {
client := &recordingNPCNormalizerClient{}
normalizer := newNormalizer(t, client)
const (
metadataKey = "normalizer-sensitive-metadata-key"
metadataValue = "normalizer-sensitive-metadata-value"
sourceID = "normalizer-sensitive-source-id"
originPath = "file:///normalizer-sensitive-origin.json"
transcript = "normalizer-sensitive-transcript"
firstName = "Normalizer Sensitive"
secondName = "Normalizer Sensitive Alias"
)
doc := &source.SourceDocument{ID: sourceID, Units: []source.SourceUnit{
{ID: 10, Kind: "speech", Text: transcript, Metadata: map[string]any{metadataKey: math.NaN(), "value": metadataValue}},
{ID: 20, Kind: "speech", Text: "other context"},
}}
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
{Name: firstName, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
{Name: secondName, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
}}
request := normalizeRequestWithSource(input, doc)
request.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(transcript), "sha256:test", originPath)
_, err := normalizer.Normalize(context.Background(), request)
if err == nil || !strings.Contains(err.Error(), "build entity reconciliation context: invalid source metadata") {
t.Fatalf("Normalize() error = %v; want content-safe context-material failure", err)
}
for _, forbidden := range []string{metadataKey, metadataValue, transcript, firstName, secondName, sourceID, originPath, "float64", "non-finite"} {
if strings.Contains(err.Error(), forbidden) {
t.Fatalf("Normalize() error leaked %q: %v", forbidden, err)
}
}
if len(client.requests) != 0 {
t.Fatalf("completion calls = %d, want context failure before completion", len(client.requests))
}
}
func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) {
client := &recordingNPCNormalizerClient{responses: []string{
`{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"},{"members":["candidate-000003","unknown"],"canonical":"candidate-000003"}]}`,
`{"duplicate_groups":[{"members":["candidate-000001","candidate-000003"],"canonical":"candidate-000003"}]}`,
}}
normalizer := newNormalizer(t, client)
doc := semanticDocument()
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
{Name: "Mira", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
{Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
{Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}}
first, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || first.Retry == nil || len(first.Value.NPCs) != 2 {
t.Fatalf("first Normalize() = %#v, %v; want partial retry fallback", first, err)
}
second, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || second.Retry != nil {
t.Fatalf("second Normalize() = %#v, %v; want accepted independent retry result", second, err)
}
if got := []string{second.Value.NPCs[0].Name, second.Value.NPCs[1].Name}; !reflect.DeepEqual(got, []string{"Captain Vale", "Mira Thorn"}) {
t.Fatalf("second NPCs = %#v, want proposal applied to original merge output", second.Value.NPCs)
}
}
func TestNormalizeExcludesIneligibleRecordsFromSemanticGroups(t *testing.T) {
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`}
normalizer := newNormalizer(t, client)
doc := semanticDocument()
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
{Name: "Mira", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
{Name: "Broken", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999}}},
{Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}}
result, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry == nil || len(result.Value.NPCs) != 3 {
t.Fatalf("Normalize() = %#v, %v; want unchanged retry fallback", result, err)
}
if !strings.Contains(result.Retry.Message, "member_ineligible") || result.Value.NPCs[1].Name != "Broken" {
t.Fatalf("result = %#v, want ineligible record excluded but preserved", result)
}
}
func TestReconciliationCandidatesKeepEqualDisplayNamesDistinct(t *testing.T) {
doc := semanticDocument()
records := []normalizedRecord{
{npc: dnd.NPC{Name: "The Guard", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}},
{npc: dnd.NPC{Name: "The Guard", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}},
}
materials, ready, err := entityreconcile.BuildContext(doc, reconciliationCandidates(records), semanticContextRadius)
if err != nil || !ready {
t.Fatalf("BuildContext() = %#v, %t, %v; want ready keyed candidates", materials, ready, err)
}
keys := materials.CandidateKeys()
if !reflect.DeepEqual(keys, []string{"candidate-000001", "candidate-000002"}) || strings.Count(string(materials.Candidates.Content), `"The Guard"`) != 2 {
t.Fatalf("candidate keys and inputs = %#v, %s; want distinct equal-display candidates", keys, materials.Candidates.Content)
}
assessment := materials.Assess(entityreconcile.ProposalResponse{DuplicateGroups: []entityreconcile.DuplicateGroup{{
Members: keys, Canonical: keys[1],
}}})
groups := reconciliationGroups(assessment, keys)
if assessment.DiscardedGroups() != 0 || len(groups) != 1 || groups[0].canonical != 1 {
t.Fatalf("reconciliation = %#v, %#v; want distinct keyed group", assessment, groups)
}
}
func TestRetryWarningLimitReservesExhaustionWarningPosition(t *testing.T) {
warnings := make([]contracts.Warning, 0, 25)
for index := 0; index < 25; index++ {
warnings = append(warnings, contracts.Warning{Scope: "npcs", ReasonCode: "test", Message: "warning"})
}
bounded := limitWarningsForRetry(warnings)
if len(bounded) != 19 || bounded[len(bounded)-1].ReasonCode != ReasonCodeNPCNormalizationWarningsOmitted || !strings.Contains(bounded[len(bounded)-1].Message, "7 additional") {
t.Fatalf("retry warnings = %#v, want 18 warnings plus accurate omission summary", bounded)
}
}
func semanticDocument() *source.SourceDocument {
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
{ID: 10, Kind: "speech", Text: "Mira speaks"},
{ID: 15, Kind: "narration", Text: "surrounding context"},
{ID: 20, Kind: "speech", Text: "Mira Thorn replies"},
{ID: 30, Kind: "speech", Text: "Captain Vale watches"},
}}
}
func cloneNPCRegistry(input dnd.NPCRegistry) dnd.NPCRegistry {
output := dnd.NPCRegistry{NPCs: make([]dnd.NPC, len(input.NPCs))}
for index, npc := range input.NPCs {
output.NPCs[index] = cloneNPC(npc)
}
return output
}