Minimize D&D NPC extraction contracts

This commit is contained in:
2026-07-22 18:51:26 +00:00
parent 14991cf58b
commit a263a0840c
43 changed files with 486 additions and 1395 deletions

View File

@@ -49,9 +49,9 @@ func TestProductionCombatPipelineRetriesMergesNormalizesAndWritesJSON(t *testing
client := &fakeCombatLLMClient{responses: []string{
combatTestInvalidEnumResponse("unsupported", "attack"),
combatTestTurnResponse("The Greencloak", "turn", "watches", "The Greencloak", 1, 1),
combatTestTurnResponse("mira thorn", "turn", "watches", "mira thorn", 1, 1),
combatTestTurnResponse("Mira Thorn", "reaction", "asks", "Hooded Guard", 2, 2),
combatTestTurnResponse("Hooded Guard", "turn", "attacks", "The Greencloak", 3, 3),
combatTestTurnResponse("Hooded Guard", "turn", "attacks", "mira thorn", 3, 3),
}}
prepared, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client})
if err != nil {
@@ -250,8 +250,8 @@ type combatNPCPayload struct {
func combatTestNPCPayload(t *testing.T) combatNPCPayload {
t.Helper()
value := dnd.NPCList{NPCs: []dnd.NPC{
{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Aliases: []string{"The Greencloak", "Mira"}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "prior-npc-session", StartUnitID: 1, EndUnitID: 1}}},
{ID: identity.DeriveID("Hooded Guard"), Name: "Hooded Guard", Aliases: []string{}, Description: "A sentry.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "prior-npc-session", StartUnitID: 3, EndUnitID: 3}}},
{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "prior-npc-session", StartUnitID: 1, EndUnitID: 1}}},
{ID: identity.DeriveID("Hooded Guard"), Name: "Hooded Guard", SourceRefs: []source.SourceRef{{SourceID: "prior-npc-session", StartUnitID: 3, EndUnitID: 3}}},
}}
content, err := npccodec.New().Encode(value)
if err != nil {

View File

@@ -65,14 +65,16 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
t.Fatalf("encode canonical NPC producer payload: %v", err)
}
canonicalDigest := digestBytes(npcPayload)
projection := []byte(`{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}`)
projectionDigest := digestBytes(projection)
seenConsumers := map[string]bool{}
for _, request := range client.requestsSnapshot() {
if request.PromptID != spells.PromptID && request.PromptID != combatextract.PromptID {
continue
}
input := request.Inputs["npcs"]
if input.MediaType != npccodec.MediaType || input.Digest != canonicalDigest || string(input.Content) != string(npcPayload) || input.OriginURI != "" {
t.Fatalf("%s NPC prompt input = %#v, want canonical generated registry without provenance", request.PromptID, input)
if input.MediaType != npccodec.MediaType || input.Digest != projectionDigest || string(input.Content) != string(projection) || input.OriginURI != "" {
t.Fatalf("%s NPC prompt input = %#v, want names-only generated registry", request.PromptID, input)
}
seenConsumers[request.PromptID] = true
}
@@ -107,7 +109,7 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
switch serialized.LaneID {
case "spells":
spellValue := decodeRunnerSpellResponse(t, serialized.Artifact.Content)
if len(spellValue.SpellCasts) != 1 || spellValue.SpellCasts[0].Caster != "The Greencloak" {
if len(spellValue.SpellCasts) != 1 || spellValue.SpellCasts[0].Caster != "Mira Thorn" {
t.Fatalf("spell output = %#v, want one registry-grounded-context spell", spellValue)
}
assertSpellEvidence(t, spellValue.SpellCasts[0].SourceRefs)
@@ -174,25 +176,23 @@ func (client *groundedDNDLLMClient) CompleteStructured(ctx context.Context, requ
case npcs.PromptID:
payload = map[string]any{"npcs": []any{
map[string]any{
"name": "Mira Thorn", "aliases": []string{"The Greencloak"}, "description": "A guarded ranger.",
"relationships": []any{}, "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}},
"name": "Mira Thorn", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}},
},
map[string]any{
"name": "Hooded Guard", "aliases": []string{}, "description": "A sentry.",
"relationships": []any{}, "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}},
"name": "Hooded Guard", "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}},
},
}}
case spells.PromptID:
payload = map[string]any{"spell_casts": []any{map[string]any{
"caster": "The Greencloak", "spell": "Cure Wounds", "effect": "Restores an ally.",
"narrative_description": "The Greencloak restores an ally.",
"caster": "Mira Thorn", "spell": "Cure Wounds", "effect": "Restores an ally.",
"narrative_description": "Mira Thorn restores an ally.",
"source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}},
}}}
case combatextract.PromptID:
payload = map[string]any{"combat_turns": []any{map[string]any{
"actor": "The Greencloak", "turn_kind": "turn", "round": 1,
"actor": "Mira Thorn", "turn_kind": "turn", "round": 1,
"actions": []any{map[string]any{"category": "attack", "declaration": "watches", "targets": []string{"Hooded Guard"}, "resolution": "observed"}},
"summary": "The Greencloak watches the gate.",
"summary": "Mira Thorn watches the gate.",
"source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}},
}}}
default:

View File

@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"os"
"reflect"
"strings"
"testing"
@@ -40,30 +39,15 @@ func TestRunnerProcessesSeriatimInputWithProductionDNDNPCPipeline(t *testing.T)
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"},
},
Name: "Mira Thorn",
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"},
},
Name: " mira thorn ",
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"},
},
Name: "Hooded Guard",
SourceRefs: []npcProductionSourceRef{{StartUnitID: 3, EndUnitID: 3}},
},
}},
@@ -87,15 +71,12 @@ func TestRunnerProcessesSeriatimInputWithProductionDNDNPCPipeline(t *testing.T)
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"}) {
if first.Name != "Mira Thorn" || len(first.SourceRefs) != 2 {
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 {
@@ -103,8 +84,8 @@ func TestRunnerProcessesSeriatimInputWithProductionDNDNPCPipeline(t *testing.T)
}
}
}
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 !hasNPCWarning(output.Warnings, "duplicate_npc_collapsed") {
t.Fatalf("warnings = %#v, want name-only consolidation warning", output.Warnings)
}
if output.Manifest.ValidationStatus != "approved" || len(output.Manifest.ArtifactLanes) != 1 {
t.Fatalf("manifest = %#v, want approved NPC lane", output.Manifest)
@@ -149,25 +130,25 @@ func TestProductionNPCPipelineRoutesSemanticCandidatesToDeterministicValidators(
}{
{
name: "blank string",
response: []byte(`{"npcs":[{"name":"","aliases":[],"description":"A participant.","relationships":[],"source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
response: []byte(`{"npcs":[{"name":"","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
reasonCode: npcshape.ReasonCode,
validatorName: npcshape.Key,
},
{
name: "empty evidence",
response: []byte(`{"npcs":[{"name":"Mira Thorn","aliases":[],"description":"A participant.","relationships":[],"source_refs":[]}]}`),
response: []byte(`{"npcs":[{"name":"Mira Thorn","source_refs":[]}]}`),
reasonCode: npcshape.ReasonCode,
validatorName: npcshape.Key,
},
{
name: "nonpositive unit candidate",
response: []byte(`{"npcs":[{"name":"Mira Thorn","aliases":[],"description":"A participant.","relationships":[],"source_refs":[{"start_unit_id":0,"end_unit_id":1}]}]}`),
response: []byte(`{"npcs":[{"name":"Mira Thorn","source_refs":[{"start_unit_id":0,"end_unit_id":1}]}]}`),
reasonCode: npcsourcerefs.ReasonCode,
validatorName: npcsourcerefs.Key,
},
{
name: "unknown unit candidate",
response: []byte(`{"npcs":[{"name":"Mira Thorn","aliases":[],"description":"A participant.","relationships":[],"source_refs":[{"start_unit_id":99,"end_unit_id":99}]}]}`),
response: []byte(`{"npcs":[{"name":"Mira Thorn","source_refs":[{"start_unit_id":99,"end_unit_id":99}]}]}`),
reasonCode: npcsourcerefs.ReasonCode,
validatorName: npcsourcerefs.Key,
},
@@ -194,16 +175,8 @@ type npcProductionResponse struct {
}
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"`
Name string `json:"name"`
SourceRefs []npcProductionSourceRef `json:"source_refs"`
}
type npcProductionSourceRef struct {