371 lines
16 KiB
Go
371 lines
16 KiB
Go
package npcinteractions
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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/framework/pipeline"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
|
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
|
)
|
|
|
|
func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
|
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{
|
|
{Name: "Other", Kind: "other", SourceRefs: interactionRefs(30, 30)},
|
|
{Name: "Opponent", Kind: "combat_opponent", SourceRefs: interactionRefs(20, 20)},
|
|
{Name: "Ally", Kind: "combat_ally", SourceRefs: interactionRefs(5, 5)},
|
|
{Name: "Speaker", Kind: "dialogue", SourceRefs: append(interactionRefs(2, 2), interactionRefs(2, 2)...)},
|
|
{Name: "Present", Kind: "noncombat_presence", SourceRefs: interactionRefs(7, 7)},
|
|
{Name: "Mentioned", Kind: "mentioned", SourceRefs: interactionRefs(10, 10)},
|
|
{Name: "Invalid", Kind: "unsupported", SourceRefs: interactionRefs(0, 0)},
|
|
}}}
|
|
references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid")
|
|
req := extractionRequest()
|
|
req.References = references
|
|
|
|
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Extract() error = %v", err)
|
|
}
|
|
if got := interactionNames(result.Value); !reflect.DeepEqual(got, []string{"Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid"}) {
|
|
t.Fatalf("interaction order = %#v", got)
|
|
}
|
|
if got := interactionKinds(result.Value); !reflect.DeepEqual(got, []dnd.NPCInteractionKind{
|
|
dnd.NPCInteractionKindMentioned,
|
|
dnd.NPCInteractionKindDialogue,
|
|
dnd.NPCInteractionKindNoncombatPresence,
|
|
dnd.NPCInteractionKindCombatAlly,
|
|
dnd.NPCInteractionKindCombatOpponent,
|
|
dnd.NPCInteractionKindOther,
|
|
"unsupported",
|
|
}) {
|
|
t.Fatalf("interaction kinds = %#v", got)
|
|
}
|
|
if refs := result.Value.Interactions[1].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) {
|
|
t.Fatalf("canonical source refs = %#v", refs)
|
|
}
|
|
if invalid := result.Value.Interactions[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)
|
|
}
|
|
if len(client.requests) != 1 {
|
|
t.Fatalf("LLM calls = %d, want 1", len(client.requests))
|
|
}
|
|
}
|
|
|
|
func TestNewRequiresLLMAndRejectsAmbiguousReferenceSets(t *testing.T) {
|
|
if _, err := New(nil, Options{}); err == nil || !strings.Contains(err.Error(), "LLM client") {
|
|
t.Fatalf("New(nil) error = %v", err)
|
|
}
|
|
if _, err := New(&fakeInteractionsLLMClient{}, Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), "at most one reference set") {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence(t *testing.T) {
|
|
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{{
|
|
Name: "Mira Thorn", Kind: "dialogue", SourceRefs: interactionRefs(10, 10),
|
|
}}}}
|
|
references := requiredRegistryReferences(t, "Mira Thorn", "Hooded Guard")
|
|
req := extractionRequest()
|
|
req.References = references
|
|
if _, err := newExtractor(t, client, references).Extract(context.Background(), req); err != nil {
|
|
t.Fatalf("Extract() error = %v", err)
|
|
}
|
|
request := client.requests[0]
|
|
registry := request.Inputs[NPCRegistryReferenceSlot]
|
|
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 exact names-only projection", registry)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
if strings.Contains(string(request.Inputs["transcript"].Content), "other-session") {
|
|
t.Fatal("transcript input contains registry evidence")
|
|
}
|
|
metadata, err := json.Marshal(newExtractor(t, &fakeInteractionsLLMClient{}, references).ManifestMetadata())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(metadata), "Mira Thorn") || strings.Contains(string(metadata), "other-session") {
|
|
t.Fatalf("manifest metadata leaked registry content: %s", metadata)
|
|
}
|
|
}
|
|
|
|
func TestExtractRequiresBoundRegistryBeforeLLMCall(t *testing.T) {
|
|
client := &fakeInteractionsLLMClient{}
|
|
if _, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()); err == nil || !strings.Contains(err.Error(), "NPC registry reference is required") {
|
|
t.Fatalf("Extract() error = %v, want required registry failure", err)
|
|
}
|
|
if len(client.requests) != 0 {
|
|
t.Fatalf("LLM calls = %d, want no call", len(client.requests))
|
|
}
|
|
|
|
malformed := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
|
NPCRegistryReferenceSlot: {
|
|
Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot},
|
|
Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: "application/json", Content: []byte(`{"secret":"private source"}`)}},
|
|
},
|
|
}}
|
|
if _, err := New(client, Options{}, malformed); err == nil || !strings.Contains(err.Error(), "prepare NPC registry") || strings.Contains(err.Error(), "private source") {
|
|
t.Fatalf("New() error = %v, want content-safe malformed registry error", err)
|
|
}
|
|
if len(client.requests) != 0 {
|
|
t.Fatalf("LLM calls = %d, want no call", len(client.requests))
|
|
}
|
|
}
|
|
|
|
func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) {
|
|
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{}}}
|
|
references := requiredRegistryReferences(t, "Mira Thorn")
|
|
req := extractionRequest()
|
|
req.References = references
|
|
extractor := newExtractor(t, client)
|
|
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":[{"name":"Mira Thorn"}]}` || input.OriginURI != "" {
|
|
t.Fatalf("generated registry prompt input = %#v", input)
|
|
}
|
|
metadata := extractor.ManifestMetadata()
|
|
if _, ok := metadata["npc_registry_digest"]; ok {
|
|
t.Fatalf("operation-time registry leaked into static metadata: %#v", metadata)
|
|
}
|
|
}
|
|
|
|
func TestExtractAcceptsEmptyBoundRegistryAndEmptyResponse(t *testing.T) {
|
|
content, err := npccodec.New().Encode(dnd.NPCList{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 := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{}}}
|
|
req := extractionRequest()
|
|
req.References = references
|
|
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
|
|
if err != nil || result.Value.Interactions == nil || len(result.Value.Interactions) != 0 {
|
|
t.Fatalf("Extract() = %#v, %v; want present empty interactions", result, err)
|
|
}
|
|
}
|
|
|
|
func TestExtractRejectsInvalidRequestsAndProviderFailures(t *testing.T) {
|
|
references := requiredRegistryReferences(t, "Mira Thorn")
|
|
valid := extractionRequest()
|
|
valid.References = references
|
|
canceled, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
extractor := newExtractor(t, &fakeInteractionsLLMClient{}, references)
|
|
for _, test := range []struct {
|
|
name string
|
|
ctx context.Context
|
|
req contracts.TypedExtractionRequest
|
|
want string
|
|
}{
|
|
{"nil context", nil, valid, "context"},
|
|
{"canceled context", canceled, valid, "context"},
|
|
{"nil source", context.Background(), contracts.TypedExtractionRequest{Chunk: valid.Chunk, References: references}, "source"},
|
|
{"nil chunk", context.Background(), contracts.TypedExtractionRequest{Source: valid.Source, References: references}, "chunk"},
|
|
{"empty chunk", context.Background(), emptyChunkRequest(valid), "units"},
|
|
{"source input mismatch", context.Background(), mismatchedSourceInputRequest(valid), "must match chunk"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if _, err := extractor.Extract(test.ctx, test.req); err == nil || !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("Extract() error = %v, want %q", err, test.want)
|
|
}
|
|
})
|
|
}
|
|
if _, err := newExtractor(t, &fakeInteractionsLLMClient{err: errors.New("provider unavailable")}, references).Extract(context.Background(), valid); err == nil || !strings.Contains(err.Error(), "provider unavailable") {
|
|
t.Fatalf("provider error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestModuleSpecRegistrationMetadataAndFingerprints(t *testing.T) {
|
|
got := ModuleSpec()
|
|
if got.Key != Key || got.Stage != pipeline.StageExtract || got.ArtifactKind != dnd.NPCInteractionListKind || !reflect.DeepEqual(got.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(got.Provides, []string{"dnd.npc_interactions"}) {
|
|
t.Fatalf("ModuleSpec() = %#v", got)
|
|
}
|
|
var registrySlot contracts.ReferenceSlot
|
|
for _, slot := range got.ReferenceSlots {
|
|
if slot.Name == NPCRegistryReferenceSlot {
|
|
registrySlot = slot
|
|
}
|
|
}
|
|
if !registrySlot.Required || !reflect.DeepEqual(registrySlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.NPCListKind}) || registrySlot.MaxBytes != NPCRegistryMaxBytes {
|
|
t.Fatalf("NPC registry slot = %#v", registrySlot)
|
|
}
|
|
got.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed"
|
|
if ModuleSpec().ReferenceSlots[0].AcceptedMediaTypes[0] == "changed" {
|
|
t.Fatal("ModuleSpec() returned mutable reference slots")
|
|
}
|
|
extractorSlots := newExtractor(t, &fakeInteractionsLLMClient{}).ReferenceSlots()
|
|
extractorSlots[0].AcceptedMediaTypes[0] = "changed"
|
|
if newExtractor(t, &fakeInteractionsLLMClient{}).ReferenceSlots()[0].AcceptedMediaTypes[0] == "changed" {
|
|
t.Fatal("ReferenceSlots() returned mutable reference slots")
|
|
}
|
|
registry := pipeline.NewExtractorRegistry()
|
|
if err := Register(registry); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := registry.Spec(Key); !ok {
|
|
t.Fatalf("registry missing %q", Key)
|
|
}
|
|
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
|
t.Fatal("DecodeOptions() accepted unknown option")
|
|
}
|
|
|
|
references := requiredRegistryReferences(t, "Mira Thorn")
|
|
extractor := newExtractor(t, &fakeInteractionsLLMClient{}, references)
|
|
metadata := extractor.ManifestMetadata()
|
|
for key, want := range map[string]string{
|
|
"prompt_id": PromptID, "prompt_version": SchemaVersion, "mapping_policy": mappingPolicy,
|
|
"response_schema_key": string(ResponseSchemaKey), "response_schema_id": ResponseSchemaID,
|
|
"response_schema_name": ResponseSchemaName, "response_schema_version": SchemaVersion,
|
|
} {
|
|
if metadata[key] != want {
|
|
t.Fatalf("metadata[%q] = %#v, want %q", key, metadata[key], want)
|
|
}
|
|
}
|
|
for _, key := range []string{"prompt_sha256", "response_schema_sha256", "npc_registry_digest"} {
|
|
if value, ok := metadata[key].(string); !ok || !strings.HasPrefix(value, "sha256:") {
|
|
t.Fatalf("metadata[%q] = %#v, want digest", key, metadata[key])
|
|
}
|
|
}
|
|
wantFingerprints := map[string]struct{}{"prompt": {}, "response_schema": {}, "mapping_policy": {}, "npc_registry": {}}
|
|
for _, fingerprint := range extractor.CheckpointFingerprints() {
|
|
delete(wantFingerprints, fingerprint.Name)
|
|
}
|
|
if len(wantFingerprints) != 0 {
|
|
t.Fatalf("missing fingerprints = %#v", wantFingerprints)
|
|
}
|
|
}
|
|
|
|
func extractionRequest() contracts.TypedExtractionRequest {
|
|
doc := sourceDocument()
|
|
chunk := &source.Chunk{
|
|
ID: "session-alpha:chunk:0", SourceID: doc.ID, Index: 0,
|
|
Ref: source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 30},
|
|
Content: []byte(`{"units":[10,2,7,5,20,30]}`), MediaType: "application/json",
|
|
Units: append([]source.SourceUnit(nil), doc.Units...),
|
|
}
|
|
return contracts.TypedExtractionRequest{
|
|
Source: doc, Chunk: chunk,
|
|
SourceInput: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:chunk", "file:///session-alpha.json"),
|
|
SessionID: "session-123", LLMProfile: "profile-npc-interactions",
|
|
}
|
|
}
|
|
|
|
func sourceDocument() *source.SourceDocument {
|
|
return &source.SourceDocument{
|
|
ID: "session-alpha", Kind: "transcript", Format: "application/vnd.seriatim.minimal+json", Digest: "sha256:source",
|
|
Units: []source.SourceUnit{
|
|
{ID: 10, Kind: "transcript_segment", Text: "Mira Thorn is mentioned."},
|
|
{ID: 2, Kind: "transcript_segment", Text: "Mira Thorn speaks to the party."},
|
|
{ID: 7, Kind: "transcript_segment", Text: "Mira Thorn watches nearby."},
|
|
{ID: 5, Kind: "transcript_segment", Text: "Mira Thorn joins the party in combat."},
|
|
{ID: 20, Kind: "transcript_segment", Text: "Mira Thorn attacks the party."},
|
|
{ID: 30, Kind: "transcript_segment", Text: "Mira Thorn performs a ritual."},
|
|
},
|
|
}
|
|
}
|
|
|
|
func requiredRegistryReferences(t *testing.T, names ...string) contracts.ReferenceSet {
|
|
t.Helper()
|
|
npcs := make([]dnd.NPC, len(names))
|
|
for index, name := range names {
|
|
npcs[index] = dnd.NPC{
|
|
ID: identity.DeriveID(name), Name: name,
|
|
SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: index + 1, EndUnitID: index + 1}},
|
|
}
|
|
}
|
|
content, err := npccodec.New().Encode(dnd.NPCList{NPCs: npcs})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
|
NPCRegistryReferenceSlot: {
|
|
Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot},
|
|
Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content, Origin: contracts.ReferenceOrigin{Type: "generated"}}},
|
|
},
|
|
}}
|
|
}
|
|
|
|
func interactionRefs(start, end int) []interactionSourceRefResponse {
|
|
return []interactionSourceRefResponse{{StartUnitID: start, EndUnitID: end}}
|
|
}
|
|
|
|
func interactionNames(value dnd.NPCInteractionList) []string {
|
|
names := make([]string, len(value.Interactions))
|
|
for index, interaction := range value.Interactions {
|
|
names[index] = interaction.Name
|
|
}
|
|
return names
|
|
}
|
|
|
|
func interactionKinds(value dnd.NPCInteractionList) []dnd.NPCInteractionKind {
|
|
kinds := make([]dnd.NPCInteractionKind, len(value.Interactions))
|
|
for index, interaction := range value.Interactions {
|
|
kinds[index] = interaction.Kind
|
|
}
|
|
return kinds
|
|
}
|
|
|
|
func emptyChunkRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
|
|
req.Chunk = &source.Chunk{ID: req.Chunk.ID, SourceID: req.Chunk.SourceID, Index: req.Chunk.Index}
|
|
return req
|
|
}
|
|
|
|
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
|
|
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "")
|
|
return req
|
|
}
|
|
|
|
func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor {
|
|
t.Helper()
|
|
extractor, err := New(client, Options{}, references...)
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
return extractor
|
|
}
|
|
|
|
type fakeInteractionsLLMClient struct {
|
|
response extractionResponse
|
|
err error
|
|
requests []contracts.StructuredCompletionRequest
|
|
}
|
|
|
|
func (client *fakeInteractionsLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
|
client.requests = append(client.requests, cloneStructuredCompletionRequest(req))
|
|
if client.err != nil {
|
|
return contracts.StructuredCompletionResponse{}, client.err
|
|
}
|
|
target, ok := out.(*extractionResponse)
|
|
if !ok {
|
|
return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target")
|
|
}
|
|
*target = client.response
|
|
content, err := json.Marshal(client.response)
|
|
if err != nil {
|
|
return contracts.StructuredCompletionResponse{}, err
|
|
}
|
|
return contracts.StructuredCompletionResponse{Content: content}, nil
|
|
}
|
|
|
|
func cloneStructuredCompletionRequest(req contracts.StructuredCompletionRequest) contracts.StructuredCompletionRequest {
|
|
req.Inputs = req.Inputs.Clone()
|
|
return req
|
|
}
|