Integrate NPC semantic normalization
This commit is contained in:
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@@ -16,6 +18,8 @@ import (
|
||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
)
|
||||
|
||||
func TestNPCInteractionPipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing.T) {
|
||||
@@ -70,6 +74,59 @@ func TestNPCInteractionPipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticNPCNormalizationCrossesOrderedRegistryHandoff(t *testing.T) {
|
||||
registries := productionNPCRegistries(t)
|
||||
cfg := loadNPCInteractionPipelineConfig(t)
|
||||
profile := cfg.Pipelines["dnd-npc-interactions-fixture"]
|
||||
profile.Chunk = pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}}
|
||||
cfg.Pipelines["dnd-npc-interactions-fixture"] = profile
|
||||
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-npc-interactions-fixture", Catalog: moduleCatalog(registries)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, warnings, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, moduleCatalog(registries), pipeline.ReferenceMaterializationOptions{})
|
||||
if err != nil || len(warnings) != 0 {
|
||||
t.Fatalf("MaterializeReferences() error = %v warnings = %#v", err, warnings)
|
||||
}
|
||||
client := &semanticNPCInteractionClient{}
|
||||
raw := []byte(`{"metadata":{"id":"semantic-session","title":"Semantic NPC session"},"segments":[{"id":1,"start":0,"end":1,"speaker":"DM","text":"Mira Thorn enters."},{"id":2,"start":1,"end":2,"speaker":"DM","text":"Mira Thorn, the Greencloak, waves."}]}`)
|
||||
output, err := runPreparedPipeline(t, registries, resolved, client, pipeline.RunInput{RawInput: raw})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if client.requestCount(npcs.PromptID) != 2 || client.requestCount(npcnormalize.PromptID) != 1 {
|
||||
t.Fatalf("prompt requests = %#v, want two extraction calls and one document normalization call", client.requests)
|
||||
}
|
||||
npcOutput := normalizedLane(t, output, "npcs")
|
||||
npcsValue, err := npccodec.New().Decode(npcOutput.Artifact.Content)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if npcOutput.StepID != "identify-npcs" || len(npcsValue.NPCs) != 1 || npcsValue.NPCs[0].Name != "Mira Thorn" || npcsValue.NPCs[0].ID != identity.DeriveID("Mira Thorn") {
|
||||
t.Fatalf("NPC output = %#v / %#v, want canonical ordered producer artifact", npcOutput, npcsValue)
|
||||
}
|
||||
if refs := npcsValue.NPCs[0].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "semantic-session", StartUnitID: 1, EndUnitID: 1}, {SourceID: "semantic-session", StartUnitID: 2, EndUnitID: 2}}) {
|
||||
t.Fatalf("NPC evidence = %#v, want original extraction evidence union", refs)
|
||||
}
|
||||
interactionOutput := normalizedLane(t, output, "interactions")
|
||||
if interactionOutput.StepID != "extract-interactions" {
|
||||
t.Fatalf("interaction output step = %q, want ordered downstream step", interactionOutput.StepID)
|
||||
}
|
||||
registryRequest := client.requestFor(t, interactionextract.PromptID)
|
||||
if got := string(registryRequest.Inputs["npcs"].Content); got != `{"npcs":[{"name":"Mira Thorn"}]}` {
|
||||
t.Fatalf("downstream registry = %s, want one canonical names-only NPC", got)
|
||||
}
|
||||
manifestContent, err := json.Marshal(output.Manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, forbidden := range []string{"npc:sha256:", "Mira Thorn enters.", "Mira Thorn, the Greencloak, waves."} {
|
||||
if strings.Contains(string(manifestContent), forbidden) {
|
||||
t.Fatalf("manifest leaked private identity or transcript content %q: %s", forbidden, manifestContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNPCInteractionPipelineSkipsConsumerWhenNPCProducerIsRejected(t *testing.T) {
|
||||
registries := productionNPCRegistries(t)
|
||||
resolved := resolveNPCInteractionPipeline(t, registries)
|
||||
@@ -168,6 +225,8 @@ func (client *npcInteractionLLMClient) CompleteStructured(ctx context.Context, r
|
||||
map[string]any{"name": "Hooded Guard", "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}}},
|
||||
}}
|
||||
}
|
||||
case npcnormalize.PromptID:
|
||||
payload = map[string]any{"duplicate_groups": []any{}}
|
||||
case interactionextract.PromptID:
|
||||
payload = map[string]any{"interactions": []any{
|
||||
map[string]any{"name": "Hooded Guard", "kind": "noncombat_presence", "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}}},
|
||||
@@ -212,3 +271,59 @@ func (client *npcInteractionLLMClient) requestCount(promptID string) int {
|
||||
}
|
||||
|
||||
var _ contracts.StructuredLLMClient = (*npcInteractionLLMClient)(nil)
|
||||
|
||||
type semanticNPCInteractionClient struct {
|
||||
requests []contracts.StructuredCompletionRequest
|
||||
npcCalls int
|
||||
}
|
||||
|
||||
func (client *semanticNPCInteractionClient) CompleteStructured(_ context.Context, request contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||
client.requests = append(client.requests, cloneStructuredCompletionRequest(request))
|
||||
var payload any
|
||||
switch request.PromptID {
|
||||
case npcs.PromptID:
|
||||
client.npcCalls++
|
||||
name := "Mira Thorn"
|
||||
if client.npcCalls == 2 {
|
||||
name = "Mira Thorn, the Greencloak"
|
||||
}
|
||||
payload = map[string]any{"npcs": []any{map[string]any{"name": name, "source_refs": []any{map[string]int{"start_unit_id": client.npcCalls, "end_unit_id": client.npcCalls}}}}}
|
||||
case npcnormalize.PromptID:
|
||||
payload = map[string]any{"duplicate_groups": []any{map[string]any{"members": []string{"Mira Thorn", "Mira Thorn, the Greencloak"}, "canonical_name": "Mira Thorn"}}}
|
||||
case interactionextract.PromptID:
|
||||
payload = map[string]any{"interactions": []any{map[string]any{"name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}}}
|
||||
default:
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected semantic pipeline prompt %q", request.PromptID)
|
||||
}
|
||||
content, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, err
|
||||
}
|
||||
if err := json.Unmarshal(content, out); err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, err
|
||||
}
|
||||
return contracts.StructuredCompletionResponse{Content: content}, nil
|
||||
}
|
||||
|
||||
func (client *semanticNPCInteractionClient) requestCount(promptID string) int {
|
||||
count := 0
|
||||
for _, request := range client.requests {
|
||||
if request.PromptID == promptID {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (client *semanticNPCInteractionClient) requestFor(t *testing.T, promptID string) contracts.StructuredCompletionRequest {
|
||||
t.Helper()
|
||||
for _, request := range client.requests {
|
||||
if request.PromptID == promptID {
|
||||
return request
|
||||
}
|
||||
}
|
||||
t.Fatalf("requests = %#v, missing %q", client.requests, promptID)
|
||||
return contracts.StructuredCompletionRequest{}
|
||||
}
|
||||
|
||||
var _ contracts.StructuredLLMClient = (*semanticNPCInteractionClient)(nil)
|
||||
|
||||
Reference in New Issue
Block a user