Ground NPC occurrences by canonical names

This commit is contained in:
2026-08-08 14:37:54 +00:00
parent 516af12916
commit ece1bca460
18 changed files with 1088 additions and 98 deletions

View File

@@ -18,13 +18,13 @@ import (
func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
{NPCID: identity.DeriveID("Other"), Name: "Other", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
{NPCID: identity.DeriveID("Opponent"), Name: "Opponent", Kind: "combat_opponent", SourceRefs: occurrenceRefs(20, 20)},
{NPCID: identity.DeriveID("Ally"), Name: "Ally", Kind: "combat_ally", SourceRefs: occurrenceRefs(5, 5)},
{NPCID: identity.DeriveID("Speaker"), Name: "Speaker", Kind: "dialogue", SourceRefs: append(occurrenceRefs(2, 2), occurrenceRefs(2, 2)...)},
{NPCID: identity.DeriveID("Present"), Name: "Present", Kind: "noncombat_presence", SourceRefs: occurrenceRefs(7, 7)},
{NPCID: identity.DeriveID("Mentioned"), Name: "Mentioned", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
{NPCID: identity.DeriveID("Invalid"), Name: "Invalid", Kind: "unsupported", SourceRefs: occurrenceRefs(0, 0)},
{Name: "Other", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
{Name: "Opponent", Kind: "combat_opponent", SourceRefs: occurrenceRefs(20, 20)},
{Name: "Ally", Kind: "combat_ally", SourceRefs: occurrenceRefs(5, 5)},
{Name: "Speaker", Kind: "dialogue", SourceRefs: append(occurrenceRefs(2, 2), occurrenceRefs(2, 2)...)},
{Name: "Present", Kind: "noncombat_presence", SourceRefs: occurrenceRefs(7, 7)},
{Name: "Mentioned", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
{Name: "Invalid", Kind: "unsupported", SourceRefs: occurrenceRefs(0, 0)},
}}}
references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid")
req := extractionRequest()
@@ -51,6 +51,9 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
if refs := result.Value.Occurrences[1].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) {
t.Fatalf("canonical source refs = %#v", refs)
}
if id := result.Value.Occurrences[0].NPCID; id != identity.DeriveID("Mentioned") {
t.Fatalf("durable NPC ID = %q, want registry identity", id)
}
if invalid := result.Value.Occurrences[6]; invalid.Name != "Invalid" || invalid.Kind != "unsupported" || !reflect.DeepEqual(invalid.SourceRefs, []source.SourceRef{{SourceID: "session-alpha"}}) {
t.Fatalf("invalid candidate = %#v, want preserved values with current source identity", invalid)
}
@@ -61,14 +64,14 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
func TestExtractUsesDocumentOrderForReferencesAndOccurrences(t *testing.T) {
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
{NPCID: identity.DeriveID("Later"), Name: "Later", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)},
{NPCID: identity.DeriveID("First"), Name: "First", Kind: "mentioned", SourceRefs: []occurrenceSourceRefResponse{
{Name: "Later", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)},
{Name: "First", Kind: "mentioned", SourceRefs: []occurrenceSourceRefResponse{
{StartUnitID: 10, EndUnitID: 10},
{StartUnitID: 30, EndUnitID: 30},
{StartUnitID: 30, EndUnitID: 30},
{StartUnitID: 999, EndUnitID: 0},
}},
{NPCID: identity.DeriveID("Second"), Name: "Second", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
{Name: "Second", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
}}}
references := requiredRegistryReferences(t, "Later", "First", "Second")
req := extractionRequest()
@@ -107,9 +110,9 @@ func TestNewRequiresLLMAndRejectsAmbiguousReferenceSets(t *testing.T) {
}
}
func TestExtractUsesRegistryIDsAndCurrentTranscriptEvidence(t *testing.T) {
func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence(t *testing.T) {
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
NPCID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10),
Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10),
}}}}
references := requiredRegistryReferences(t, "Mira Thorn", "Hooded Guard")
req := extractionRequest()
@@ -119,10 +122,10 @@ func TestExtractUsesRegistryIDsAndCurrentTranscriptEvidence(t *testing.T) {
}
request := client.requests[0]
registry := request.Inputs[NPCRegistryReferenceSlot]
if registry.Name != NPCRegistryReferenceSlot || registry.MediaType != "application/json" || string(registry.Content) != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"},{"id":"`+identity.DeriveID("Hooded Guard")+`","name":"Hooded Guard"}]}` {
t.Fatalf("registry prompt input = %#v, want exact ID and name projection", registry)
if registry.Name != NPCRegistryReferenceSlot || registry.MediaType != "application/json" || string(registry.Content) != `{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}` {
t.Fatalf("registry prompt input = %#v, want names-only projection", registry)
}
for _, forbidden := range []string{"other-session", "start_unit_id"} {
for _, forbidden := range []string{"npc:sha256:", "other-session", "start_unit_id"} {
if strings.Contains(string(registry.Content), forbidden) {
t.Fatalf("registry prompt input leaked %q: %s", forbidden, registry.Content)
}
@@ -162,24 +165,37 @@ func TestExtractRequiresBoundRegistryBeforeLLMCall(t *testing.T) {
}
}
func TestExtractRejectsUnknownIDsAndMismatchedNames(t *testing.T) {
func TestExtractRejectsUnknownNamesWithoutPartialResult(t *testing.T) {
references := requiredRegistryReferences(t, "Mira Thorn")
for _, test := range []struct {
name string
occurrence occurrenceResponse
want string
}{
{"unknown ID", occurrenceResponse{NPCID: "npc:unknown", Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}, "npc_id is not in the NPC registry"},
{"mismatched name", occurrenceResponse{NPCID: identity.DeriveID("Mira Thorn"), Name: "Hooded Guard", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}, "name does not match npc_id"},
} {
t.Run(test.name, func(t *testing.T) {
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{test.occurrence}}}
req := extractionRequest()
req.References = references
if _, err := newExtractor(t, client, references).Extract(context.Background(), req); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Extract() error = %v, want %q", err, test.want)
}
})
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
{Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)},
{Name: "Unknown NPC", Kind: "mentioned", SourceRefs: occurrenceRefs(20, 20)},
}}}
req := extractionRequest()
req.References = references
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "name is not in the NPC registry") {
t.Fatalf("Extract() error = %v, want unknown name failure", err)
}
if len(result.Value.Occurrences) != 0 {
t.Fatalf("Extract() returned partial result = %#v", result.Value)
}
}
func TestExtractCanonicalizesComparisonEquivalentRegistryNames(t *testing.T) {
references := requiredRegistryReferences(t, "Mira Thorn")
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
Name: " mIRA\u2003thorn ", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10),
}}}}
req := extractionRequest()
req.References = references
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
if err != nil {
t.Fatalf("Extract() error = %v", err)
}
occurrence := result.Value.Occurrences[0]
if occurrence.Name != "Mira Thorn" || occurrence.NPCID != identity.DeriveID("Mira Thorn") {
t.Fatalf("canonical occurrence = %#v", occurrence)
}
}
@@ -192,7 +208,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) {
if _, err := extractor.Extract(context.Background(), req); err != nil {
t.Fatalf("Extract() error = %v", err)
}
if input := client.requests[0].Inputs[NPCRegistryReferenceSlot]; string(input.Content) != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"}]}` || input.OriginURI != "" {
if input := client.requests[0].Inputs[NPCRegistryReferenceSlot]; string(input.Content) != `{"npcs":[{"name":"Mira Thorn"}]}` || input.OriginURI != "" {
t.Fatalf("generated registry prompt input = %#v", input)
}
metadata := extractor.ManifestMetadata()
@@ -221,6 +237,24 @@ func TestExtractAcceptsEmptyBoundRegistryAndEmptyResponse(t *testing.T) {
}
}
func TestExtractRejectsNonemptyResponseForEmptyBoundRegistry(t *testing.T) {
content, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: []dnd.NPC{}})
if err != nil {
t.Fatal(err)
}
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{NPCRegistryReferenceSlot: {
Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot},
Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}},
}}}
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}}}}
req := extractionRequest()
req.References = references
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "name is not in the NPC registry") || len(result.Value.Occurrences) != 0 {
t.Fatalf("Extract() = %#v, %v; want no accepted occurrences", result, err)
}
}
func TestExtractRejectsInvalidRequestsAndProviderFailures(t *testing.T) {
references := requiredRegistryReferences(t, "Mira Thorn")
valid := extractionRequest()