Add LLM-assisted NPC normalization
This commit is contained in:
@@ -16,8 +16,9 @@ import (
|
||||
const semanticContextRadius = 2
|
||||
|
||||
type normalizeContextMaterials struct {
|
||||
Candidates contracts.LLMInputMaterial
|
||||
Transcript contracts.LLMInputMaterial
|
||||
Candidates contracts.LLMInputMaterial
|
||||
Transcript contracts.LLMInputMaterial
|
||||
candidatePositions []int
|
||||
}
|
||||
|
||||
type normalizeCandidateInput struct {
|
||||
@@ -81,11 +82,12 @@ func buildNormalizeContextMaterials(doc *source.SourceDocument, records []dnd.NP
|
||||
|
||||
index := source.NewDocumentIndex(doc)
|
||||
candidates := make([]normalizeCandidate, 0, len(records))
|
||||
candidatePositions := make([]int, 0, len(records))
|
||||
intervals := make([]sourceInterval, 0)
|
||||
cited := make([]bool, len(doc.Units))
|
||||
seenKeys := make(map[string]struct{}, len(records))
|
||||
|
||||
for _, record := range records {
|
||||
for position, record := range records {
|
||||
key := identity.ComparisonKey(record.Name)
|
||||
if key == "" || len(record.SourceRefs) == 0 {
|
||||
continue
|
||||
@@ -100,6 +102,7 @@ func buildNormalizeContextMaterials(doc *source.SourceDocument, records []dnd.NP
|
||||
}
|
||||
seenKeys[key] = struct{}{}
|
||||
candidates = append(candidates, normalizeCandidate{Name: record.Name, SourceRefs: references})
|
||||
candidatePositions = append(candidatePositions, position)
|
||||
for _, interval := range recordIntervals {
|
||||
for position := interval.start; position <= interval.end; position++ {
|
||||
cited[position] = true
|
||||
@@ -127,8 +130,9 @@ func buildNormalizeContextMaterials(doc *source.SourceDocument, records []dnd.NP
|
||||
return normalizeContextMaterials{}, false, fmt.Errorf("build NPC normalization context: encode transcript: %w", err)
|
||||
}
|
||||
return normalizeContextMaterials{
|
||||
Candidates: newNormalizeInputMaterial("candidates", candidateContent),
|
||||
Transcript: newNormalizeInputMaterial("transcript", transcriptContent),
|
||||
Candidates: newNormalizeInputMaterial("candidates", candidateContent),
|
||||
Transcript: newNormalizeInputMaterial("transcript", transcriptContent),
|
||||
candidatePositions: candidatePositions,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ package npcs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -18,14 +20,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
Key = "dnd/npcs"
|
||||
normalizationPolicy = "dnd.npcs.normalize.v2"
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
Key = "dnd/npcs"
|
||||
normalizationPolicy = "dnd.npcs.normalize.v3"
|
||||
semanticContextPolicy = "dnd.npcs.semantic_context.v1"
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
|
||||
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
|
||||
ReasonCodeNPCIDRecomputed = "npc_id_recomputed"
|
||||
ReasonCodeSourceReferencesNormalized = "source_references_normalized"
|
||||
ReasonCodeDuplicateNPCCollapsed = "duplicate_npc_collapsed"
|
||||
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"}
|
||||
@@ -36,9 +42,28 @@ var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
|
||||
|
||||
type Options struct{}
|
||||
type Normalizer struct{}
|
||||
|
||||
func New(Options) *Normalizer { return &Normalizer{} }
|
||||
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 := scriptoriumPromptMetadata()
|
||||
if err != nil {
|
||||
return nil, normalizerErrorf("load prompt metadata: %w", err)
|
||||
}
|
||||
responseSchema, err := 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 }
|
||||
|
||||
@@ -46,7 +71,20 @@ func (n *Normalizer) ManifestMetadata() map[string]any {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"identity_policy": identity.Policy, "normalization_policy": normalizationPolicy}
|
||||
return map[string]any{
|
||||
"prompt_id": PromptID,
|
||||
"prompt_version": SchemaVersion,
|
||||
"prompt_sha256": n.promptSHA,
|
||||
"response_schema_key": string(ResponseSchemaKey),
|
||||
"response_schema_id": ResponseSchemaID,
|
||||
"response_schema_name": ResponseSchemaName,
|
||||
"response_schema_version": 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 {
|
||||
@@ -54,8 +92,11 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
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)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,30 +104,114 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
||||
if n == nil {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("normalizer must not be nil")
|
||||
}
|
||||
if n.llm == nil {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("LLM client must not be nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context must not be nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context error before normalize: %w", err)
|
||||
}
|
||||
|
||||
order := shared.NewSourceRefOrder(req.Source)
|
||||
value, warnings := normalizeList(req.MergeOutput.Value, order)
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{Value: value, Warnings: warnings}, nil
|
||||
records, warnings := preprocessRecords(req.MergeOutput.Value, order)
|
||||
deterministic := recordList(records)
|
||||
materials, ready, err := buildDefaultNormalizeContextMaterials(req.Source, recordValues(records))
|
||||
if err != nil {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("build semantic context: %w", err)
|
||||
}
|
||||
if !ready {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
|
||||
}
|
||||
|
||||
var response normalizeProposalResponse
|
||||
if _, err := n.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||
StageName: Key, PromptID: PromptID, PromptVersion: 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.NPCList]{}, normalizerErrorf("complete structured output: %w", err)
|
||||
}
|
||||
|
||||
assessment := assessProposal(response, records, materials.candidatePositions)
|
||||
applied, semanticWarnings := applySafeGroups(records, assessment.safeGroups, order)
|
||||
warnings = append(warnings, semanticWarnings...)
|
||||
if assessment.discardedGroups == 0 {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{Value: recordList(applied), Warnings: limitWarnings(warnings)}, nil
|
||||
}
|
||||
return retryResult(recordList(applied), warnings, assessment), nil
|
||||
}
|
||||
|
||||
func (n *Normalizer) invalidStructuredResult(value dnd.NPCList, warnings []contracts.Warning) contracts.TypedNormalizeResult[dnd.NPCList] {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{
|
||||
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.NPCList, warnings []contracts.Warning, assessment proposalAssessment) contracts.TypedNormalizeResult[dnd.NPCList] {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{
|
||||
Value: value,
|
||||
Warnings: limitWarningsForRetry(warnings),
|
||||
Retry: &contracts.NormalizeRetry{
|
||||
ReasonCode: ReasonCodeNPCSemanticProposalInvalid,
|
||||
Message: diagnostics.Aggregate("semantic proposal requires retry", assessment.issues),
|
||||
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
|
||||
npc dnd.NPC
|
||||
inputIndexes []int
|
||||
earliest int
|
||||
}
|
||||
|
||||
func normalizeList(input dnd.NPCList, order shared.SourceRefOrder) (dnd.NPCList, []contracts.Warning) {
|
||||
func preprocessRecords(input dnd.NPCList, order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning) {
|
||||
if input.NPCs == nil {
|
||||
return dnd.NPCList{}, 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}
|
||||
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))})
|
||||
}
|
||||
@@ -99,16 +224,16 @@ func normalizeList(input dnd.NPCList, order shared.SourceRefOrder) (dnd.NPCList,
|
||||
}
|
||||
|
||||
groups := canonicalNameGroups(records)
|
||||
output := dnd.NPCList{NPCs: make([]dnd.NPC, 0, len(groups))}
|
||||
output := make([]normalizedRecord, 0, len(groups))
|
||||
for _, members := range groups {
|
||||
consolidated, referencesChanged := consolidate(records, members, order)
|
||||
retainedIndex := members[0]
|
||||
output.NPCs = append(output.NPCs, consolidated)
|
||||
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.SourceRefs))})
|
||||
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, members[1:]))
|
||||
warnings = append(warnings, duplicateWarning(retainedIndex, memberInputIndexes(records, members[1:])))
|
||||
}
|
||||
}
|
||||
return output, warnings
|
||||
@@ -144,15 +269,68 @@ func canonicalNameGroups(records []normalizedRecord) [][]int {
|
||||
return groups
|
||||
}
|
||||
|
||||
func consolidate(records []normalizedRecord, members []int, order shared.SourceRefOrder) (dnd.NPC, bool) {
|
||||
output := cloneNPC(records[members[0]].npc)
|
||||
originalRefs := cloneSourceRefs(output.SourceRefs)
|
||||
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.SourceRefs = append(output.SourceRefs, records[member].npc.SourceRefs...)
|
||||
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.SourceRefs = order.Canonicalize(output.SourceRefs)
|
||||
output.ID = identity.DeriveID(output.Name)
|
||||
return output, !reflect.DeepEqual(originalRefs, output.SourceRefs)
|
||||
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.NPCList {
|
||||
if records == nil {
|
||||
return dnd.NPCList{}
|
||||
}
|
||||
return dnd.NPCList{NPCs: recordValues(records)}
|
||||
}
|
||||
|
||||
func cloneSourceRefs(input []source.SourceRef) []source.SourceRef {
|
||||
@@ -191,7 +369,7 @@ func Register(registry *pipeline.NormalizerRegistry) error {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return New(options), nil
|
||||
return New(request.Dependencies.LLM, options)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package npcs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -28,11 +29,14 @@ func TestModuleContractAndIdentity(t *testing.T) {
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatalf("Register() error = %v", err)
|
||||
}
|
||||
normalizer := New(Options{})
|
||||
if metadata := normalizer.ManifestMetadata(); metadata["identity_policy"] != identity.Policy || metadata["normalization_policy"] != normalizationPolicy {
|
||||
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["semantic_context_policy"] != semanticContextPolicy || metadata["semantic_context_radius"] != semanticContextRadius {
|
||||
t.Fatalf("metadata = %#v", metadata)
|
||||
}
|
||||
wantFingerprints := []pipeline.CheckpointFingerprint{{Name: "identity_policy", Value: identity.Policy}, {Name: "normalization_policy", Value: normalizationPolicy}}
|
||||
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)
|
||||
}
|
||||
@@ -46,7 +50,7 @@ func TestNormalizeNamesEvidenceAndIDs(t *testing.T) {
|
||||
{SourceID: "b", StartUnitID: 2, EndUnitID: 3},
|
||||
},
|
||||
}}}
|
||||
result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input))
|
||||
result, err := newNormalizer(t, &recordingNPCNormalizerClient{}).Normalize(context.Background(), normalizeRequest(input))
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
@@ -68,7 +72,7 @@ func TestNormalizeOrdersEvidenceBySourceDocumentPosition(t *testing.T) {
|
||||
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
|
||||
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
|
||||
}}}}
|
||||
result, err := New(Options{}).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
||||
result, err := newNormalizer(t, &recordingNPCNormalizerClient{}).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
@@ -84,7 +88,7 @@ func TestNormalizeConsolidatesCanonicalNamesOnlyAndUnionsEvidence(t *testing.T)
|
||||
{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 := New(Options{}).Normalize(context.Background(), normalizeRequest(input))
|
||||
result, err := newNormalizer(t, &recordingNPCNormalizerClient{}).Normalize(context.Background(), normalizeRequest(input))
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
@@ -102,7 +106,7 @@ func TestNormalizeConsolidatesCanonicalNamesOnlyAndUnionsEvidence(t *testing.T)
|
||||
func TestNormalizePreservesInvalidCandidatesAndDoesNotAliasInput(t *testing.T) {
|
||||
input := dnd.NPCList{NPCs: []dnd.NPC{{Name: " ", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 0, EndUnitID: -1}}}}}
|
||||
before := dnd.NPCList{NPCs: []dnd.NPC{{Name: " ", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 0, EndUnitID: -1}}}}}
|
||||
result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input))
|
||||
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)
|
||||
}
|
||||
@@ -116,7 +120,7 @@ func TestNormalizePreservesInvalidCandidatesAndDoesNotAliasInput(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNormalizeHandlesNilAndCanceledCalls(t *testing.T) {
|
||||
normalizer := New(Options{})
|
||||
normalizer := newNormalizer(t, &recordingNPCNormalizerClient{})
|
||||
result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCList{NPCs: nil}))
|
||||
if err != nil || result.Value.NPCs != nil {
|
||||
t.Fatalf("nil list result = %#v, error = %v", result.Value, err)
|
||||
@@ -128,6 +132,44 @@ func TestNormalizeHandlesNilAndCanceledCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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.NPCList) contracts.TypedNormalizeRequest[dnd.NPCList] {
|
||||
return contracts.TypedNormalizeRequest[dnd.NPCList]{MergeOutput: contracts.MergeArtifact[dnd.NPCList]{Value: value}}
|
||||
}
|
||||
|
||||
195
internal/modules/dnd/normalize/npcs/proposal.go
Normal file
195
internal/modules/dnd/normalize/npcs/proposal.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package npcs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type proposalAssessment struct {
|
||||
safeGroups []safeProposalGroup
|
||||
discardedGroups int
|
||||
issues []string
|
||||
}
|
||||
|
||||
type safeProposalGroup struct {
|
||||
members []int
|
||||
canonical int
|
||||
}
|
||||
|
||||
type assessedProposalGroup struct {
|
||||
members []int
|
||||
canonical int
|
||||
locallyValid bool
|
||||
conflicting bool
|
||||
}
|
||||
|
||||
func assessProposal(response normalizeProposalResponse, records []normalizedRecord, candidatePositions []int) proposalAssessment {
|
||||
positionsByKey := make(map[string][]int, len(candidatePositions))
|
||||
for _, position := range candidatePositions {
|
||||
if position < 0 || position >= len(records) {
|
||||
continue
|
||||
}
|
||||
key := identity.ComparisonKey(records[position].npc.Name)
|
||||
if key != "" {
|
||||
positionsByKey[key] = append(positionsByKey[key], position)
|
||||
}
|
||||
}
|
||||
groups := make([]assessedProposalGroup, len(response.DuplicateGroups))
|
||||
issues := make([]string, 0)
|
||||
owners := make(map[int][]int)
|
||||
|
||||
for groupIndex, proposal := range response.DuplicateGroups {
|
||||
group, groupIssues := assessProposalGroup(proposal, positionsByKey)
|
||||
groups[groupIndex] = group
|
||||
for _, position := range group.members {
|
||||
owners[position] = append(owners[position], groupIndex)
|
||||
}
|
||||
for _, issue := range groupIssues {
|
||||
issues = append(issues, proposalIssue(groupIndex, issue))
|
||||
}
|
||||
}
|
||||
for groupIndex := range groups {
|
||||
for _, position := range groups[groupIndex].members {
|
||||
if len(owners[position]) > 1 {
|
||||
groups[groupIndex].conflicting = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if groups[groupIndex].conflicting {
|
||||
issues = append(issues, proposalIssue(groupIndex, "overlapping_member"))
|
||||
}
|
||||
}
|
||||
|
||||
assessment := proposalAssessment{issues: issues}
|
||||
for _, group := range groups {
|
||||
if !group.locallyValid || group.conflicting {
|
||||
assessment.discardedGroups++
|
||||
continue
|
||||
}
|
||||
assessment.safeGroups = append(assessment.safeGroups, safeProposalGroup{members: group.members, canonical: group.canonical})
|
||||
}
|
||||
return assessment
|
||||
}
|
||||
|
||||
func assessProposalGroup(proposal normalizeProposalGroup, positionsByKey map[string][]int) (assessedProposalGroup, []string) {
|
||||
issues := make([]string, 0)
|
||||
members := make([]int, 0, len(proposal.Members))
|
||||
seenMembers := make(map[int]struct{}, len(proposal.Members))
|
||||
for _, name := range proposal.Members {
|
||||
position, issue := resolveCandidate(name, positionsByKey)
|
||||
if issue != "" {
|
||||
issues = append(issues, "member_"+issue)
|
||||
continue
|
||||
}
|
||||
if _, exists := seenMembers[position]; exists {
|
||||
issues = append(issues, "repeated_member")
|
||||
continue
|
||||
}
|
||||
seenMembers[position] = struct{}{}
|
||||
members = append(members, position)
|
||||
}
|
||||
canonical, canonicalIssue := resolveCandidate(proposal.CanonicalName, positionsByKey)
|
||||
if canonicalIssue != "" {
|
||||
issues = append(issues, "canonical_"+canonicalIssue)
|
||||
}
|
||||
if len(members) < 2 {
|
||||
issues = append(issues, "fewer_than_two_members")
|
||||
}
|
||||
if canonicalIssue == "" && !containsPosition(members, canonical) {
|
||||
issues = append(issues, "canonical_not_member")
|
||||
}
|
||||
sort.Ints(members)
|
||||
return assessedProposalGroup{
|
||||
members: members, canonical: canonical, locallyValid: len(issues) == 0,
|
||||
}, issues
|
||||
}
|
||||
|
||||
func resolveCandidate(name string, positionsByKey map[string][]int) (position int, issue string) {
|
||||
key := identity.ComparisonKey(name)
|
||||
if key == "" {
|
||||
return 0, "blank"
|
||||
}
|
||||
positions := positionsByKey[key]
|
||||
if len(positions) == 0 {
|
||||
return 0, "unknown"
|
||||
}
|
||||
if len(positions) != 1 {
|
||||
return 0, "ambiguous"
|
||||
}
|
||||
return positions[0], ""
|
||||
}
|
||||
|
||||
func containsPosition(positions []int, want int) bool {
|
||||
for _, position := range positions {
|
||||
if position == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func proposalIssue(groupIndex int, category string) string {
|
||||
return "group " + strconv.Itoa(groupIndex) + ": " + category
|
||||
}
|
||||
|
||||
func applySafeGroups(records []normalizedRecord, groups []safeProposalGroup, order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning) {
|
||||
byMember := make(map[int]safeProposalGroup, 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 safeProposalGroup, 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),
|
||||
}
|
||||
}
|
||||
265
internal/modules/dnd/normalize/npcs/semantic_normalizer_test.go
Normal file
265
internal/modules/dnd/normalize/npcs/semantic_normalizer_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package npcs
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func TestNormalizeSkipsSemanticCompletionWithoutTwoEligibleCandidates(t *testing.T) {
|
||||
client := &recordingNPCNormalizerClient{}
|
||||
normalizer := newNormalizer(t, client)
|
||||
doc := semanticDocument()
|
||||
input := dnd.NPCList{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":[" MIRA THORN ","Mira"],"canonical_name":"Mira Thorn"}]}`}
|
||||
normalizer := newNormalizer(t, client)
|
||||
doc := semanticDocument()
|
||||
input := dnd.NPCList{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 := cloneNPCList(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 != 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":["Mira","Mira Thorn"],"canonical_name":"Mira Thorn"},{"members":["Captain Vale","Unknown"],"canonical_name":"Captain Vale"}]}`}
|
||||
normalizer := newNormalizer(t, client)
|
||||
doc := semanticDocument()
|
||||
input := dnd.NPCList{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 TestNormalizeRejectsOverlapsWithoutResponseOrderDependence(t *testing.T) {
|
||||
records := []normalizedRecord{
|
||||
{npc: dnd.NPC{Name: "Alpha"}}, {npc: dnd.NPC{Name: "Bravo"}}, {npc: dnd.NPC{Name: "Charlie"}}, {npc: dnd.NPC{Name: "Delta"}},
|
||||
}
|
||||
for index := range records {
|
||||
records[index].inputIndexes = []int{index}
|
||||
records[index].earliest = index
|
||||
}
|
||||
response := normalizeProposalResponse{DuplicateGroups: []normalizeProposalGroup{
|
||||
{Members: []string{"Alpha", "Bravo"}, CanonicalName: "Alpha"},
|
||||
{Members: []string{"Bravo", "Charlie"}, CanonicalName: "Bravo"},
|
||||
{Members: []string{"Charlie", "Delta"}, CanonicalName: "Charlie"},
|
||||
}}
|
||||
assessment := assessProposal(response, records, []int{0, 1, 2, 3})
|
||||
if assessment.discardedGroups != 3 || len(assessment.safeGroups) != 0 {
|
||||
t.Fatalf("assessment = %#v, want chained conflicts all discarded", assessment)
|
||||
}
|
||||
for _, issue := range []string{"group 0: overlapping_member", "group 1: overlapping_member", "group 2: overlapping_member"} {
|
||||
if !containsString(assessment.issues, issue) {
|
||||
t.Fatalf("issues = %#v, want %q", assessment.issues, issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeInvalidStructuredOutputAndOperationalErrorsRemainDistinct(t *testing.T) {
|
||||
doc := semanticDocument()
|
||||
input := dnd.NPCList{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 TestNormalizeRejectsContextEncodingFailuresWithoutLeakingContent(t *testing.T) {
|
||||
client := &recordingNPCNormalizerClient{}
|
||||
normalizer := newNormalizer(t, client)
|
||||
doc := semanticDocument()
|
||||
doc.Units[0].Metadata = map[string]any{"invalid": math.NaN()}
|
||||
input := dnd.NPCList{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}}},
|
||||
}}
|
||||
if _, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc)); err == nil || !strings.Contains(err.Error(), "build semantic context") || strings.Contains(err.Error(), doc.Units[0].Text) || len(client.requests) != 0 {
|
||||
t.Fatalf("Normalize() error = %v, calls = %d; want safe preparation error before completion", err, len(client.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) {
|
||||
client := &recordingNPCNormalizerClient{responses: []string{
|
||||
`{"duplicate_groups":[{"members":["Mira","Mira Thorn"],"canonical_name":"Mira Thorn"},{"members":["Captain Vale","Unknown"],"canonical_name":"Captain Vale"}]}`,
|
||||
`{"duplicate_groups":[{"members":["Mira","Captain Vale"],"canonical_name":"Captain Vale"}]}`,
|
||||
}}
|
||||
normalizer := newNormalizer(t, client)
|
||||
doc := semanticDocument()
|
||||
input := dnd.NPCList{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":["Mira","Broken"],"canonical_name":"Mira"}]}`}
|
||||
normalizer := newNormalizer(t, client)
|
||||
doc := semanticDocument()
|
||||
input := dnd.NPCList{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_unknown") || result.Value.NPCs[1].Name != "Broken" {
|
||||
t.Fatalf("result = %#v, want ineligible record excluded but preserved", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposalValidationRejectsUnsafeCategories(t *testing.T) {
|
||||
records := []normalizedRecord{
|
||||
{npc: dnd.NPC{Name: "Mira"}, inputIndexes: []int{0}, earliest: 0},
|
||||
{npc: dnd.NPC{Name: "Mira Thorn"}, inputIndexes: []int{1}, earliest: 1},
|
||||
{npc: dnd.NPC{Name: "Captain Vale"}, inputIndexes: []int{2}, earliest: 2},
|
||||
}
|
||||
for _, proposal := range []normalizeProposalGroup{
|
||||
{Members: []string{"Mira"}, CanonicalName: "Mira"},
|
||||
{Members: []string{"Mira", "Mira"}, CanonicalName: "Mira"},
|
||||
{Members: []string{"Mira", "Mira Thorn"}, CanonicalName: "Unknown"},
|
||||
{Members: []string{"Mira", "Mira Thorn"}, CanonicalName: " "},
|
||||
{Members: []string{" ", "Mira Thorn"}, CanonicalName: "Mira Thorn"},
|
||||
{Members: []string{"Mira", "Mira Thorn"}, CanonicalName: "Captain Vale"},
|
||||
} {
|
||||
assessment := assessProposal(normalizeProposalResponse{DuplicateGroups: []normalizeProposalGroup{proposal}}, records, []int{0, 1, 2})
|
||||
if assessment.discardedGroups != 1 || len(assessment.safeGroups) != 0 {
|
||||
t.Fatalf("assessment for %#v = %#v, want discarded unsafe group", proposal, assessment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposalResolutionUsesOnlyExistingComparisonKeyEquivalences(t *testing.T) {
|
||||
records := []normalizedRecord{
|
||||
{npc: dnd.NPC{Name: "O'Neill"}, inputIndexes: []int{0}, earliest: 0},
|
||||
{npc: dnd.NPC{Name: "Mira Thorn"}, inputIndexes: []int{1}, earliest: 1},
|
||||
}
|
||||
assessment := assessProposal(normalizeProposalResponse{DuplicateGroups: []normalizeProposalGroup{{
|
||||
Members: []string{" O’NEILL ", "Mira Thorn"}, CanonicalName: "Mira Thorn",
|
||||
}}}, records, []int{0, 1})
|
||||
if assessment.discardedGroups != 0 || len(assessment.safeGroups) != 1 || assessment.safeGroups[0].canonical != 1 {
|
||||
t.Fatalf("assessment = %#v, want comparison-key-only resolution", assessment)
|
||||
}
|
||||
}
|
||||
|
||||
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 cloneNPCList(input dnd.NPCList) dnd.NPCList {
|
||||
output := dnd.NPCList{NPCs: make([]dnd.NPC, len(input.NPCs))}
|
||||
for index, npc := range input.NPCs {
|
||||
output.NPCs[index] = cloneNPC(npc)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user