193 lines
8.7 KiB
Go
193 lines
8.7 KiB
Go
package npcs
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
|
)
|
|
|
|
func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
|
|
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
|
|
{
|
|
Name: "Captain Vale", SourceRefs: responseSourceRefs(3, 3),
|
|
},
|
|
{
|
|
Name: "Mira Thorn",
|
|
SourceRefs: []npcSourceRefResponse{
|
|
{StartUnitID: 2, EndUnitID: 2},
|
|
{StartUnitID: 1, EndUnitID: 2},
|
|
{StartUnitID: 1, EndUnitID: 2},
|
|
},
|
|
},
|
|
}}}
|
|
|
|
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
|
if err != nil {
|
|
t.Fatalf("Extract() error = %v, want nil", err)
|
|
}
|
|
want := dnd.NPCList{NPCs: []dnd.NPC{
|
|
{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}, {SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}},
|
|
{ID: identity.DeriveID("Captain Vale"), Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}},
|
|
}}
|
|
if !reflect.DeepEqual(result.Value, want) {
|
|
t.Fatalf("Value = %#v, want %#v", result.Value, want)
|
|
}
|
|
if len(client.requests) != 1 {
|
|
t.Fatalf("LLM calls = %d, want 1", len(client.requests))
|
|
}
|
|
request := client.requests[0]
|
|
if request.StageName != Key || request.PromptID != PromptID || request.PromptVersion != SchemaVersion || request.ProfileID != "profile-npcs" || request.SessionID != "session-123" {
|
|
t.Fatalf("LLM request identity = %#v", request)
|
|
}
|
|
transcript := request.Inputs["transcript"]
|
|
if transcript.Name != "transcript" || transcript.MediaType != "application/json" || transcript.Digest != "sha256:chunk" || transcript.OriginURI != "file:///session-alpha.json" || string(transcript.Content) != string(extractionRequest().Chunk.Content) {
|
|
t.Fatalf("transcript input = %#v, want chunk-scoped material", transcript)
|
|
}
|
|
}
|
|
|
|
func TestExtractOrdersNPCsBySourcePositionRatherThanUnitID(t *testing.T) {
|
|
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
|
|
{
|
|
Name: "Later NPC",
|
|
SourceRefs: responseSourceRefs(10, 10),
|
|
},
|
|
{
|
|
Name: "Earlier NPC",
|
|
SourceRefs: []npcSourceRefResponse{
|
|
{StartUnitID: 50, EndUnitID: 50},
|
|
{StartUnitID: 100, EndUnitID: 100},
|
|
},
|
|
},
|
|
}}}
|
|
req := extractionRequest()
|
|
req.Source.Units = []source.SourceUnit{
|
|
{ID: 100, Kind: "transcript_segment", Text: "Earlier NPC appears."},
|
|
{ID: 10, Kind: "transcript_segment", Text: "Later NPC appears."},
|
|
{ID: 50, Kind: "transcript_segment", Text: "Earlier NPC appears again."},
|
|
}
|
|
req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...)
|
|
req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 100, EndUnitID: 50}
|
|
|
|
result, err := newExtractor(t, client).Extract(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Extract() error = %v, want nil", err)
|
|
}
|
|
if len(result.Value.NPCs) != 2 || result.Value.NPCs[0].Name != "Earlier NPC" || result.Value.NPCs[1].Name != "Later NPC" {
|
|
t.Fatalf("NPC order = %#v, want source-document order", result.Value.NPCs)
|
|
}
|
|
}
|
|
|
|
func TestExtractUsesDocumentOrderForNPCReferencesAndStableTies(t *testing.T) {
|
|
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
|
|
{Name: "Later", SourceRefs: responseSourceRefs(10, 10)},
|
|
{Name: "First", SourceRefs: []npcSourceRefResponse{
|
|
{StartUnitID: 10, EndUnitID: 10},
|
|
{StartUnitID: 30, EndUnitID: 30},
|
|
{StartUnitID: 30, EndUnitID: 30},
|
|
{StartUnitID: 999, EndUnitID: 0},
|
|
}},
|
|
{Name: "Second", SourceRefs: responseSourceRefs(30, 30)},
|
|
}}}
|
|
req := extractionRequest()
|
|
req.Source.Units = []source.SourceUnit{{ID: 30}, {ID: 10}}
|
|
req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...)
|
|
req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 30, EndUnitID: 10}
|
|
|
|
result, err := newExtractor(t, client).Extract(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Extract() error = %v", err)
|
|
}
|
|
if got := []string{result.Value.NPCs[0].Name, result.Value.NPCs[1].Name, result.Value.NPCs[2].Name}; !reflect.DeepEqual(got, []string{"First", "Second", "Later"}) {
|
|
t.Fatalf("NPC order = %#v, want document chronology with stable equal-evidence ties", got)
|
|
}
|
|
refs := result.Value.NPCs[0].SourceRefs
|
|
if got := []int{refs[0].StartUnitID, refs[1].StartUnitID, refs[2].StartUnitID}; !reflect.DeepEqual(got, []int{30, 10, 999}) {
|
|
t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs)
|
|
}
|
|
refs[0].StartUnitID = 777
|
|
for _, npc := range client.response.NPCs {
|
|
for _, ref := range npc.SourceRefs {
|
|
if ref.StartUnitID == 777 {
|
|
t.Fatal("result source references alias the model response")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtractPassesCampaignReferencesAsPromptInputs(t *testing.T) {
|
|
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}}
|
|
req := extractionRequest()
|
|
req.References = contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
|
"players": {Slot: contracts.ReferenceSlot{Name: "players"}, Items: []contracts.ReferenceItem{{SlotName: "players", Content: []byte("Dana: Mira")}}},
|
|
"party": {Slot: contracts.ReferenceSlot{Name: "party"}, Items: []contracts.ReferenceItem{{SlotName: "party", Content: []byte("Mira: ranger")}}},
|
|
"glossary": {Slot: contracts.ReferenceSlot{Name: "glossary"}, Items: []contracts.ReferenceItem{{SlotName: "glossary", Content: []byte("Greencloak: local title")}}},
|
|
}}
|
|
if _, err := newExtractor(t, client).Extract(context.Background(), req); err != nil {
|
|
t.Fatalf("Extract() error = %v, want nil", err)
|
|
}
|
|
inputs := client.requests[0].Inputs
|
|
if string(inputs["players"].Content) != "Dana: Mira" || string(inputs["party"].Content) != "Mira: ranger" || string(inputs["glossary"].Content) != "Greencloak: local title" {
|
|
t.Fatalf("reference inputs = %#v", inputs)
|
|
}
|
|
if strings.Contains(string(inputs["transcript"].Content), "local title") {
|
|
t.Fatal("transcript input contains reference content")
|
|
}
|
|
}
|
|
|
|
func TestExtractPreservesMalformedCandidatesForValidators(t *testing.T) {
|
|
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{{
|
|
Name: "",
|
|
SourceRefs: []npcSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
|
}}}}
|
|
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
|
if err != nil {
|
|
t.Fatalf("Extract() error = %v, want nil", err)
|
|
}
|
|
if len(result.Value.NPCs) != 1 || result.Value.NPCs[0].ID != "" || result.Value.NPCs[0].Name != "" {
|
|
t.Fatalf("malformed candidate = %#v, want invalid values preserved", result.Value)
|
|
}
|
|
if refs := result.Value.NPCs[0].SourceRefs; len(refs) != 1 || refs[0].SourceID != "session-alpha" || refs[0].StartUnitID != 99 || refs[0].EndUnitID != 0 {
|
|
t.Fatalf("malformed source refs = %#v, want invalid range preserved after exact deduplication", refs)
|
|
}
|
|
}
|
|
|
|
func TestExtractMapsRawSemanticCandidatesWithoutRepair(t *testing.T) {
|
|
client := &fakeNPCsLLMClient{content: []byte(`{"npcs":[{"name":"","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)}
|
|
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
|
if err != nil {
|
|
t.Fatalf("Extract() error = %v, want nil", err)
|
|
}
|
|
npc := result.Value.NPCs[0]
|
|
if npc.ID != "" || npc.Name != "" {
|
|
t.Fatalf("NPC = %#v, want blank semantic values preserved", npc)
|
|
}
|
|
if refs := npc.SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 0, EndUnitID: -1}) {
|
|
t.Fatalf("source refs = %#v, want raw nonpositive candidates preserved", refs)
|
|
}
|
|
}
|
|
|
|
func TestExtractHandlesCancellationAndProviderErrors(t *testing.T) {
|
|
request := extractionRequest()
|
|
extractor := newExtractor(t, &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}})
|
|
canceled, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if _, err := extractor.Extract(canceled, request); err == nil || !strings.Contains(err.Error(), "context") {
|
|
t.Fatalf("canceled Extract() error = %v, want context error", err)
|
|
}
|
|
_, err := extractor.Extract(context.Background(), mismatchedSourceInputRequest(request))
|
|
if err == nil || !strings.Contains(err.Error(), "dnd npcs") || !strings.Contains(err.Error(), "must match chunk") {
|
|
t.Fatalf("source input error = %v, want contextual source input error", err)
|
|
}
|
|
_, err = newExtractor(t, &fakeNPCsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), request)
|
|
if err == nil || !strings.Contains(err.Error(), "dnd npcs") || !strings.Contains(err.Error(), "provider unavailable") {
|
|
t.Fatalf("provider Extract() error = %v, want contextual provider error", err)
|
|
}
|
|
}
|