243 lines
9.5 KiB
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
|
|
}
|