diff --git a/assets/dnd/entity-reconciliation/schemas/dnd_entity_reconcile_llm.v1.json b/assets/dnd/entity-reconciliation/schemas/dnd_entity_reconcile_llm.v1.json index 49fe3e7..a251924 100644 --- a/assets/dnd/entity-reconciliation/schemas/dnd_entity_reconcile_llm.v1.json +++ b/assets/dnd/entity-reconciliation/schemas/dnd_entity_reconcile_llm.v1.json @@ -14,9 +14,32 @@ "properties": { "members": { "type": "array", - "items": {"type": "string"} + "items": {"$ref": "#/$defs/selector"} }, - "canonical": {"type": "string"} + "canonical": {"$ref": "#/$defs/selector"} + } + } + } + }, + "$defs": { + "selector": { + "type": "object", + "additionalProperties": false, + "required": ["name", "source_refs"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "source_refs": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["start_unit_id", "end_unit_id"], + "properties": { + "start_unit_id": {"type": "integer", "minimum": 1}, + "end_unit_id": {"type": "integer", "minimum": 1} + } + } } } } diff --git a/assets/dnd/shared/prompts/common-dnd-entity-reconciliation.md b/assets/dnd/shared/prompts/common-dnd-entity-reconciliation.md index 04f60df..c45ff9b 100644 --- a/assets/dnd/shared/prompts/common-dnd-entity-reconciliation.md +++ b/assets/dnd/shared/prompts/common-dnd-entity-reconciliation.md @@ -1,6 +1,7 @@ Identify only well-supported duplicate groups among the supplied candidates. -Candidate keys are opaque identifiers. Copy each selected key exactly. A group -must contain at least two supplied keys, and its `canonical` key must be one of -its members. Do not create keys, records, names, source references, evidence, -or replacement values. Omit any uncertain or unsafe group. +Return each selected candidate's supplied contextual descriptor exactly: its +`name` and complete ordered `source_refs`. A group must contain at least two +supplied descriptors, and its `canonical` descriptor must be one of its +members. Do not invent names, ranges, records, evidence, or replacement values. +Omit any uncertain or unsafe group. diff --git a/internal/modules/dnd/normalize/itemregistry/normalizer.go b/internal/modules/dnd/normalize/itemregistry/normalizer.go index e505106..8c0192a 100644 --- a/internal/modules/dnd/normalize/itemregistry/normalizer.go +++ b/internal/modules/dnd/normalize/itemregistry/normalizer.go @@ -23,7 +23,7 @@ import ( const ( Key = "dnd/item-registry" PromptID = "dnd.item_registry.normalize" - normalizationPolicy = "dnd.item_registry.normalize.v1" + normalizationPolicy = "dnd.item_registry.normalize.v2" semanticContextPolicy = "dnd.entity_reconcile.context.v1" semanticContextRadius = 2 NormalizationPolicy = normalizationPolicy diff --git a/internal/modules/dnd/normalize/itemregistry/normalizer_test.go b/internal/modules/dnd/normalize/itemregistry/normalizer_test.go index 408a2af..a4272f4 100644 --- a/internal/modules/dnd/normalize/itemregistry/normalizer_test.go +++ b/internal/modules/dnd/normalize/itemregistry/normalizer_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "reflect" + "strconv" "strings" "testing" "time" @@ -101,7 +102,7 @@ func TestNormalizeAppliesSafeAliasProposal(t *testing.T) { t.Fatalf("merged item = %#v, warnings = %#v", merged, result.Warnings) } encoded := string(client.requests[0].Inputs["candidates"].Content) + string(client.requests[0].Inputs["transcript"].Content) - if strings.Contains(encoded, doc.ID) || !strings.Contains(encoded, "candidate-000001") || strings.Contains(encoded, merged.ID) { + if strings.Contains(encoded, doc.ID) || strings.Contains(encoded, "candidate-") || strings.Contains(encoded, merged.ID) || !strings.Contains(encoded, `"source_refs"`) { t.Fatalf("private inputs = %s", encoded) } } @@ -261,7 +262,7 @@ func TestRegisterPromptAssetsPreparesItemNormalizationPrompt(t *testing.T) { if err != nil { t.Fatal(err) } - prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "item-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"key":"candidate-000001","name":"Rope","source_refs":[]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}}) + prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "item-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"name":"Rope","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}}) if err != nil { t.Fatal(err) } @@ -285,10 +286,51 @@ func (c *recordingNormalizerClient) CompleteStructured(_ context.Context, reques if response == "" { response = `{"duplicate_groups":[]}` } - if err := json.Unmarshal([]byte(response), output); err != nil { + content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content) + if err != nil { return contracts.StructuredCompletionResponse{}, err } - return contracts.StructuredCompletionResponse{Content: json.RawMessage(response)}, nil + if err := json.Unmarshal(content, output); err != nil { + return contracts.StructuredCompletionResponse{}, err + } + return contracts.StructuredCompletionResponse{Content: content}, nil +} + +func contextualProposalResponse(response string, candidateContent []byte) ([]byte, error) { + if !strings.Contains(response, "candidate-") { + return []byte(response), nil + } + var selection struct { + DuplicateGroups []struct { + Members []string `json:"members"` + Canonical string `json:"canonical"` + } `json:"duplicate_groups"` + } + if err := json.Unmarshal([]byte(response), &selection); err != nil { + return nil, err + } + var candidates struct { + Candidates []entityreconcile.Selector `json:"candidates"` + } + if err := json.Unmarshal(candidateContent, &candidates); err != nil { + return nil, err + } + selector := func(key string) entityreconcile.Selector { + index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-")) + if err != nil || index < 1 || index > len(candidates.Candidates) { + return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}} + } + return candidates.Candidates[index-1].Clone() + } + proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))} + for index, group := range selection.DuplicateGroups { + members := make([]entityreconcile.Selector, len(group.Members)) + for memberIndex, key := range group.Members { + members[memberIndex] = selector(key) + } + proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)} + } + return json.Marshal(proposal) } func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer { diff --git a/internal/modules/dnd/normalize/locationregistry/normalizer.go b/internal/modules/dnd/normalize/locationregistry/normalizer.go index 5553c17..ea23bdb 100644 --- a/internal/modules/dnd/normalize/locationregistry/normalizer.go +++ b/internal/modules/dnd/normalize/locationregistry/normalizer.go @@ -23,7 +23,7 @@ import ( const ( Key = "dnd/location-registry" PromptID = "dnd.location_registry.normalize" - normalizationPolicy = "dnd.location_registry.normalize.v1" + normalizationPolicy = "dnd.location_registry.normalize.v2" semanticContextPolicy = "dnd.entity_reconcile.context.v1" semanticContextRadius = 2 NormalizationPolicy = normalizationPolicy diff --git a/internal/modules/dnd/normalize/locationregistry/normalizer_test.go b/internal/modules/dnd/normalize/locationregistry/normalizer_test.go index f337062..665862a 100644 --- a/internal/modules/dnd/normalize/locationregistry/normalizer_test.go +++ b/internal/modules/dnd/normalize/locationregistry/normalizer_test.go @@ -60,7 +60,7 @@ func TestNormalizePreparesOnlyExactDuplicatesAndRetainsSameNameAndNestedPlaces(t } } -func TestNormalizeAppliesSafeAliasGroupAndUsesOpaqueInputs(t *testing.T) { +func TestNormalizeAppliesSafeAliasGroupAndUsesContextualInputs(t *testing.T) { client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`} doc := semanticDocument() input := dnd.LocationRegistry{Locations: []dnd.Location{ @@ -77,7 +77,7 @@ func TestNormalizeAppliesSafeAliasGroupAndUsesOpaqueInputs(t *testing.T) { t.Fatalf("merged location = %#v, warnings = %#v", merged, result.Warnings) } encoded := string(client.requests[0].Inputs["candidates"].Content) + string(client.requests[0].Inputs["transcript"].Content) - if strings.Contains(encoded, doc.ID) || !strings.Contains(encoded, "candidate-000001") || strings.Contains(encoded, merged.ID) { + if strings.Contains(encoded, doc.ID) || strings.Contains(encoded, "candidate-") || strings.Contains(encoded, merged.ID) || !strings.Contains(encoded, `"source_refs"`) { t.Fatalf("private inputs = %s", encoded) } } diff --git a/internal/modules/dnd/normalize/locationregistry/prompt_assets_test.go b/internal/modules/dnd/normalize/locationregistry/prompt_assets_test.go index ad2a629..53ee279 100644 --- a/internal/modules/dnd/normalize/locationregistry/prompt_assets_test.go +++ b/internal/modules/dnd/normalize/locationregistry/prompt_assets_test.go @@ -28,7 +28,7 @@ func TestRegisterPromptAssetsPreparesLocationNormalizationPrompt(t *testing.T) { if err != nil { t.Fatal(err) } - prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "location-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"key":"candidate-000001","name":"The Tavern","source_refs":[]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}}) + prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "location-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"name":"The Tavern","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}}) if err != nil { t.Fatal(err) } @@ -40,14 +40,14 @@ func TestRegisterPromptAssetsPreparesLocationNormalizationPrompt(t *testing.T) { t.Fatalf("message %d cache = %#v", index, prepared.Messages[index].CacheControl) } } - if !strings.Contains(prepared.Messages[3].Content, "candidate-000001") || strings.Contains(prepared.Messages[3].Content, `"windows"`) { + if !strings.Contains(prepared.Messages[3].Content, `"The Tavern"`) || strings.Contains(prepared.Messages[3].Content, `"candidate-`) || strings.Contains(prepared.Messages[3].Content, `"windows"`) { t.Fatalf("candidate message = %q", prepared.Messages[3].Content) } - if !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, "candidate-000001") { + if !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, `"The Tavern"`) { t.Fatalf("transcript message = %q", prepared.Messages[4].Content) } for index, message := range prepared.Messages { - if index != 3 && strings.Contains(message.Content, "candidate-000001") { + if index != 3 && strings.Contains(message.Content, `"The Tavern"`) { t.Errorf("message %d unexpectedly rendered candidate input", index) } if index != 4 && strings.Contains(message.Content, `"windows"`) { diff --git a/internal/modules/dnd/normalize/locationregistry/test_helpers_test.go b/internal/modules/dnd/normalize/locationregistry/test_helpers_test.go index 08acb91..6a8e835 100644 --- a/internal/modules/dnd/normalize/locationregistry/test_helpers_test.go +++ b/internal/modules/dnd/normalize/locationregistry/test_helpers_test.go @@ -3,11 +3,14 @@ package locationregistry import ( "context" "encoding/json" + "strconv" + "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/shared/entityreconcile" ) type recordingLocationNormalizerClient struct { @@ -25,10 +28,51 @@ func (c *recordingLocationNormalizerClient) CompleteStructured(_ context.Context if response == "" { response = `{"duplicate_groups":[]}` } - if err := json.Unmarshal([]byte(response), output); err != nil { + content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content) + if err != nil { return contracts.StructuredCompletionResponse{}, err } - return contracts.StructuredCompletionResponse{Content: json.RawMessage(response)}, nil + if err := json.Unmarshal(content, output); err != nil { + return contracts.StructuredCompletionResponse{}, err + } + return contracts.StructuredCompletionResponse{Content: content}, nil +} + +func contextualProposalResponse(response string, candidateContent []byte) ([]byte, error) { + if !strings.Contains(response, "candidate-") { + return []byte(response), nil + } + var selection struct { + DuplicateGroups []struct { + Members []string `json:"members"` + Canonical string `json:"canonical"` + } `json:"duplicate_groups"` + } + if err := json.Unmarshal([]byte(response), &selection); err != nil { + return nil, err + } + var candidates struct { + Candidates []entityreconcile.Selector `json:"candidates"` + } + if err := json.Unmarshal(candidateContent, &candidates); err != nil { + return nil, err + } + selector := func(key string) entityreconcile.Selector { + index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-")) + if err != nil || index < 1 || index > len(candidates.Candidates) { + return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}} + } + return candidates.Candidates[index-1].Clone() + } + proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))} + for index, group := range selection.DuplicateGroups { + members := make([]entityreconcile.Selector, len(group.Members)) + for memberIndex, key := range group.Members { + members[memberIndex] = selector(key) + } + proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)} + } + return json.Marshal(proposal) } func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer { diff --git a/internal/modules/dnd/normalize/npcregistry/normalizer.go b/internal/modules/dnd/normalize/npcregistry/normalizer.go index d23edf1..5d9cd0a 100644 --- a/internal/modules/dnd/normalize/npcregistry/normalizer.go +++ b/internal/modules/dnd/normalize/npcregistry/normalizer.go @@ -23,7 +23,7 @@ import ( const ( Key = "dnd/npc-registry" PromptID = "dnd.npc_registry.normalize" - normalizationPolicy = "dnd.npc_registry.normalize.v3" + normalizationPolicy = "dnd.npc_registry.normalize.v4" semanticContextPolicy = "dnd.entity_reconcile.context.v1" NormalizationPolicy = normalizationPolicy diff --git a/internal/modules/dnd/normalize/npcregistry/normalizer_test.go b/internal/modules/dnd/normalize/npcregistry/normalizer_test.go index 1ab515a..125f809 100644 --- a/internal/modules/dnd/normalize/npcregistry/normalizer_test.go +++ b/internal/modules/dnd/normalize/npcregistry/normalizer_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "reflect" + "strconv" "strings" "testing" @@ -156,10 +157,51 @@ func (c *recordingNPCNormalizerClient) CompleteStructured(_ context.Context, req if response == "" { response = `{"duplicate_groups":[]}` } - if err := json.Unmarshal([]byte(response), output); err != nil { + content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content) + if err != nil { return contracts.StructuredCompletionResponse{}, err } - return contracts.StructuredCompletionResponse{Content: json.RawMessage(response)}, nil + if err := json.Unmarshal(content, output); err != nil { + return contracts.StructuredCompletionResponse{}, err + } + return contracts.StructuredCompletionResponse{Content: content}, nil +} + +func contextualProposalResponse(response string, candidateContent []byte) ([]byte, error) { + if !strings.Contains(response, "candidate-") { + return []byte(response), nil + } + var selection struct { + DuplicateGroups []struct { + Members []string `json:"members"` + Canonical string `json:"canonical"` + } `json:"duplicate_groups"` + } + if err := json.Unmarshal([]byte(response), &selection); err != nil { + return nil, err + } + var candidates struct { + Candidates []entityreconcile.Selector `json:"candidates"` + } + if err := json.Unmarshal(candidateContent, &candidates); err != nil { + return nil, err + } + selector := func(key string) entityreconcile.Selector { + index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-")) + if err != nil || index < 1 || index > len(candidates.Candidates) { + return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}} + } + return candidates.Candidates[index-1].Clone() + } + proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))} + for index, group := range selection.DuplicateGroups { + members := make([]entityreconcile.Selector, len(group.Members)) + for memberIndex, key := range group.Members { + members[memberIndex] = selector(key) + } + proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)} + } + return json.Marshal(proposal) } func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer { diff --git a/internal/modules/dnd/normalize/npcregistry/prompt_assets_test.go b/internal/modules/dnd/normalize/npcregistry/prompt_assets_test.go index ca24535..330a754 100644 --- a/internal/modules/dnd/normalize/npcregistry/prompt_assets_test.go +++ b/internal/modules/dnd/normalize/npcregistry/prompt_assets_test.go @@ -36,7 +36,7 @@ func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) { 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":[]}]}`), + "candidates": promptkit.Inline(`{"candidates":[{"name":"Mira","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`), }, }) diff --git a/internal/modules/dnd/normalize/npcregistry/semantic_normalizer_test.go b/internal/modules/dnd/normalize/npcregistry/semantic_normalizer_test.go index a308071..faab635 100644 --- a/internal/modules/dnd/normalize/npcregistry/semantic_normalizer_test.go +++ b/internal/modules/dnd/normalize/npcregistry/semantic_normalizer_test.go @@ -2,6 +2,7 @@ package npcregistry import ( "context" + "encoding/json" "errors" "math" "reflect" @@ -174,8 +175,8 @@ func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) { } } -func TestNormalizeExcludesIneligibleRecordsFromSemanticGroups(t *testing.T) { - client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`} +func TestNormalizeRetainsRecordsWhenAProposalUsesAnUnknownDescriptor(t *testing.T) { + client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","unknown"],"canonical":"candidate-000001"}]}`} normalizer := newNormalizer(t, client) doc := semanticDocument() input := dnd.NPCRegistry{NPCs: []dnd.NPC{ @@ -187,8 +188,8 @@ func TestNormalizeExcludesIneligibleRecordsFromSemanticGroups(t *testing.T) { 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) + if !strings.Contains(result.Retry.Message, "member_unknown") || result.Value.NPCs[1].Name != "Broken" { + t.Fatalf("result = %#v, want unknown descriptor rejected without dropping a record", result) } } @@ -206,8 +207,14 @@ func TestReconciliationCandidatesKeepEqualDisplayNamesDistinct(t *testing.T) { 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) } + var candidateInput struct { + Candidates []entityreconcile.Selector `json:"candidates"` + } + if err := json.Unmarshal(materials.Candidates.Content, &candidateInput); err != nil { + t.Fatal(err) + } assessment := materials.Assess(entityreconcile.ProposalResponse{DuplicateGroups: []entityreconcile.DuplicateGroup{{ - Members: keys, Canonical: keys[1], + Members: candidateInput.Candidates, Canonical: candidateInput.Candidates[1], }}}) groups := reconciliationGroups(assessment, keys) if assessment.DiscardedGroups() != 0 || len(groups) != 1 || groups[0].canonical != 1 { diff --git a/internal/modules/dnd/shared/entityreconcile/context.go b/internal/modules/dnd/shared/entityreconcile/context.go index 3840cd1..34e2ca6 100644 --- a/internal/modules/dnd/shared/entityreconcile/context.go +++ b/internal/modules/dnd/shared/entityreconcile/context.go @@ -22,13 +22,34 @@ type Candidate struct { SourceRefs []source.SourceRef } +// Selector identifies one candidate through its canonical name and source-free +// evidence ranges. It is the complete model-facing candidate descriptor. +type Selector struct { + Name string `json:"name"` + SourceRefs []SourceRange `json:"source_refs"` +} + +// Clone returns an owned copy of the selector. +func (s Selector) Clone() Selector { + s.SourceRefs = cloneSourceRanges(s.SourceRefs) + return s +} + +// SourceRange is a source-free evidence coordinate used in a selector. +type SourceRange struct { + StartUnitID int `json:"start_unit_id"` + EndUnitID int `json:"end_unit_id"` +} + // Materials contains owned prompt inputs and opaque candidate-key mappings. type Materials struct { Candidates contracts.LLMInputMaterial Transcript contracts.LLMInputMaterial - candidateKeys []string - eligible map[string]struct{} + candidateKeys []string + eligible map[string]struct{} + keyBySelector map[string]string + collidedSelectors map[string]struct{} } // CandidateKeys returns all deterministic keys in candidate input order. @@ -49,18 +70,7 @@ func (m Materials) EligibleCandidateKeys() []string { } type candidateInput struct { - Candidates []candidateView `json:"candidates"` -} - -type candidateView struct { - Key string `json:"key"` - Name string `json:"name"` - SourceRefs []candidateSourceRef `json:"source_refs"` -} - -type candidateSourceRef struct { - StartUnitID int `json:"start_unit_id"` - EndUnitID int `json:"end_unit_id"` + Candidates []Selector `json:"candidates"` } type transcriptInput struct { @@ -91,8 +101,10 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int return Materials{}, false, fmt.Errorf("build entity reconciliation context: radius must not be negative") } materials := Materials{ - candidateKeys: make([]string, len(candidates)), - eligible: make(map[string]struct{}), + candidateKeys: make([]string, len(candidates)), + eligible: make(map[string]struct{}), + keyBySelector: make(map[string]string), + collidedSelectors: make(map[string]struct{}), } for index := range candidates { key := fmt.Sprintf(candidateKeyFormat, index+1) @@ -103,7 +115,15 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int } index := source.NewDocumentIndex(doc) - views := make([]candidateView, 0, len(candidates)) + type preparedCandidate struct { + key string + selector Selector + intervals []sourceInterval + lookupKey string + } + prepared := make([]preparedCandidate, 0, len(candidates)) + selectorCounts := make(map[string]int, len(candidates)) + views := make([]Selector, 0, len(candidates)) intervals := make([]sourceInterval, 0) cited := make([]bool, len(doc.Units)) for candidateIndex, candidate := range candidates { @@ -112,9 +132,23 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int continue } key := materials.candidateKeys[candidateIndex] - materials.eligible[key] = struct{}{} - views = append(views, candidateView{Key: key, Name: candidate.Name, SourceRefs: references}) - for _, interval := range candidateIntervals { + selector := Selector{Name: candidate.Name, SourceRefs: references} + lookupKey, err := selectorLookupKey(selector) + if err != nil { + return Materials{}, false, fmt.Errorf("build entity reconciliation context: invalid candidate material") + } + prepared = append(prepared, preparedCandidate{key: key, selector: selector, intervals: candidateIntervals, lookupKey: lookupKey}) + selectorCounts[lookupKey]++ + } + for _, candidate := range prepared { + if selectorCounts[candidate.lookupKey] != 1 { + materials.collidedSelectors[candidate.lookupKey] = struct{}{} + continue + } + materials.eligible[candidate.key] = struct{}{} + materials.keyBySelector[candidate.lookupKey] = candidate.key + views = append(views, candidate.selector.Clone()) + for _, interval := range candidate.intervals { for position := interval.start; position <= interval.end; position++ { cited[position] = true } @@ -145,24 +179,56 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int return materials, true, nil } -func candidateReferences(index source.DocumentIndex, refs []source.SourceRef) ([]candidateSourceRef, []sourceInterval, bool) { +func candidateReferences(index source.DocumentIndex, refs []source.SourceRef) ([]SourceRange, []sourceInterval, bool) { if len(refs) == 0 { return nil, nil, false } - references := make([]candidateSourceRef, 0, len(refs)) - intervals := make([]sourceInterval, 0, len(refs)) + type referencedInterval struct { + reference SourceRange + interval sourceInterval + } + prepared := make([]referencedInterval, 0, len(refs)) for _, ref := range refs { if err := index.ValidateRef(ref); err != nil { return nil, nil, false } start, _ := index.Position(ref.StartUnitID) end, _ := index.Position(ref.EndUnitID) - references = append(references, candidateSourceRef{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}) - intervals = append(intervals, sourceInterval{start: start, end: end}) + prepared = append(prepared, referencedInterval{reference: SourceRange{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}, interval: sourceInterval{start: start, end: end}}) + } + sort.Slice(prepared, func(left, right int) bool { + if prepared[left].interval.start != prepared[right].interval.start { + return prepared[left].interval.start < prepared[right].interval.start + } + return prepared[left].interval.end < prepared[right].interval.end + }) + references := make([]SourceRange, 0, len(prepared)) + intervals := make([]sourceInterval, 0, len(prepared)) + for _, item := range prepared { + if len(references) > 0 && references[len(references)-1] == item.reference { + continue + } + references = append(references, item.reference) + intervals = append(intervals, item.interval) } return references, intervals, true } +func selectorLookupKey(selector Selector) (string, error) { + content, err := json.Marshal(selector.Clone()) + if err != nil { + return "", err + } + return string(content), nil +} + +func cloneSourceRanges(values []SourceRange) []SourceRange { + if len(values) == 0 { + return []SourceRange{} + } + return append([]SourceRange(nil), values...) +} + func coalesceIntervals(intervals []sourceInterval) []sourceInterval { if len(intervals) == 0 { return nil diff --git a/internal/modules/dnd/shared/entityreconcile/entityreconcile_test.go b/internal/modules/dnd/shared/entityreconcile/entityreconcile_test.go index 5620c1d..767a764 100644 --- a/internal/modules/dnd/shared/entityreconcile/entityreconcile_test.go +++ b/internal/modules/dnd/shared/entityreconcile/entityreconcile_test.go @@ -13,7 +13,7 @@ import ( "github.com/santhosh-tekuri/jsonschema/v6" ) -func TestBuildContextUsesOpaqueKeysSourceOrderAndOwnedData(t *testing.T) { +func TestBuildContextUsesContextualSelectorsSourceOrderAndOwnedData(t *testing.T) { doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{ {ID: 40, Kind: "narration", Text: "zero"}, {ID: 10, Kind: "speech", Text: "one", Metadata: map[string]any{"speaker": map[string]any{"name": "Mira"}}}, @@ -52,10 +52,10 @@ func TestBuildContextUsesOpaqueKeysSourceOrderAndOwnedData(t *testing.T) { if err := json.Unmarshal(materials.Candidates.Content, &candidatePayload); err != nil { t.Fatal(err) } - if len(candidatePayload.Candidates) != 2 || candidatePayload.Candidates[0].Key != "candidate-000001" || candidatePayload.Candidates[1].Key != "candidate-000002" || candidatePayload.Candidates[0].Name != candidatePayload.Candidates[1].Name { - t.Fatalf("candidate payload = %#v, want distinct opaque keys for equal names", candidatePayload) + if len(candidatePayload.Candidates) != 2 || candidatePayload.Candidates[0].Name != candidatePayload.Candidates[1].Name || strings.Contains(string(materials.Candidates.Content), "candidate-") { + t.Fatalf("candidate payload = %#v, want contextual descriptors without keys", candidatePayload) } - if got := candidatePayload.Candidates[0].SourceRefs[0]; got != (candidateSourceRef{StartUnitID: 10, EndUnitID: 20}) { + if got := candidatePayload.Candidates[0].SourceRefs[0]; got != (SourceRange{StartUnitID: 10, EndUnitID: 20}) { t.Fatalf("candidate reference = %#v", got) } @@ -86,6 +86,50 @@ func TestBuildContextUsesOpaqueKeysSourceOrderAndOwnedData(t *testing.T) { } } +func TestBuildContextExcludesCollidingDescriptors(t *testing.T) { + doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}}} + candidates := []Candidate{ + {Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}, + {Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}, + {Name: "The Market", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}}, + } + materials, ready, err := BuildContext(doc, candidates, 0) + if err != nil || ready || len(materials.EligibleCandidateKeys()) != 1 || strings.Contains(string(materials.Candidates.Content), "The Tavern") { + t.Fatalf("BuildContext() = %#v, %t, %v", materials, ready, err) + } + assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{{ + Members: []Selector{{Name: "The Tavern", SourceRefs: []SourceRange{{StartUnitID: 1, EndUnitID: 1}}}, {Name: "The Market", SourceRefs: []SourceRange{{StartUnitID: 3, EndUnitID: 3}}}}, + Canonical: Selector{Name: "The Market", SourceRefs: []SourceRange{{StartUnitID: 3, EndUnitID: 3}}}, + }}}) + if !hasIssue(assessment.Issues(), "member_ineligible") { + t.Fatalf("Assess() issues = %#v, want collided descriptor rejection", assessment.Issues()) + } +} + +func TestAssessmentRejectsPartialAndReorderedDescriptors(t *testing.T) { + doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}} + materials, ready, err := BuildContext(doc, []Candidate{ + {Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, + {Name: "The Market", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}}, + }, 0) + if err != nil || !ready { + t.Fatalf("BuildContext() = %#v, %t, %v", materials, ready, err) + } + selectors := materialSelectors(t, materials) + for _, refs := range [][]SourceRange{ + {{StartUnitID: 10, EndUnitID: 10}}, + {{StartUnitID: 20, EndUnitID: 20}, {StartUnitID: 10, EndUnitID: 10}}, + } { + assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{{ + Members: []Selector{{Name: "The Tavern", SourceRefs: refs}, selectors[1]}, + Canonical: selectors[1], + }}}) + if !hasIssue(assessment.Issues(), "member_unknown") { + t.Fatalf("Assess(%#v) issues = %#v, want descriptor mismatch rejection", refs, assessment.Issues()) + } + } +} + func TestBuildContextExcludesUnsafeReferencesAndCoalescesAdjacentWindows(t *testing.T) { doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 9}, {ID: 3}, {ID: 8}, {ID: 1}, {ID: 7}}} candidates := []Candidate{ @@ -116,22 +160,21 @@ func TestBuildContextExcludesUnsafeReferencesAndCoalescesAdjacentWindows(t *test func TestAssessmentRejectsEveryUnsafeProposalCategory(t *testing.T) { materials := preparedMaterials(t, 4, true) - keys := materials.CandidateKeys() + selectors := materialSelectors(t, materials) unsafe := []struct { name string response ProposalResponse category string }{ - {"blank member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{"", keys[1]}, Canonical: keys[1]}}}, "member_blank"}, - {"unknown member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{"candidate-999999", keys[1]}, Canonical: keys[1]}}}, "member_unknown"}, - {"ineligible member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[3]}, Canonical: keys[0]}}}, "member_ineligible"}, - {"repeated member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[0]}, Canonical: keys[0]}}}, "repeated_member"}, - {"too small", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0]}, Canonical: keys[0]}}}, "fewer_than_two_members"}, - {"canonical blank", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[1]}, Canonical: ""}}}, "canonical_blank"}, - {"canonical not member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[1]}, Canonical: keys[2]}}}, "canonical_not_member"}, + {"blank member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{{}, selectors[1]}, Canonical: selectors[1]}}}, "member_blank"}, + {"unknown member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{{Name: "unknown", SourceRefs: []SourceRange{{StartUnitID: 99, EndUnitID: 99}}}, selectors[1]}, Canonical: selectors[1]}}}, "member_unknown"}, + {"repeated member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[0]}, Canonical: selectors[0]}}}, "repeated_member"}, + {"too small", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0]}, Canonical: selectors[0]}}}, "fewer_than_two_members"}, + {"canonical blank", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[1]}, Canonical: Selector{}}}}, "canonical_blank"}, + {"canonical not member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[1]}, Canonical: selectors[2]}}}, "canonical_not_member"}, {"overlapping", ProposalResponse{DuplicateGroups: []DuplicateGroup{ - {Members: []string{keys[0], keys[1]}, Canonical: keys[0]}, - {Members: []string{keys[1], keys[2]}, Canonical: keys[2]}, + {Members: []Selector{selectors[0], selectors[1]}, Canonical: selectors[0]}, + {Members: []Selector{selectors[1], selectors[2]}, Canonical: selectors[2]}, }}, "overlapping_member"}, } for _, test := range unsafe { @@ -147,9 +190,10 @@ func TestAssessmentRejectsEveryUnsafeProposalCategory(t *testing.T) { func TestAssessmentReturnsNonOverlappingSafeGroupsAndDefensiveCopies(t *testing.T) { materials := preparedMaterials(t, 4, false) keys := materials.CandidateKeys() + selectors := materialSelectors(t, materials) assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{ - {Members: []string{keys[1], keys[0]}, Canonical: keys[1]}, - {Members: []string{keys[3], keys[2]}, Canonical: keys[2]}, + {Members: []Selector{selectors[1], selectors[0]}, Canonical: selectors[1]}, + {Members: []Selector{selectors[3], selectors[2]}, Canonical: selectors[2]}, }}) groups := assessment.SafeGroups() if assessment.DiscardedGroups() != 0 || len(assessment.Issues()) != 0 || len(groups) != 2 { @@ -183,12 +227,12 @@ func TestSharedResponseSchemaIsPrivateStrictAndRegisterableOnce(t *testing.T) { valid bool }{ {"empty groups", map[string]any{"duplicate_groups": []any{}}, true}, - {"semantic proposal problem", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{""}, "canonical": ""}}}, true}, + {"semantic proposal problem", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{map[string]any{"name": "Mira", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}}}}, "canonical": map[string]any{"name": "Mira", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}}}}}}, true}, {"missing groups", map[string]any{}, false}, {"unknown top level", map[string]any{"duplicate_groups": []any{}, "extra": true}, false}, - {"replacement name", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": "candidate-000001", "name": "replacement"}}}, false}, - {"replacement evidence", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": "candidate-000001", "source_refs": []any{}}}}, false}, - {"wrong key type", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{1}, "canonical": "candidate-000001"}}}, false}, + {"replacement name", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": map[string]any{"name": "Mira", "source_refs": []any{}}, "name": "replacement"}}}, false}, + {"missing selector evidence", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": map[string]any{"name": "Mira"}}}}, false}, + {"invalid range", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{map[string]any{"name": "Mira", "source_refs": []any{map[string]any{"start_unit_id": 0, "end_unit_id": 1}}}}, "canonical": map[string]any{"name": "Mira", "source_refs": []any{}}}}}, false}, } { t.Run(test.name, func(t *testing.T) { content, err := json.Marshal(test.value) @@ -236,6 +280,15 @@ func preparedMaterials(t *testing.T, count int, includeIneligible bool) Material return materials } +func materialSelectors(t *testing.T, materials Materials) []Selector { + t.Helper() + var input candidateInput + if err := json.Unmarshal(materials.Candidates.Content, &input); err != nil { + t.Fatal(err) + } + return input.Candidates +} + func cloneCandidates(input []Candidate) []Candidate { output := make([]Candidate, len(input)) copy(output, input) diff --git a/internal/modules/dnd/shared/entityreconcile/proposal.go b/internal/modules/dnd/shared/entityreconcile/proposal.go index b3dcd8a..829e7fe 100644 --- a/internal/modules/dnd/shared/entityreconcile/proposal.go +++ b/internal/modules/dnd/shared/entityreconcile/proposal.go @@ -6,15 +6,16 @@ import ( ) // ProposalResponse is the private structured response exchanged with the -// reconciliation prompt. It identifies candidates only by opaque keys. +// reconciliation prompt. It identifies candidates by contextual selectors. type ProposalResponse struct { DuplicateGroups []DuplicateGroup `json:"duplicate_groups"` } -// DuplicateGroup proposes candidate keys that might denote one entity. +// DuplicateGroup proposes contextual candidate descriptors that might denote +// one entity. type DuplicateGroup struct { - Members []string `json:"members"` - Canonical string `json:"canonical"` + Members []Selector `json:"members"` + Canonical Selector `json:"canonical"` } // Issue identifies one unsafe proposal category without prescribing a warning @@ -59,16 +60,13 @@ func (a Assessment) DiscardedGroups() int { return a.discardedGroups } // Issues returns the deterministic rejection categories in proposal order. func (a Assessment) Issues() []Issue { return append([]Issue(nil), a.issues...) } -// Assess validates a proposal against the opaque keys created by BuildContext. +// Assess resolves contextual descriptors to internal candidate keys, then +// validates the proposal without exposing those keys to the model. func (m Materials) Assess(response ProposalResponse) Assessment { - all := make(map[string]struct{}, len(m.candidateKeys)) - for _, key := range m.candidateKeys { - all[key] = struct{}{} - } groups := make([]assessedGroup, len(response.DuplicateGroups)) issues := make([]Issue, 0) for groupIndex, proposal := range response.DuplicateGroups { - groups[groupIndex] = assessGroup(proposal, all, m.eligible) + groups[groupIndex] = m.assessGroup(proposal) for _, category := range groups[groupIndex].issues { issues = append(issues, Issue{GroupIndex: groupIndex, Category: category}) } @@ -115,12 +113,13 @@ type assessedGroup struct { conflicting bool } -func assessGroup(proposal DuplicateGroup, all, eligible map[string]struct{}) assessedGroup { +func (m Materials) assessGroup(proposal DuplicateGroup) assessedGroup { issues := make([]string, 0) members := make([]string, 0, len(proposal.Members)) seen := make(map[string]struct{}, len(proposal.Members)) - for _, key := range proposal.Members { - if category := keyCategory(key, all, eligible); category != "" { + for _, selector := range proposal.Members { + key, category := m.selectorKey(selector) + if category != "" { issues = append(issues, "member_"+category) continue } @@ -131,31 +130,39 @@ func assessGroup(proposal DuplicateGroup, all, eligible map[string]struct{}) ass seen[key] = struct{}{} members = append(members, key) } - canonicalCategory := keyCategory(proposal.Canonical, all, eligible) + canonical, canonicalCategory := m.selectorKey(proposal.Canonical) if canonicalCategory != "" { issues = append(issues, "canonical_"+canonicalCategory) } if len(members) < 2 { issues = append(issues, "fewer_than_two_members") } - if canonicalCategory == "" && !contains(members, proposal.Canonical) { + if canonicalCategory == "" && !contains(members, canonical) { issues = append(issues, "canonical_not_member") } sort.Strings(members) - return assessedGroup{members: members, canonical: proposal.Canonical, issues: issues, locallyValid: len(issues) == 0} + return assessedGroup{members: members, canonical: canonical, issues: issues, locallyValid: len(issues) == 0} } -func keyCategory(key string, all, eligible map[string]struct{}) string { - if strings.TrimSpace(key) == "" { - return "blank" +func (m Materials) selectorKey(selector Selector) (string, string) { + if strings.TrimSpace(selector.Name) == "" { + return "", "blank" } - if _, ok := all[key]; !ok { - return "unknown" + lookupKey, err := selectorLookupKey(selector) + if err != nil { + return "", "unknown" } - if _, ok := eligible[key]; !ok { - return "ineligible" + if _, collided := m.collidedSelectors[lookupKey]; collided { + return "", "ineligible" } - return "" + key, ok := m.keyBySelector[lookupKey] + if !ok { + return "", "unknown" + } + if _, eligible := m.eligible[key]; !eligible { + return "", "ineligible" + } + return key, "" } func contains(values []string, want string) bool { diff --git a/internal/modules/integration/dnd_npc_grounded_test.go b/internal/modules/integration/dnd_npc_grounded_test.go index bdaf423..05f8f2a 100644 --- a/internal/modules/integration/dnd_npc_grounded_test.go +++ b/internal/modules/integration/dnd_npc_grounded_test.go @@ -51,7 +51,7 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T for name, value := range map[string]string{ "extract:npc_registry:dnd/npc-registry:mapping_policy": "dnd.npc_registry.extract_mapping.v2", "normalize:npc_registry:dnd/npc-registry:identity_policy": "dnd.npc_registry.identity.v1", - "normalize:npc_registry:dnd/npc-registry:normalization_policy": "dnd.npc_registry.normalize.v3", + "normalize:npc_registry:dnd/npc-registry:normalization_policy": "dnd.npc_registry.normalize.v4", "normalize:npc_registry:dnd/npc-registry:semantic_context_policy": "dnd.entity_reconcile.context.v1:2", "extract:spells:dnd/spells:mapping_policy": "dnd.spells.extract_mapping.v2", "extract:combat:dnd/combat-turns:scene_gate_policy": "dnd.combat_turns.scene_gate.v1", diff --git a/internal/modules/integration/dnd_npc_occurrences_runner_test.go b/internal/modules/integration/dnd_npc_occurrences_runner_test.go index 594626d..0e90b16 100644 --- a/internal/modules/integration/dnd_npc_occurrences_runner_test.go +++ b/internal/modules/integration/dnd_npc_occurrences_runner_test.go @@ -292,7 +292,14 @@ func (client *semanticNPCOccurrenceClient) CompleteStructured(_ context.Context, } payload = map[string]any{"npcs": []any{map[string]any{"name": name, "source_refs": []any{map[string]int{"start_unit_id": client.npcCalls, "end_unit_id": client.npcCalls}}}}} case npcnormalize.PromptID: - payload = map[string]any{"duplicate_groups": []any{map[string]any{"members": []string{"candidate-000001", "candidate-000002"}, "canonical": "candidate-000001"}}} + content, err := contextualReconciliationContent([]byte(`{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`), request.Inputs["candidates"].Content) + if err != nil { + return contracts.StructuredCompletionResponse{}, err + } + if err := json.Unmarshal(content, out); err != nil { + return contracts.StructuredCompletionResponse{}, err + } + return contracts.StructuredCompletionResponse{Content: content}, nil case occurrenceextract.PromptID: payload = map[string]any{"occurrences": []any{map[string]any{"npc_id": identity.DeriveID("Mira Thorn"), "name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}}} default: diff --git a/internal/modules/integration/dnd_npc_registry_runner_test.go b/internal/modules/integration/dnd_npc_registry_runner_test.go index 8e91089..89d713e 100644 --- a/internal/modules/integration/dnd_npc_registry_runner_test.go +++ b/internal/modules/integration/dnd_npc_registry_runner_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "strconv" "strings" "testing" @@ -266,6 +267,11 @@ func (client *fakeNPCProductionLLMClient) CompleteStructured(_ context.Context, } content = append([]byte(nil), client.normalizeResponses[index]...) } + var err error + content, err = contextualReconciliationContent(content, req.Inputs["candidates"].Content) + if err != nil { + return contracts.StructuredCompletionResponse{}, err + } default: return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected fake NPC prompt %q", req.PromptID) } @@ -275,6 +281,43 @@ func (client *fakeNPCProductionLLMClient) CompleteStructured(_ context.Context, return contracts.StructuredCompletionResponse{Content: content}, nil } +func contextualReconciliationContent(content, candidateContent []byte) ([]byte, error) { + if !strings.Contains(string(content), "candidate-") { + return content, nil + } + var selection struct { + DuplicateGroups []struct { + Members []string `json:"members"` + Canonical string `json:"canonical"` + } `json:"duplicate_groups"` + } + if err := json.Unmarshal(content, &selection); err != nil { + return nil, err + } + var candidates struct { + Candidates []entityreconcile.Selector `json:"candidates"` + } + if err := json.Unmarshal(candidateContent, &candidates); err != nil { + return nil, err + } + selector := func(key string) entityreconcile.Selector { + index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-")) + if err != nil || index < 1 || index > len(candidates.Candidates) { + return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}} + } + return candidates.Candidates[index-1].Clone() + } + proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))} + for index, group := range selection.DuplicateGroups { + members := make([]entityreconcile.Selector, len(group.Members)) + for memberIndex, key := range group.Members { + members[memberIndex] = selector(key) + } + proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)} + } + return json.Marshal(proposal) +} + func (client *fakeNPCProductionLLMClient) requestCount(promptID string) int { count := 0 for _, request := range client.requests {