Add D&D location tracking to complete example
This commit is contained in:
@@ -36,6 +36,8 @@ pipelines:
|
|||||||
window_units: 3
|
window_units: 3
|
||||||
lanes:
|
lanes:
|
||||||
- item-events
|
- item-events
|
||||||
|
- locations
|
||||||
|
- location-occurrences
|
||||||
- npcs
|
- npcs
|
||||||
- spells
|
- spells
|
||||||
- combat-turns
|
- combat-turns
|
||||||
@@ -59,6 +61,14 @@ pipelines:
|
|||||||
normalize:
|
normalize:
|
||||||
module: dnd/npcs
|
module: dnd/npcs
|
||||||
retries: 2
|
retries: 2
|
||||||
|
locations:
|
||||||
|
extract:
|
||||||
|
module: dnd/locations
|
||||||
|
retries: 2
|
||||||
|
merge: appendorder
|
||||||
|
normalize:
|
||||||
|
module: dnd/locations
|
||||||
|
retries: 2
|
||||||
scene-descriptions:
|
scene-descriptions:
|
||||||
extract:
|
extract:
|
||||||
module: dnd/scene-descriptions
|
module: dnd/scene-descriptions
|
||||||
@@ -66,9 +76,13 @@ pipelines:
|
|||||||
merge: appendorder
|
merge: appendorder
|
||||||
normalize: dnd/scene-descriptions
|
normalize: dnd/scene-descriptions
|
||||||
- id: extract-events
|
- id: extract-events
|
||||||
# Accepted NPC grounding and scene-description eligibility artifacts are
|
# Accepted registry artifacts and scene-description eligibility artifacts
|
||||||
# supplied in memory to their compatible consumers in this step.
|
# are supplied in memory to their compatible consumers in this step.
|
||||||
references:
|
references:
|
||||||
|
locations:
|
||||||
|
artifact:
|
||||||
|
step: describe-session
|
||||||
|
lane: locations
|
||||||
npcs:
|
npcs:
|
||||||
artifact:
|
artifact:
|
||||||
step: describe-session
|
step: describe-session
|
||||||
@@ -102,6 +116,12 @@ pipelines:
|
|||||||
retries: 2
|
retries: 2
|
||||||
merge: appendorder
|
merge: appendorder
|
||||||
normalize: dnd/npc-interactions
|
normalize: dnd/npc-interactions
|
||||||
|
location-occurrences:
|
||||||
|
extract:
|
||||||
|
module: dnd/location-occurrences
|
||||||
|
retries: 2
|
||||||
|
merge: appendorder
|
||||||
|
normalize: dnd/location-occurrences
|
||||||
- id: track-enemies
|
- id: track-enemies
|
||||||
references:
|
references:
|
||||||
npcs:
|
npcs:
|
||||||
|
|||||||
@@ -20,14 +20,19 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
||||||
|
locationoccurrencecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationoccurrences"
|
||||||
|
locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locations"
|
||||||
combat "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
combat "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||||
enemyevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
|
enemyevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
|
||||||
itemevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
itemevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
||||||
|
locationoccurrences "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationoccurrences"
|
||||||
|
locations "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locations"
|
||||||
npcinteractions "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
npcinteractions "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
||||||
npcs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
npcs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||||
scenedescriptions "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
|
scenedescriptions "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||||
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
|
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
|
||||||
|
locationnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locations"
|
||||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -117,10 +122,15 @@ func TestMaintainedCompleteExampleProducesEnemyEventsThroughGeneratedHandoffs(t
|
|||||||
runRoot := filepath.Join(outputRoot, productionRunID)
|
runRoot := filepath.Join(outputRoot, productionRunID)
|
||||||
index := readProductionJSON[exampleOutputIndex](t, filepath.Join(runRoot, "index.json"))
|
index := readProductionJSON[exampleOutputIndex](t, filepath.Join(runRoot, "index.json"))
|
||||||
var enemyOutput exampleOutputIndexEntry
|
var enemyOutput exampleOutputIndexEntry
|
||||||
|
var locationOutput, occurrenceOutput exampleOutputIndexEntry
|
||||||
for _, entry := range index.OutputFiles {
|
for _, entry := range index.OutputFiles {
|
||||||
if entry.LaneID == "enemy-events" {
|
switch entry.LaneID {
|
||||||
|
case "enemy-events":
|
||||||
enemyOutput = entry
|
enemyOutput = entry
|
||||||
break
|
case "locations":
|
||||||
|
locationOutput = entry
|
||||||
|
case "location-occurrences":
|
||||||
|
occurrenceOutput = entry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if enemyOutput.File != "lanes/enemy-events.json" || enemyOutput.SchemaID != "notarius.dnd.enemy_events" || enemyOutput.SchemaVersion != "v1" {
|
if enemyOutput.File != "lanes/enemy-events.json" || enemyOutput.SchemaID != "notarius.dnd.enemy_events" || enemyOutput.SchemaVersion != "v1" {
|
||||||
@@ -130,10 +140,26 @@ func TestMaintainedCompleteExampleProducesEnemyEventsThroughGeneratedHandoffs(t
|
|||||||
if len(value.Events) != 1 || value.Events[0].Name != "Kesh" || value.Events[0].Kind != dnd.EnemyEventKindFled || len(value.Events[0].SourceRefs) != 1 || value.Events[0].SourceRefs[0].SourceID != "session-ravenfall" || value.Events[0].SourceRefs[0].StartUnitID != 10 {
|
if len(value.Events) != 1 || value.Events[0].Name != "Kesh" || value.Events[0].Kind != dnd.EnemyEventKindFled || len(value.Events[0].SourceRefs) != 1 || value.Events[0].SourceRefs[0].SourceID != "session-ravenfall" || value.Events[0].SourceRefs[0].StartUnitID != 10 {
|
||||||
t.Fatalf("enemy event artifact = %#v, want source-linked Kesh fleeing event", value)
|
t.Fatalf("enemy event artifact = %#v, want source-linked Kesh fleeing event", value)
|
||||||
}
|
}
|
||||||
|
if locationOutput.File != "lanes/locations.json" || locationOutput.SchemaID != locationcodec.SchemaID || locationOutput.SchemaVersion != locationcodec.SchemaVersion {
|
||||||
|
t.Fatalf("location output = %#v, want typed location registry JSON", locationOutput)
|
||||||
|
}
|
||||||
|
locationsValue := readProductionJSON[dnd.LocationList](t, filepath.Join(runRoot, locationOutput.File))
|
||||||
|
if len(locationsValue.Locations) != 2 || locationsValue.Locations[0].Name != "Moon Gate" || locationsValue.Locations[1].Name != "Moon Gate" || locationsValue.Locations[0].ID == locationsValue.Locations[1].ID {
|
||||||
|
t.Fatalf("location registry = %#v, want distinct source-grounded identities for same-name locations", locationsValue)
|
||||||
|
}
|
||||||
|
if occurrenceOutput.File != "lanes/location-occurrences.json" || occurrenceOutput.SchemaID != locationoccurrencecodec.SchemaID || occurrenceOutput.SchemaVersion != locationoccurrencecodec.SchemaVersion {
|
||||||
|
t.Fatalf("location occurrence output = %#v, want typed occurrence JSON", occurrenceOutput)
|
||||||
|
}
|
||||||
|
occurrencesValue := readProductionJSON[dnd.LocationOccurrenceList](t, filepath.Join(runRoot, occurrenceOutput.File))
|
||||||
|
if len(occurrencesValue.Occurrences) != 2 || occurrencesValue.Occurrences[0].LocationID == occurrencesValue.Occurrences[1].LocationID || occurrencesValue.Occurrences[0].Name != "Moon Gate" || occurrencesValue.Occurrences[1].Name != "Moon Gate" {
|
||||||
|
t.Fatalf("location occurrences = %#v, want source-grounded references to distinct registry identities", occurrencesValue)
|
||||||
|
}
|
||||||
|
|
||||||
evidence := readProductionJSON[evidencecontext.Document](t, filepath.Join(runRoot, "evidence-context.json"))
|
evidence := readProductionJSON[evidencecontext.Document](t, filepath.Join(runRoot, "evidence-context.json"))
|
||||||
if !containsString(evidence.SelectedLanes, "enemy-events") || !evidenceHasLane(evidence, "enemy-events") {
|
for _, laneID := range []string{"enemy-events", "locations", "location-occurrences"} {
|
||||||
t.Fatalf("evidence context = %#v, want direct enemy-event evidence", evidence)
|
if !containsString(evidence.SelectedLanes, laneID) || !evidenceHasLane(evidence, laneID) {
|
||||||
|
t.Fatalf("evidence context = %#v, want direct %s evidence", evidence, laneID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
requests := client.requestsFor(enemyevents.PromptID)
|
requests := client.requestsFor(enemyevents.PromptID)
|
||||||
@@ -154,6 +180,16 @@ func TestMaintainedCompleteExampleProducesEnemyEventsThroughGeneratedHandoffs(t
|
|||||||
t.Fatalf("enemy event %s prompt input = %q, want compact source-free grounding", slot, input.Content)
|
t.Fatalf("enemy event %s prompt input = %q, want compact source-free grounding", slot, input.Content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
locationRequests := client.requestsFor(locationoccurrences.PromptID)
|
||||||
|
if len(locationRequests) != 2 {
|
||||||
|
t.Fatalf("location occurrence requests = %#v, want one request per scene", locationRequests)
|
||||||
|
}
|
||||||
|
for _, request := range locationRequests {
|
||||||
|
registryInput := request.Inputs["locations"]
|
||||||
|
if !strings.Contains(string(registryInput.Content), "Moon Gate") || !strings.Contains(string(registryInput.Content), `"id"`) || strings.Contains(string(registryInput.Content), "source_refs") {
|
||||||
|
t.Fatalf("location occurrence registry input = %q, want source-free ID grounding", registryInput.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func completeExampleConfigWithTemporaryCache(t *testing.T) string {
|
func completeExampleConfigWithTemporaryCache(t *testing.T) string {
|
||||||
@@ -207,6 +243,14 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
|
|||||||
kind, title = "combat", "Raiders attack"
|
kind, title = "combat", "Raiders attack"
|
||||||
}
|
}
|
||||||
content = []byte(fmt.Sprintf(`{"kind":%q,"title":%q,"summary":"session scene"}`, kind, title))
|
content = []byte(fmt.Sprintf(`{"kind":%q,"title":%q,"summary":"session scene"}`, kind, title))
|
||||||
|
case locations.PromptID:
|
||||||
|
unitID := 1
|
||||||
|
if combatScene {
|
||||||
|
unitID = 7
|
||||||
|
}
|
||||||
|
content = []byte(fmt.Sprintf(`{"locations":[{"name":"Moon Gate","source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, unitID, unitID))
|
||||||
|
case locationnormalize.PromptID:
|
||||||
|
content = []byte(`{"duplicate_groups":[]}`)
|
||||||
case spells.PromptID:
|
case spells.PromptID:
|
||||||
content = []byte(`{"spell_casts":[]}`)
|
content = []byte(`{"spell_casts":[]}`)
|
||||||
case itemevents.PromptID:
|
case itemevents.PromptID:
|
||||||
@@ -219,6 +263,27 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
|
|||||||
} else {
|
} else {
|
||||||
content = []byte(`{"interactions":[]}`)
|
content = []byte(`{"interactions":[]}`)
|
||||||
}
|
}
|
||||||
|
case locationoccurrences.PromptID:
|
||||||
|
var registry struct {
|
||||||
|
Locations []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"locations"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(request.Inputs["locations"].Content, ®istry); err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated location registry: %w", err)
|
||||||
|
}
|
||||||
|
if len(registry.Locations) == 0 {
|
||||||
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated location registry has no locations")
|
||||||
|
}
|
||||||
|
unitID := 1
|
||||||
|
locationID := registry.Locations[0].ID
|
||||||
|
if combatScene {
|
||||||
|
unitID = 7
|
||||||
|
if len(registry.Locations) > 1 {
|
||||||
|
locationID = registry.Locations[1].ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content = []byte(fmt.Sprintf(`{"occurrences":[{"location_id":%q,"name":"Moon Gate","kind":"visited","source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, locationID, unitID, unitID))
|
||||||
case enemyevents.PromptID:
|
case enemyevents.PromptID:
|
||||||
content = []byte(`{"events":[{"name":"Kesh","kind":"fled","source_refs":[{"start_unit_id":10,"end_unit_id":10}]}]}`)
|
content = []byte(`{"events":[{"name":"Kesh","kind":"fled","source_refs":[{"start_unit_id":10,"end_unit_id":10}]}]}`)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
locationoccurrenceextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationoccurrences"
|
||||||
|
locationextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locations"
|
||||||
|
locationoccurrencenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locationoccurrences"
|
||||||
|
locationnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locations"
|
||||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
|
||||||
)
|
)
|
||||||
@@ -54,9 +58,23 @@ func TestMaintainedExamplesLoadResolveAndList(t *testing.T) {
|
|||||||
t.Fatalf("materialize maintained example references for %q: %v", pipelineID, err)
|
t.Fatalf("materialize maintained example references for %q: %v", pipelineID, err)
|
||||||
}
|
}
|
||||||
if example.name == "complete" {
|
if example.name == "complete" {
|
||||||
if got := exampleStepLaneIDs(materialized); strings.Join(got, "|") != "describe-session:item-events,npcs,scene-descriptions|extract-events:combat-turns,npc-interactions,spells|track-enemies:enemy-events" {
|
if got := exampleStepLaneIDs(materialized); strings.Join(got, "|") != "describe-session:item-events,locations,npcs,scene-descriptions|extract-events:combat-turns,location-occurrences,npc-interactions,spells|track-enemies:enemy-events" {
|
||||||
t.Fatalf("complete example steps and lanes = %v, want the documented D&D extractor composition", got)
|
t.Fatalf("complete example steps and lanes = %v, want the documented D&D extractor composition", got)
|
||||||
}
|
}
|
||||||
|
locationLane := referenceContractLane(t, materialized, "locations")
|
||||||
|
if locationLane.ArtifactKind != dnd.LocationListKind || locationLane.Extract.Module != locationextract.Key || locationLane.Extract.Retries != 2 || locationLane.Merge.Module != pipeline.DefaultMergeModule || locationLane.Normalize.Module != locationnormalize.Key || locationLane.Normalize.Retries != 2 {
|
||||||
|
t.Fatalf("location lane = %#v, want typed registry composition", locationLane)
|
||||||
|
}
|
||||||
|
occurrenceLane := referenceContractLane(t, materialized, "location-occurrences")
|
||||||
|
if occurrenceLane.ArtifactKind != dnd.LocationOccurrenceListKind || occurrenceLane.Extract.Module != locationoccurrenceextract.Key || occurrenceLane.Extract.Retries != 2 || occurrenceLane.Merge.Module != pipeline.DefaultMergeModule || occurrenceLane.Normalize.Module != locationoccurrencenormalize.Key {
|
||||||
|
t.Fatalf("location occurrence lane = %#v, want typed occurrence composition", occurrenceLane)
|
||||||
|
}
|
||||||
|
for _, target := range []pipeline.ResolvedReferenceTarget{occurrenceLane.ExtractReferences, occurrenceLane.NormalizeReferences} {
|
||||||
|
binding, found := generatedReferenceBinding(target.Bindings, "locations")
|
||||||
|
if !found || binding.Artifact.Step != "describe-session" || binding.Artifact.Lane != "locations" {
|
||||||
|
t.Fatalf("location occurrence %s reference = %#v, want generated location registry", target.Stage, binding)
|
||||||
|
}
|
||||||
|
}
|
||||||
spellLane := referenceContractLane(t, materialized, "spells")
|
spellLane := referenceContractLane(t, materialized, "spells")
|
||||||
if len(spellLane.ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 ||
|
if len(spellLane.ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 ||
|
||||||
len(spellLane.NormalizeReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 {
|
len(spellLane.NormalizeReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 {
|
||||||
|
|||||||
268
internal/modules/integration/dnd_locations_runner_test.go
Normal file
268
internal/modules/integration/dnd_locations_runner_test.go
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
package integration_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/evidencecontext"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
locationoccurrencecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationoccurrences"
|
||||||
|
locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locations"
|
||||||
|
locationoccurrences "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationoccurrences"
|
||||||
|
locations "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locations"
|
||||||
|
locationnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLocationRegistryHandoffProducesOccurrencesAndEvidence(t *testing.T) {
|
||||||
|
registries := productionNPCRegistries(t)
|
||||||
|
resolved := resolveLocationPipeline(t, registries)
|
||||||
|
client := &locationHandoffLLMClient{}
|
||||||
|
output, err := runPreparedPipeline(t, registries, resolved, client, pipeline.RunInput{RawInput: readNPCFixture(t)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 2 {
|
||||||
|
t.Fatalf("run outputs = %#v rejected = %#v, want accepted registry and occurrences", output.NormalizeOutputs, output.Rejected)
|
||||||
|
}
|
||||||
|
|
||||||
|
registryOutput := normalizedLane(t, output, "locations")
|
||||||
|
if registryOutput.Artifact.Kind != dnd.LocationListKind || registryOutput.Artifact.Schema.ID != locationcodec.SchemaID {
|
||||||
|
t.Fatalf("registry envelope = %#v, want durable location artifact", registryOutput)
|
||||||
|
}
|
||||||
|
registry, err := locationcodec.New().Decode(registryOutput.Artifact.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decode(registry) error = %v", err)
|
||||||
|
}
|
||||||
|
if len(registry.Locations) != 2 || registry.Locations[0].Name != "Moon Gate" || registry.Locations[1].Name != "Moon Gate" || registry.Locations[0].ID == registry.Locations[1].ID {
|
||||||
|
t.Fatalf("registry = %#v, want same-name locations distinguished by source-derived IDs", registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
occurrenceOutput := normalizedLane(t, output, "occurrences")
|
||||||
|
if occurrenceOutput.Artifact.Kind != dnd.LocationOccurrenceListKind || occurrenceOutput.Artifact.Schema.ID != locationoccurrencecodec.SchemaID {
|
||||||
|
t.Fatalf("occurrence envelope = %#v, want durable occurrence artifact", occurrenceOutput)
|
||||||
|
}
|
||||||
|
occurrences, err := locationoccurrencecodec.New().Decode(occurrenceOutput.Artifact.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decode(occurrences) error = %v", err)
|
||||||
|
}
|
||||||
|
if len(occurrences.Occurrences) != 2 || occurrences.Occurrences[0].LocationID == occurrences.Occurrences[1].LocationID || occurrences.Occurrences[0].LocationID != registry.Locations[0].ID || occurrences.Occurrences[1].LocationID != registry.Locations[1].ID {
|
||||||
|
t.Fatalf("occurrences = %#v, want exact registry ID grounding", occurrences)
|
||||||
|
}
|
||||||
|
for _, occurrence := range occurrences.Occurrences {
|
||||||
|
if occurrence.SourceRefs[0].SourceID != "npc-session" {
|
||||||
|
t.Fatalf("occurrence evidence = %#v, want current source evidence only", occurrence.SourceRefs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request := client.requestFor(t, locationoccurrences.PromptID)
|
||||||
|
registryInput := request.Inputs["locations"]
|
||||||
|
if registryInput.MediaType != locationcodec.MediaType || strings.Contains(string(registryInput.Content), "source_refs") || !strings.Contains(string(registryInput.Content), registry.Locations[0].ID) || !strings.Contains(string(registryInput.Content), registry.Locations[1].ID) {
|
||||||
|
t.Fatalf("occurrence registry input = %#v, want source-free generated ID projection", registryInput)
|
||||||
|
}
|
||||||
|
contextArtifact := outputFileContent(t, output.OutputFiles, "evidence-context.json")
|
||||||
|
evidence, err := evidencecontext.New().Decode(contextArtifact)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decode(evidence context) error = %v", err)
|
||||||
|
}
|
||||||
|
if !locationEvidenceHasLane(evidence, "locations") || !locationEvidenceHasLane(evidence, "occurrences") {
|
||||||
|
t.Fatalf("evidence context = %#v, want registry and occurrence evidence from their own artifacts", evidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationEvidenceHasLane(document evidencecontext.Document, laneID string) bool {
|
||||||
|
for _, context := range document.Contexts {
|
||||||
|
for _, reference := range context.EvidenceRefs {
|
||||||
|
if reference.LaneID == laneID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocationOccurrenceConsumerDoesNotRunAfterRejectedRegistry(t *testing.T) {
|
||||||
|
registries := productionNPCRegistries(t)
|
||||||
|
client := &locationHandoffLLMClient{rejectRegistry: true}
|
||||||
|
_, err := runPreparedPipeline(t, registries, resolveLocationPipeline(t, registries), client, pipeline.RunInput{RawInput: readNPCFixture(t)})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want missing accepted location registry")
|
||||||
|
}
|
||||||
|
if client.requestCount(locationoccurrences.PromptID) != 0 {
|
||||||
|
t.Fatalf("occurrence requests = %d, want none after rejected registry", client.requestCount(locationoccurrences.PromptID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocationOccurrenceConfigurationRequiresEarlierRegistry(t *testing.T) {
|
||||||
|
registries := productionNPCRegistries(t)
|
||||||
|
cfg, err := configFromYAML(strings.Replace(locationPipelineConfig, "step: identify-locations", "step: track-location-occurrences", 1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = cfg.Resolve(config.ResolveInput{PipelineID: "dnd-locations-fixture", Catalog: moduleCatalog(registries)})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "earlier step") {
|
||||||
|
t.Fatalf("Resolve() error = %v, want an earlier generated registry requirement", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocationOccurrenceCheckpointTracksGeneratedRegistry(t *testing.T) {
|
||||||
|
registries := productionNPCRegistries(t)
|
||||||
|
checkpoint := newGeneratedReferenceCheckpointLoader()
|
||||||
|
_, err := runPreparedPipeline(t, registries, resolveLocationPipeline(t, registries), &locationHandoffLLMClient{}, pipeline.RunInput{
|
||||||
|
RawInput: readNPCFixture(t),
|
||||||
|
Checkpoint: checkpoint,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, dependency := range checkpoint.extractDependencies("occurrences") {
|
||||||
|
if dependency.Name == "generated-reference:locations:0" && strings.HasPrefix(dependency.Value, "sha256:") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("occurrence checkpoint dependencies = %#v, want generated location registry fingerprint", checkpoint.extractDependencies("occurrences"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLocationPipeline(t *testing.T, registries pipeline.Registries) pipeline.ResolvedPipeline {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := configFromYAML(locationPipelineConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-locations-fixture", Catalog: moduleCatalog(registries)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", 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)
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func configFromYAML(content string) (config.Config, error) {
|
||||||
|
fileConfig, err := config.ParseFileConfigYAML([]byte(content))
|
||||||
|
if err != nil {
|
||||||
|
return config.Config{}, err
|
||||||
|
}
|
||||||
|
cfg := config.Default()
|
||||||
|
if err := cfg.ApplyFileConfig(fileConfig); err != nil {
|
||||||
|
return config.Config{}, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationPipelineConfig = `version: 4
|
||||||
|
cache:
|
||||||
|
chunk_plans:
|
||||||
|
mode: bypass
|
||||||
|
checkpoints: {}
|
||||||
|
pipelines:
|
||||||
|
dnd-locations-fixture:
|
||||||
|
input: seriatim
|
||||||
|
output:
|
||||||
|
module: json
|
||||||
|
options:
|
||||||
|
evidence_context:
|
||||||
|
enabled: true
|
||||||
|
lanes: [locations, occurrences]
|
||||||
|
steps:
|
||||||
|
- id: identify-locations
|
||||||
|
artifacts:
|
||||||
|
locations:
|
||||||
|
extract: dnd/locations
|
||||||
|
merge: appendorder
|
||||||
|
normalize: dnd/locations
|
||||||
|
- id: track-location-occurrences
|
||||||
|
references:
|
||||||
|
locations:
|
||||||
|
artifact:
|
||||||
|
step: identify-locations
|
||||||
|
lane: locations
|
||||||
|
artifacts:
|
||||||
|
occurrences:
|
||||||
|
extract: dnd/location-occurrences
|
||||||
|
merge: appendorder
|
||||||
|
normalize: dnd/location-occurrences
|
||||||
|
`
|
||||||
|
|
||||||
|
type locationHandoffLLMClient struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
requests []contracts.StructuredCompletionRequest
|
||||||
|
rejectRegistry bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *locationHandoffLLMClient) CompleteStructured(ctx context.Context, request contracts.StructuredCompletionRequest, output any) (contracts.StructuredCompletionResponse, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
|
}
|
||||||
|
client.mu.Lock()
|
||||||
|
client.requests = append(client.requests, cloneStructuredCompletionRequest(request))
|
||||||
|
client.mu.Unlock()
|
||||||
|
var payload any
|
||||||
|
switch request.PromptID {
|
||||||
|
case locations.PromptID:
|
||||||
|
name := "Moon Gate"
|
||||||
|
if client.rejectRegistry {
|
||||||
|
name = ""
|
||||||
|
}
|
||||||
|
payload = map[string]any{"locations": []any{map[string]any{"name": name, "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}, map[string]any{"name": name, "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}}}}}
|
||||||
|
case locationnormalize.PromptID:
|
||||||
|
payload = map[string]any{"duplicate_groups": []any{}}
|
||||||
|
case locationoccurrences.PromptID:
|
||||||
|
var projection struct {
|
||||||
|
Locations []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"locations"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(request.Inputs["locations"].Content, &projection); err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode location registry: %w", err)
|
||||||
|
}
|
||||||
|
if len(projection.Locations) != 2 {
|
||||||
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated location registry has %d locations, want 2", len(projection.Locations))
|
||||||
|
}
|
||||||
|
payload = map[string]any{"occurrences": []any{map[string]any{"location_id": projection.Locations[0].ID, "name": "Moon Gate", "kind": "visited", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}, map[string]any{"location_id": projection.Locations[1].ID, "name": "Moon Gate", "kind": "mentioned", "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}}}}}
|
||||||
|
default:
|
||||||
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", request.PromptID)
|
||||||
|
}
|
||||||
|
content, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(content, output); err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
|
}
|
||||||
|
return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "location-handoff"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *locationHandoffLLMClient) requestFor(t *testing.T, promptID string) contracts.StructuredCompletionRequest {
|
||||||
|
t.Helper()
|
||||||
|
client.mu.Lock()
|
||||||
|
defer client.mu.Unlock()
|
||||||
|
for _, request := range client.requests {
|
||||||
|
if request.PromptID == promptID {
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("requests = %#v, missing %q", client.requests, promptID)
|
||||||
|
return contracts.StructuredCompletionRequest{}
|
||||||
|
}
|
||||||
|
func (client *locationHandoffLLMClient) requestCount(promptID string) int {
|
||||||
|
client.mu.Lock()
|
||||||
|
defer client.mu.Unlock()
|
||||||
|
count := 0
|
||||||
|
for _, request := range client.requests {
|
||||||
|
if request.PromptID == promptID {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ contracts.StructuredLLMClient = (*locationHandoffLLMClient)(nil)
|
||||||
Reference in New Issue
Block a user