Use contextual descriptors for entity reconciliation
This commit is contained in:
@@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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":[]}]}`),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user