From 9c5e3cff1444b082db3f84a970b635b88cad78ce Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 4 Aug 2026 00:45:50 +0000 Subject: [PATCH] Add D&D location tracking to complete example --- examples/dnd-complete.config.yml | 24 +- .../cli/dnd_enemy_events_contract_test.go | 73 ++++- internal/cli/example_contract_test.go | 20 +- .../integration/dnd_locations_runner_test.go | 268 ++++++++++++++++++ 4 files changed, 378 insertions(+), 7 deletions(-) create mode 100644 internal/modules/integration/dnd_locations_runner_test.go diff --git a/examples/dnd-complete.config.yml b/examples/dnd-complete.config.yml index 49cc8a1..e8da21c 100644 --- a/examples/dnd-complete.config.yml +++ b/examples/dnd-complete.config.yml @@ -36,6 +36,8 @@ pipelines: window_units: 3 lanes: - item-events + - locations + - location-occurrences - npcs - spells - combat-turns @@ -59,6 +61,14 @@ pipelines: normalize: module: dnd/npcs retries: 2 + locations: + extract: + module: dnd/locations + retries: 2 + merge: appendorder + normalize: + module: dnd/locations + retries: 2 scene-descriptions: extract: module: dnd/scene-descriptions @@ -66,9 +76,13 @@ pipelines: merge: appendorder normalize: dnd/scene-descriptions - id: extract-events - # Accepted NPC grounding and scene-description eligibility artifacts are - # supplied in memory to their compatible consumers in this step. + # Accepted registry artifacts and scene-description eligibility artifacts + # are supplied in memory to their compatible consumers in this step. references: + locations: + artifact: + step: describe-session + lane: locations npcs: artifact: step: describe-session @@ -102,6 +116,12 @@ pipelines: retries: 2 merge: appendorder normalize: dnd/npc-interactions + location-occurrences: + extract: + module: dnd/location-occurrences + retries: 2 + merge: appendorder + normalize: dnd/location-occurrences - id: track-enemies references: npcs: diff --git a/internal/cli/dnd_enemy_events_contract_test.go b/internal/cli/dnd_enemy_events_contract_test.go index c08912d..f0f60ae 100644 --- a/internal/cli/dnd_enemy_events_contract_test.go +++ b/internal/cli/dnd_enemy_events_contract_test.go @@ -20,14 +20,19 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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" enemyevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents" 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" npcs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" scenedescriptions "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" 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" ) @@ -117,10 +122,15 @@ func TestMaintainedCompleteExampleProducesEnemyEventsThroughGeneratedHandoffs(t runRoot := filepath.Join(outputRoot, productionRunID) index := readProductionJSON[exampleOutputIndex](t, filepath.Join(runRoot, "index.json")) var enemyOutput exampleOutputIndexEntry + var locationOutput, occurrenceOutput exampleOutputIndexEntry for _, entry := range index.OutputFiles { - if entry.LaneID == "enemy-events" { + switch entry.LaneID { + case "enemy-events": 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" { @@ -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 { 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")) - if !containsString(evidence.SelectedLanes, "enemy-events") || !evidenceHasLane(evidence, "enemy-events") { - t.Fatalf("evidence context = %#v, want direct enemy-event evidence", evidence) + for _, laneID := range []string{"enemy-events", "locations", "location-occurrences"} { + if !containsString(evidence.SelectedLanes, laneID) || !evidenceHasLane(evidence, laneID) { + t.Fatalf("evidence context = %#v, want direct %s evidence", evidence, laneID) + } } 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) } } + 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 { @@ -207,6 +243,14 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque kind, title = "combat", "Raiders attack" } 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: content = []byte(`{"spell_casts":[]}`) case itemevents.PromptID: @@ -219,6 +263,27 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque } else { 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: content = []byte(`{"events":[{"name":"Kesh","kind":"fled","source_refs":[{"start_unit_id":10,"end_unit_id":10}]}]}`) default: diff --git a/internal/cli/example_contract_test.go b/internal/cli/example_contract_test.go index cdca615..a95866b 100644 --- a/internal/cli/example_contract_test.go +++ b/internal/cli/example_contract_test.go @@ -15,6 +15,10 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "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" "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) } 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) } + 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") if len(spellLane.ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 || len(spellLane.NormalizeReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 { diff --git a/internal/modules/integration/dnd_locations_runner_test.go b/internal/modules/integration/dnd_locations_runner_test.go new file mode 100644 index 0000000..719656b --- /dev/null +++ b/internal/modules/integration/dnd_locations_runner_test.go @@ -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)