Files
notarius/internal/modules/integration/dnd_npcs_runner_test.go

243 lines
9.5 KiB
Go

package integration_test
import (
"context"
"encoding/json"
"fmt"
"os"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
dndregister "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/register"
genericregister "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/register"
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
seriatimregister "gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/register"
)
func TestRunnerProcessesSeriatimInputWithProductionDNDNPCPipeline(t *testing.T) {
raw := readNPCFixture(t)
doc, err := transcript.New().Parse(context.Background(), contracts.ParseRequest{Raw: raw})
if err != nil {
t.Fatalf("Parse() error = %v, want nil", err)
}
registries := productionNPCRegistries(t)
configValue := loadNPCPipelineConfig(t)
effective, err := configValue.Resolve(config.ResolveInput{PipelineID: "dnd-npcs-fixture", Catalog: moduleCatalog(registries)})
if err != nil {
t.Fatalf("Resolve() error = %v, want nil", err)
}
client := &fakeNPCProductionLLMClient{response: npcProductionResponse{
NPCs: []npcProductionRecord{
{
Name: "Mira Thorn",
Aliases: []string{"The Greencloak"},
Description: "The first named NPC encountered.",
Relationships: []npcProductionRelationship{
{Target: "Hooded Guard", Relationship: "works with"},
},
SourceRefs: []npcProductionSourceRef{{StartUnitID: 1, EndUnitID: 1}},
},
{
Name: "The Greencloak",
Aliases: []string{"Mira"},
Description: "A later description that must not replace the first.",
Relationships: []npcProductionRelationship{
{Target: "Hooded Guard", Relationship: "trusts"},
},
SourceRefs: []npcProductionSourceRef{{StartUnitID: 2, EndUnitID: 2}},
},
{
Name: "Hooded Guard",
Aliases: []string{},
Description: "An unnamed but distinguishable sentry.",
Relationships: []npcProductionRelationship{
{Target: "The Greencloak", Relationship: "reports to"},
},
SourceRefs: []npcProductionSourceRef{{StartUnitID: 3, EndUnitID: 3}},
},
}},
}
output, err := runPreparedPipeline(t, registries, effective.ResolvedPipeline, client, pipeline.RunInput{RawInput: raw})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
if len(output.NormalizeOutputs) != 1 {
t.Fatalf("normalize outputs = %d, want one NPC output; rejected=%#v", len(output.NormalizeOutputs), output.Rejected)
}
serialized := output.NormalizeOutputs[0]
if serialized.LaneID != "npcs" || serialized.NormalizerKey != npcs.Key || serialized.Artifact.Schema.ID != npccodec.SchemaID || serialized.Artifact.Schema.Version != npccodec.SchemaVersion {
t.Fatalf("serialized output = %#v, want durable NPC lane schema", serialized)
}
value, err := npccodec.New().Decode(serialized.Artifact.Content)
if err != nil {
t.Fatalf("Decode(output) error = %v, want durable NPC payload", err)
}
if len(value.NPCs) != 2 {
t.Fatalf("NPC output = %#v, want repeated name consolidated and group/PC omitted", value.NPCs)
}
first, second := value.NPCs[0], value.NPCs[1]
if first.Name != "Mira Thorn" || first.Description != "The first named NPC encountered." || !reflect.DeepEqual(first.Aliases, []string{"The Greencloak", "Mira"}) {
t.Fatalf("first NPC = %#v, want consolidated Mira identity", first)
}
if first.ID != identity.DeriveID(first.Name) || second.Name != "Hooded Guard" || second.ID != identity.DeriveID(second.Name) {
t.Fatalf("NPC IDs = %q/%q, want derived IDs", first.ID, second.ID)
}
if first.Relationships[0].Target != "Hooded Guard" || second.Relationships[0].Target != "Mira Thorn" {
t.Fatalf("relationship targets = %q/%q, want canonical target rewrite", first.Relationships[0].Target, second.Relationships[0].Target)
}
for _, npc := range value.NPCs {
for _, ref := range npc.SourceRefs {
if ref.SourceID != doc.ID {
t.Fatalf("NPC source ref = %#v, want source document %q", ref, doc.ID)
}
}
}
if !hasNPCWarning(output.Warnings, "duplicate_npc_collapsed") || !hasNPCWarning(output.Warnings, "relationship_target_canonicalized") {
t.Fatalf("warnings = %#v, want consolidation and target warnings", output.Warnings)
}
if output.Manifest.ValidationStatus != "approved" || len(output.Manifest.ArtifactLanes) != 1 {
t.Fatalf("manifest = %#v, want approved NPC lane", output.Manifest)
}
lane := output.Manifest.ArtifactLanes[0]
if lane.ID != "npcs" || lane.Extractor != npcs.Key || lane.Merger != pipeline.DefaultMergeModule || lane.Normalizer != npcs.Key {
t.Fatalf("manifest lane = %#v, want NPC production composition", lane)
}
normalizerMetadata, ok := lane.Metadata["normalizer"].(map[string]any)
if !ok || normalizerMetadata["identity_policy"] != identity.Policy || normalizerMetadata["normalization_policy"] != "dnd.npcs.normalize.v1" {
t.Fatalf("normalizer metadata = %#v, want identity and normalization policies", lane.Metadata)
}
var npcOutputFile *contracts.OutputFile
for index := range output.OutputFiles {
if output.OutputFiles[index].Name == "lanes/npcs.json" {
npcOutputFile = &output.OutputFiles[index]
break
}
}
if npcOutputFile == nil || npcOutputFile.ContentType != npccodec.MediaType {
t.Fatalf("output files = %#v, want JSON NPC lane file", output.OutputFiles)
}
if len(client.requests) != 1 || client.requests[0].PromptID != npcs.PromptID {
t.Fatalf("LLM requests = %#v, want one NPC prompt request", client.requests)
}
}
type npcProductionResponse struct {
NPCs []npcProductionRecord `json:"npcs"`
}
type npcProductionRecord struct {
Name string `json:"name"`
Aliases []string `json:"aliases"`
Description string `json:"description"`
Relationships []npcProductionRelationship `json:"relationships"`
SourceRefs []npcProductionSourceRef `json:"source_refs"`
}
type npcProductionRelationship struct {
Target string `json:"target"`
Relationship string `json:"relationship"`
}
type npcProductionSourceRef struct {
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
}
type fakeNPCProductionLLMClient struct {
response npcProductionResponse
requests []contracts.StructuredCompletionRequest
}
func (client *fakeNPCProductionLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
client.requests = append(client.requests, req)
content, err := json.Marshal(client.response)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
if err := json.Unmarshal(content, out); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate NPC structured target: %w", err)
}
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func productionNPCRegistries(t *testing.T) pipeline.Registries {
t.Helper()
registries := pipeline.Registries{
Inputs: pipeline.NewInputAdapterRegistry(),
Chunkers: pipeline.NewChunkerRegistry(),
ArtifactCodecs: pipeline.NewArtifactCodecRegistry(),
Extractors: pipeline.NewExtractorRegistry(),
Mergers: pipeline.NewMergerRegistry(),
Normalizers: pipeline.NewNormalizerRegistry(),
Validators: pipeline.NewValidatorRegistry(),
ValidatorChains: pipeline.NewValidatorChainRegistry(),
Outputs: pipeline.NewOutputEncoderRegistry(),
}
assets := llm.NewAssetRegistry()
for _, registration := range []struct {
name string
fn func(pipeline.Registries, *llm.AssetRegistry) error
}{
{name: "generic", fn: genericregister.Register},
{name: "seriatim", fn: seriatimregister.Register},
{name: "dnd", fn: dndregister.Register},
} {
if err := registration.fn(registries, assets); err != nil {
t.Fatalf("register %s modules: %v", registration.name, err)
}
}
return registries
}
func moduleCatalog(registries pipeline.Registries) pipeline.ModuleCatalog {
return pipeline.ModuleCatalog{
Inputs: registries.Inputs, Chunkers: registries.Chunkers, ArtifactCodecs: registries.ArtifactCodecs,
Extractors: registries.Extractors, Mergers: registries.Mergers, Normalizers: registries.Normalizers,
Validators: registries.Validators, ValidatorChains: registries.ValidatorChains, Outputs: registries.Outputs,
}
}
func loadNPCPipelineConfig(t *testing.T) config.Config {
t.Helper()
data, err := os.ReadFile("testdata/dnd_npcs_pipeline.yml")
if err != nil {
t.Fatalf("ReadFile(dnd_npcs_pipeline.yml) error = %v", err)
}
fileConfig, err := config.ParseFileConfigYAML(data)
if err != nil {
t.Fatalf("ParseFileConfigYAML() error = %v", err)
}
cfg := config.Default()
if err := cfg.ApplyFileConfig(fileConfig); err != nil {
t.Fatalf("ApplyFileConfig() error = %v", err)
}
return cfg
}
func readNPCFixture(t *testing.T) []byte {
t.Helper()
raw, err := os.ReadFile("testdata/seriatim_npc_session.json")
if err != nil {
t.Fatalf("ReadFile(seriatim_npc_session.json) error = %v", err)
}
return raw
}
func hasNPCWarning(warnings []contracts.Warning, reason string) bool {
for _, warning := range warnings {
if warning.ReasonCode == reason && strings.HasPrefix(warning.Scope, "npcs[") {
return true
}
}
return false
}