143 lines
7.5 KiB
Go
143 lines
7.5 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"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
|
)
|
|
|
|
func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
|
|
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
|
|
{
|
|
Name: "Captain Vale", Aliases: []string{"The Captain"}, Description: "A road captain.",
|
|
Relationships: []npcRelationshipResponse{{Target: "Mira Thorn", Relationship: "reports to"}},
|
|
SourceRefs: responseSourceRefs(3, 3),
|
|
},
|
|
{
|
|
Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, Description: "A guarded ranger.",
|
|
Relationships: []npcRelationshipResponse{{Target: "Captain Vale", Relationship: "commands"}},
|
|
SourceRefs: []npcSourceRefResponse{
|
|
{StartUnitID: sharedUnitRef(2), EndUnitID: sharedUnitRef(2)},
|
|
{StartUnitID: sharedUnitRef(1), EndUnitID: sharedUnitRef(2)},
|
|
{StartUnitID: sharedUnitRef(1), EndUnitID: sharedUnitRef(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", Aliases: []string{"The Greencloak"}, Description: "A guarded ranger.", Relationships: []dnd.NPCRelationship{{Target: "Captain Vale", Relationship: "commands"}}, 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", Aliases: []string{"The Captain"}, Description: "A road captain.", Relationships: []dnd.NPCRelationship{{Target: "Mira Thorn", Relationship: "reports to"}}, 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", Aliases: []string{}, Description: "Appears later.", Relationships: []npcRelationshipResponse{},
|
|
SourceRefs: responseSourceRefs(10, 10),
|
|
},
|
|
{
|
|
Name: "Earlier NPC", Aliases: []string{}, Description: "Appears first.", Relationships: []npcRelationshipResponse{},
|
|
SourceRefs: []npcSourceRefResponse{
|
|
{StartUnitID: sharedUnitRef(50), EndUnitID: sharedUnitRef(50)},
|
|
{StartUnitID: sharedUnitRef(100), EndUnitID: sharedUnitRef(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 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: "", Aliases: nil, Description: "", Relationships: nil,
|
|
SourceRefs: []npcSourceRefResponse{{StartUnitID: sharedUnitRef(99), EndUnitID: shared.UnitRefFromString("missing")}, {StartUnitID: sharedUnitRef(99), EndUnitID: shared.UnitRefFromInt(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 != "" || result.Value.NPCs[0].Aliases != nil || result.Value.NPCs[0].Relationships != nil {
|
|
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 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 := 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)
|
|
}
|
|
}
|
|
|
|
func sharedUnitRef(value int) shared.UnitRef { return shared.UnitRefFromInt(value) }
|