Add production NPC pipeline composition
This commit is contained in:
242
internal/modules/integration/dnd_npcs_runner_test.go
Normal file
242
internal/modules/integration/dnd_npcs_runner_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
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
|
||||
}
|
||||
20
internal/modules/integration/testdata/dnd_npcs_pipeline.yml
vendored
Normal file
20
internal/modules/integration/testdata/dnd_npcs_pipeline.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
version: 3
|
||||
output:
|
||||
directory: ./notarius-output
|
||||
cache:
|
||||
chunk_plans:
|
||||
mode: bypass
|
||||
checkpoints: {}
|
||||
debug:
|
||||
directory: ./notarius-debug
|
||||
pipelines:
|
||||
dnd-npcs-fixture:
|
||||
input: seriatim
|
||||
chunk: generic
|
||||
artifacts:
|
||||
npcs:
|
||||
extract:
|
||||
module: dnd/npcs
|
||||
retries: 2
|
||||
normalize: dnd/npcs
|
||||
output: json
|
||||
43
internal/modules/integration/testdata/seriatim_npc_session.json
vendored
Normal file
43
internal/modules/integration/testdata/seriatim_npc_session.json
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"metadata": {
|
||||
"id": "npc-session",
|
||||
"title": "Synthetic D&D NPC session"
|
||||
},
|
||||
"segments": [
|
||||
{
|
||||
"id": 1,
|
||||
"start": 0,
|
||||
"end": 4,
|
||||
"speaker": "Aria",
|
||||
"text": "Aria watches Mira Thorn, the Greencloak, enter the ruined hall."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"start": 4,
|
||||
"end": 8,
|
||||
"speaker": "DM",
|
||||
"text": "Mira Thorn asks the party to follow the old road."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"start": 8,
|
||||
"end": 12,
|
||||
"speaker": "DM",
|
||||
"text": "A hooded guard opens the side gate and waits in silence."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"start": 12,
|
||||
"end": 16,
|
||||
"speaker": "DM",
|
||||
"text": "Three identical guards surround the interchangeable group."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"start": 16,
|
||||
"end": 20,
|
||||
"speaker": "Aria",
|
||||
"text": "Aria keeps watch while the named NPCs leave the hall."
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user