From 8e680cf96ee2cad2522e2b75bf3d329e4ab2f262 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 8 Aug 2026 14:43:04 +0000 Subject: [PATCH] Ground item occurrences by canonical names --- .../item-occurrences/prompts/instructions.md | 8 +- .../item-occurrences/prompts/item-registry.md | 6 +- .../schemas/dnd_item_occurrences_llm.v1.json | 3 +- .../cli/dnd_enemy_events_contract_test.go | 14 +-- .../extract/itemoccurrences/canonicalize.go | 33 +++---- .../dnd/extract/itemoccurrences/extractor.go | 8 +- .../extract/itemoccurrences/extractor_test.go | 96 ++++++++++--------- .../dnd/extract/itemoccurrences/model.go | 1 - .../itemoccurrences/prompt_assets_test.go | 2 +- .../extract/itemoccurrences/registry_test.go | 9 ++ .../extract/itemoccurrences/schema_test.go | 7 +- .../modules/dnd/items/registry/registry.go | 9 +- .../dnd/items/registry/registry_test.go | 9 +- 13 files changed, 112 insertions(+), 93 deletions(-) diff --git a/assets/dnd/item-occurrences/prompts/instructions.md b/assets/dnd/item-occurrences/prompts/instructions.md index 72d2a4a..5bccf69 100644 --- a/assets/dnd/item-occurrences/prompts/instructions.md +++ b/assets/dnd/item-occurrences/prompts/instructions.md @@ -3,10 +3,10 @@ in party possession established by the transcript. This is an occurrence history not an inventory or ledger: do not calculate balances, resolve item identity across records, or infer ownership that the transcript does not establish. -For every occurrence, copy the exact `item_id` and `name` pair from the supplied -item registry. Record a stated quantity as an integer and leave it null when the transcript -does not state one. Use a concise observed item name and preserve the stated -currency denomination. +For every occurrence, use the supplied canonical item `name`. Record a stated +quantity as an integer and leave it null when the transcript does not state +one. Use a concise observed item name and preserve the stated currency +denomination. Use `discovered` when the party learns of or encounters an item without establishing possession. Use `acquired` when the party or a party member gains diff --git a/assets/dnd/item-occurrences/prompts/item-registry.md b/assets/dnd/item-occurrences/prompts/item-registry.md index 8ae3993..924fd19 100644 --- a/assets/dnd/item-occurrences/prompts/item-registry.md +++ b/assets/dnd/item-occurrences/prompts/item-registry.md @@ -1,6 +1,6 @@ Use the supplied item registry only to ground each occurrence. Every record -must copy one registry item's exact `id` and exact `name`; do not invent, -rename, merge, or infer registry items. The registry is not transcript -evidence: cite only the current transcript chunk in `source_refs`. +must use one registry item's canonical `name`; do not invent, rename, merge, +or infer registry items. The registry is not transcript evidence: cite only the +current transcript chunk in `source_refs`. {{ input "item_registry" }} diff --git a/assets/dnd/item-occurrences/schemas/dnd_item_occurrences_llm.v1.json b/assets/dnd/item-occurrences/schemas/dnd_item_occurrences_llm.v1.json index 8ef3dfb..bb452ba 100644 --- a/assets/dnd/item-occurrences/schemas/dnd_item_occurrences_llm.v1.json +++ b/assets/dnd/item-occurrences/schemas/dnd_item_occurrences_llm.v1.json @@ -10,9 +10,8 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["item_id", "name", "kind", "quantity", "from", "to", "source_refs"], + "required": ["name", "kind", "quantity", "from", "to", "source_refs"], "properties": { - "item_id": {"type": "string"}, "name": {"type": "string"}, "kind": {"type": "string"}, "quantity": {"type": ["integer", "null"]}, diff --git a/internal/cli/dnd_enemy_events_contract_test.go b/internal/cli/dnd_enemy_events_contract_test.go index f376131..48942fb 100644 --- a/internal/cli/dnd_enemy_events_contract_test.go +++ b/internal/cli/dnd_enemy_events_contract_test.go @@ -230,7 +230,7 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te requiresIDs bool }{ {promptID: npcoccurrences.PromptID, slot: "npc_registry", name: "Kesh"}, - {promptID: itemoccurrences.PromptID, slot: "item_registry", name: "Moonblade", requiresIDs: true}, + {promptID: itemoccurrences.PromptID, slot: "item_registry", name: "Moonblade"}, } { requests := client.requestsFor(test.promptID) if len(requests) != 2 { @@ -321,16 +321,16 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque } else { var registry struct { Items []struct { - ID string `json:"id"` + Name string `json:"name"` } `json:"items"` } if err := json.Unmarshal(request.Inputs["item_registry"].Content, ®istry); err != nil { return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated item registry: %w", err) } - if len(registry.Items) != 1 { + if len(registry.Items) != 1 || registry.Items[0].Name != "Moonblade" { return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated item registry has %d items, want 1", len(registry.Items)) } - content = []byte(fmt.Sprintf(`{"occurrences":[{"item_id":%q,"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":5,"end_segment":5}]}]}`, registry.Items[0].ID)) + content = []byte(`{"occurrences":[{"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":5,"end_segment":5}]}]}`) } case combat.PromptID: content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`) @@ -338,16 +338,16 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque if combatScene { var registry struct { NPCs []struct { - ID string `json:"id"` + Name string `json:"name"` } `json:"npcs"` } if err := json.Unmarshal(request.Inputs["npc_registry"].Content, ®istry); err != nil { return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated NPC registry: %w", err) } - if len(registry.NPCs) == 0 { + if len(registry.NPCs) == 0 || registry.NPCs[0].Name != "Kesh" { return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated NPC registry has no NPCs") } - content = []byte(fmt.Sprintf(`{"occurrences":[{"npc_id":%q,"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`, registry.NPCs[0].ID)) + content = []byte(`{"occurrences":[{"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`) } else { content = []byte(`{"occurrences":[]}`) } diff --git a/internal/modules/dnd/extract/itemoccurrences/canonicalize.go b/internal/modules/dnd/extract/itemoccurrences/canonicalize.go index cbd37ba..ab9bba0 100644 --- a/internal/modules/dnd/extract/itemoccurrences/canonicalize.go +++ b/internal/modules/dnd/extract/itemoccurrences/canonicalize.go @@ -22,9 +22,11 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr } ordered := make([]orderedItemOccurrenceResponse, len(response.Occurrences)) for index := range response.Occurrences { - if err := validateRegistryPair(index, response.Occurrences[index], registry); err != nil { - return err + canonical, found := registry.Lookup(response.Occurrences[index].Name) + if !found { + return fmt.Errorf("occurrences[%d].name is not in the item registry", index) } + response.Occurrences[index].Name = canonical.Name earliest, hasEvidence := canonicalizeItemOccurrence(&response.Occurrences[index], order, sourceID) ordered[index] = orderedItemOccurrenceResponse{ value: response.Occurrences[index], @@ -56,26 +58,19 @@ func canonicalizeItemOccurrence(occurrence *itemOccurrenceResponse, order shared return order.EarliestValid(refs) } -func validateRegistryPair(index int, occurrence itemOccurrenceResponse, registry *itemregistry.Registry) error { - item, found := registry.LookupID(occurrence.ItemID) - if !found { - return fmt.Errorf("occurrences[%d].item_id is not in the item registry", index) - } - if item.Name != occurrence.Name { - return fmt.Errorf("occurrences[%d].name does not match item_id", index) - } - return nil -} - -func canonicalItemOccurrenceList(response extractionResponse, sourceID string) dnd.ItemOccurrenceList { +func canonicalItemOccurrenceList(response extractionResponse, sourceID string, registry *itemregistry.Registry) (dnd.ItemOccurrenceList, error) { if response.Occurrences == nil { - return dnd.ItemOccurrenceList{} + return dnd.ItemOccurrenceList{}, nil } occurrences := make([]dnd.ItemOccurrence, 0, len(response.Occurrences)) - for _, occurrence := range response.Occurrences { + for index, occurrence := range response.Occurrences { + item, found := registry.Lookup(occurrence.Name) + if !found { + return dnd.ItemOccurrenceList{}, fmt.Errorf("occurrences[%d].name is not in the item registry", index) + } occurrences = append(occurrences, dnd.ItemOccurrence{ - ItemID: occurrence.ItemID, - Name: occurrence.Name, + ItemID: item.ID, + Name: item.Name, Kind: dnd.ItemOccurrenceKind(occurrence.Kind), Quantity: cloneQuantity(occurrence.Quantity), From: occurrence.From, @@ -83,7 +78,7 @@ func canonicalItemOccurrenceList(response extractionResponse, sourceID string) d SourceRefs: itemOccurrenceSourceRefs(occurrence.SourceRefs, sourceID), }) } - return dnd.ItemOccurrenceList{Occurrences: occurrences} + return dnd.ItemOccurrenceList{Occurrences: occurrences}, nil } func itemOccurrenceSourceRefs(refs []itemOccurrenceSourceRefResponse, sourceID string) []source.SourceRef { diff --git a/internal/modules/dnd/extract/itemoccurrences/extractor.go b/internal/modules/dnd/extract/itemoccurrences/extractor.go index d0e76d2..4a1903f 100644 --- a/internal/modules/dnd/extract/itemoccurrences/extractor.go +++ b/internal/modules/dnd/extract/itemoccurrences/extractor.go @@ -20,7 +20,7 @@ const ( ItemRegistryMaxBytes = itemregistry.MaxBytes ) -const mappingPolicy = "dnd.item_occurrences.extract_mapping.v1" +const mappingPolicy = "dnd.item_occurrences.extract_mapping.v2" var requiredCapabilities = []string{ "chunks", @@ -164,7 +164,11 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe if err := canonicalizeResponse(&response, order, req.Source.ID, registry); err != nil { return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("map item occurrence response: %w", err) } - return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{Value: canonicalItemOccurrenceList(response, req.Source.ID)}, nil + value, err := canonicalItemOccurrenceList(response, req.Source.ID, registry) + if err != nil { + return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("resolve canonical item occurrence names: %w", err) + } + return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{Value: value}, nil } func ModuleSpec() pipeline.ModuleSpec { diff --git a/internal/modules/dnd/extract/itemoccurrences/extractor_test.go b/internal/modules/dnd/extract/itemoccurrences/extractor_test.go index 0f19ac6..acc50d5 100644 --- a/internal/modules/dnd/extract/itemoccurrences/extractor_test.go +++ b/internal/modules/dnd/extract/itemoccurrences/extractor_test.go @@ -12,7 +12,7 @@ import ( func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) { id := itemidentity.DeriveID("Torch") client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{ - {ItemID: id, Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}, + {Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}, }}} req := extractionRequest() req.References = itemRegistryReferences(t) @@ -24,55 +24,38 @@ func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) { t.Fatalf("occurrences = %#v", result.Value.Occurrences) } input := client.requests[0].Inputs[ItemRegistryReferenceSlot] - if input.Name != ItemRegistryReferenceSlot || string(input.Content) == "" { - t.Fatalf("registry prompt input = %#v", input) + if input.Name != ItemRegistryReferenceSlot || string(input.Content) != `{"items":[{"name":"Torch"}]}` || strings.Contains(string(input.Content), "item:sha256:") { + t.Fatalf("registry prompt input = %#v, want names-only projection", input) } } -func TestExtractRejectsInvalidRegistryPairs(t *testing.T) { - id := itemidentity.DeriveID("Torch") - for _, test := range []struct { - name string - occurrence itemOccurrenceResponse - wantError string - }{ - { - name: "unknown item ID", - occurrence: itemOccurrenceResponse{ItemID: "unknown", Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}, - wantError: "occurrences[0].item_id is not in the item registry", - }, - { - name: "mismatched item name", - occurrence: itemOccurrenceResponse{ItemID: id, Name: "Lantern", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}, - wantError: "occurrences[0].name does not match item_id", - }, - } { - t.Run(test.name, func(t *testing.T) { - client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{test.occurrence}}} - req := extractionRequest() - req.References = itemRegistryReferences(t) - result, err := newExtractor(t, client, req.References).Extract(context.Background(), req) - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Fatalf("Extract() error = %v, want %q", err, test.wantError) - } - if len(result.Value.Occurrences) != 0 { - t.Fatalf("Extract() returned partial occurrences: %#v", result.Value.Occurrences) - } - }) - } -} - -func TestExtractRejectsResponseWithInvalidRegistryPairAfterValidOccurrence(t *testing.T) { +func TestExtractCanonicalizesComparisonEquivalentNames(t *testing.T) { id := itemidentity.DeriveID("Torch") client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{ - {ItemID: id, Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}, - {ItemID: "unknown", Name: "Unknown", Kind: "lost", From: "party", SourceRefs: responseRefs(2, 2)}, + {Name: " tORCH ", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}, }}} req := extractionRequest() req.References = itemRegistryReferences(t) result, err := newExtractor(t, client, req.References).Extract(context.Background(), req) - if err == nil || !strings.Contains(err.Error(), "occurrences[1].item_id") { - t.Fatalf("Extract() error = %v, want occurrence index and item ID", err) + if err != nil { + t.Fatal(err) + } + occurrence := result.Value.Occurrences[0] + if occurrence.ItemID != id || occurrence.Name != "Torch" { + t.Fatalf("canonical occurrence = %#v", occurrence) + } +} + +func TestExtractRejectsUnknownNameAfterValidOccurrence(t *testing.T) { + client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{ + {Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}, + {Name: "Unknown", Kind: "lost", From: "party", SourceRefs: responseRefs(2, 2)}, + }}} + req := extractionRequest() + req.References = itemRegistryReferences(t) + result, err := newExtractor(t, client, req.References).Extract(context.Background(), req) + if err == nil || !strings.Contains(err.Error(), "occurrences[1].name is not in the item registry") { + t.Fatalf("Extract() error = %v, want occurrence index and unknown name", err) } if len(result.Value.Occurrences) != 0 { t.Fatalf("Extract() returned partial occurrences: %#v", result.Value.Occurrences) @@ -86,9 +69,36 @@ func TestExtractRequiresItemRegistry(t *testing.T) { } } +func TestExtractAcceptsOnlyEmptyResponseForEmptyRegistry(t *testing.T) { + references := emptyItemRegistryReferences(t) + for _, test := range []struct { + name string + response extractionResponse + wantErr bool + }{ + {name: "empty", response: extractionResponse{Occurrences: []itemOccurrenceResponse{}}}, + {name: "selection", response: extractionResponse{Occurrences: []itemOccurrenceResponse{{Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}}}, wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + client := &fakeItemOccurrencesLLMClient{response: test.response} + req := extractionRequest() + req.References = references + result, err := newExtractor(t, client, references).Extract(context.Background(), req) + if test.wantErr { + if err == nil || !strings.Contains(err.Error(), "name is not in the item registry") || len(result.Value.Occurrences) != 0 { + t.Fatalf("Extract() = %#v, %v; want no accepted occurrences", result, err) + } + return + } + if err != nil || result.Value.Occurrences == nil || len(result.Value.Occurrences) != 0 { + t.Fatalf("Extract() = %#v, %v; want present empty occurrences", result, err) + } + }) + } +} + func TestExtractPreservesNullableFields(t *testing.T) { - id := itemidentity.DeriveID("Torch") - client := &fakeItemOccurrencesLLMClient{content: []byte(`{"occurrences":[{"item_id":"` + id + `","name":"Torch","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":1,"end_segment":1}]}]}`)} + client := &fakeItemOccurrencesLLMClient{content: []byte(`{"occurrences":[{"name":"Torch","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":1,"end_segment":1}]}]}`)} req := extractionRequest() req.References = itemRegistryReferences(t) result, err := newExtractor(t, client, req.References).Extract(context.Background(), req) diff --git a/internal/modules/dnd/extract/itemoccurrences/model.go b/internal/modules/dnd/extract/itemoccurrences/model.go index c205d80..d450bf6 100644 --- a/internal/modules/dnd/extract/itemoccurrences/model.go +++ b/internal/modules/dnd/extract/itemoccurrences/model.go @@ -5,7 +5,6 @@ type extractionResponse struct { } type itemOccurrenceResponse struct { - ItemID string `json:"item_id"` Name string `json:"name"` Kind string `json:"kind"` Quantity *int `json:"quantity,omitempty"` diff --git a/internal/modules/dnd/extract/itemoccurrences/prompt_assets_test.go b/internal/modules/dnd/extract/itemoccurrences/prompt_assets_test.go index 77640d6..cfac6d2 100644 --- a/internal/modules/dnd/extract/itemoccurrences/prompt_assets_test.go +++ b/internal/modules/dnd/extract/itemoccurrences/prompt_assets_test.go @@ -34,7 +34,7 @@ func TestPromptAssetsPrepareItemOccurrencePrompt(t *testing.T) { "players": promptkit.Inline("item-occurrence-player"), "party": promptkit.Inline(" "), "glossary": promptkit.Inline(" "), - "item_registry": promptkit.Inline(`{"items":[{"id":"item:sha256:test","name":"Torch"}]}`), + "item_registry": promptkit.Inline(`{"items":[{"name":"Torch"}]}`), }, }) if err != nil { diff --git a/internal/modules/dnd/extract/itemoccurrences/registry_test.go b/internal/modules/dnd/extract/itemoccurrences/registry_test.go index d50454c..c3d9793 100644 --- a/internal/modules/dnd/extract/itemoccurrences/registry_test.go +++ b/internal/modules/dnd/extract/itemoccurrences/registry_test.go @@ -21,6 +21,15 @@ func itemRegistryReferences(t *testing.T) contracts.ReferenceSet { return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ItemRegistryReferenceSlot: {Slot: contracts.ReferenceSlot{Name: ItemRegistryReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: ItemRegistryReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}} } +func emptyItemRegistryReferences(t *testing.T) contracts.ReferenceSet { + t.Helper() + content, err := itemcodec.New().Encode(dnd.ItemRegistry{Items: []dnd.Item{}}) + if err != nil { + t.Fatal(err) + } + return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ItemRegistryReferenceSlot: {Slot: contracts.ReferenceSlot{Name: ItemRegistryReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: ItemRegistryReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}} +} + func testSourceRefs() []source.SourceRef { return []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}} } diff --git a/internal/modules/dnd/extract/itemoccurrences/schema_test.go b/internal/modules/dnd/extract/itemoccurrences/schema_test.go index c2c1fc5..6ca1505 100644 --- a/internal/modules/dnd/extract/itemoccurrences/schema_test.go +++ b/internal/modules/dnd/extract/itemoccurrences/schema_test.go @@ -19,11 +19,11 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) { } valid := map[string]any{"occurrences": []any{ map[string]any{ - "item_id": "item:sha256:test", "name": "", "kind": "unsupported", "quantity": 0, "from": "party", "to": "Party", + "name": "", "kind": "unsupported", "quantity": 0, "from": "party", "to": "Party", "source_refs": []any{map[string]any{"start_segment": 0, "end_segment": -1}}, }, map[string]any{ - "item_id": "item:sha256:test", "name": "Hidden Cache", "kind": "discovered", "quantity": nil, "from": nil, "to": nil, + "name": "Hidden Cache", "kind": "discovered", "quantity": nil, "from": nil, "to": nil, "source_refs": []any{map[string]any{"start_segment": 1, "end_segment": 1}}, }, }} @@ -41,6 +41,7 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) { {"missing occurrences", map[string]any{}}, {"missing occurrence name", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "name")}}}, {"missing nullable field", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "quantity")}}}, + {"opaque item identifier", map[string]any{"occurrences": []any{withField(responseOccurrence(), "item_id", "item:sha256:opaque")}}}, {"unknown occurrence field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "extra", true)}}}, {"unknown reference field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1, "end_segment": 1, "extra": true}})}}}, {"noninteger range", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1.5, "end_segment": 1}})}}}, @@ -68,7 +69,7 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) { func responseOccurrence() map[string]any { return map[string]any{ - "item_id": "item:sha256:test", "name": "Ring", "kind": "acquired", "quantity": nil, "from": nil, "to": "party", "source_refs": []any{}, + "name": "Ring", "kind": "acquired", "quantity": nil, "from": nil, "to": "party", "source_refs": []any{}, } } diff --git a/internal/modules/dnd/items/registry/registry.go b/internal/modules/dnd/items/registry/registry.go index 2792617..c92d6fb 100644 --- a/internal/modules/dnd/items/registry/registry.go +++ b/internal/modules/dnd/items/registry/registry.go @@ -185,7 +185,7 @@ func (r *Registry) Digest() string { return r.digest } -// ProjectionDigest returns the SHA-256 digest of the exact ID/name prompt +// ProjectionDigest returns the SHA-256 digest of the exact names-only prompt // projection, including for an unbound or empty registry. func (r *Registry) ProjectionDigest() string { if r == nil { @@ -202,8 +202,8 @@ func (r *Registry) Count() int { return len(r.list.Items) } -// PromptInput returns the ordered ID/name registry projection as a content-safe -// prompt input. Evidence and reference provenance are omitted. +// PromptInput returns the ordered names-only registry projection as a +// content-safe prompt input. Durable IDs, evidence, and provenance are omitted. func (r *Registry) PromptInput() contracts.LLMInputMaterial { if r == nil { return contracts.LLMInputMaterial{} @@ -242,7 +242,6 @@ func semanticDigest(content []byte) string { } type projectedItem struct { - ID string `json:"id"` Name string `json:"name"` } @@ -253,7 +252,7 @@ type projectedItemRegistry struct { func promptProjection(list dnd.ItemRegistry) ([]byte, error) { projection := projectedItemRegistry{Items: make([]projectedItem, len(list.Items))} for index, item := range list.Items { - projection.Items[index] = projectedItem{ID: item.ID, Name: item.Name} + projection.Items[index] = projectedItem{Name: item.Name} } return json.Marshal(projection) } diff --git a/internal/modules/dnd/items/registry/registry_test.go b/internal/modules/dnd/items/registry/registry_test.go index 7947ba9..a1ee048 100644 --- a/internal/modules/dnd/items/registry/registry_test.go +++ b/internal/modules/dnd/items/registry/registry_test.go @@ -25,16 +25,16 @@ func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) { } } -func TestResolveProjectsOrderedIDsAndNamesWithoutEvidence(t *testing.T) { +func TestResolveProjectsOrderedNamesWithoutEvidence(t *testing.T) { registry := resolveRegistry(t, fixture()) if !registry.Bound() || registry.Digest() == "" || registry.Count() != 2 { t.Fatalf("registry identity = bound %t digest %q count %d", registry.Bound(), registry.Digest(), registry.Count()) } - want := `{"items":[{"id":"` + identity.DeriveID("Silver Key") + `","name":"Silver Key"},{"id":"` + identity.DeriveID("Healer's Kit") + `","name":"Healer's Kit"}]}` + want := `{"items":[{"name":"Silver Key"},{"name":"Healer's Kit"}]}` if got := string(registry.PromptInput().Content); got != want { t.Fatalf("prompt projection = %s, want %s", got, want) } - for _, forbidden := range []string{"source_refs", "source_id", "session-alpha"} { + for _, forbidden := range []string{"item:sha256:", "source_refs", "source_id", "session-alpha"} { if strings.Contains(string(registry.PromptInput().Content), forbidden) { t.Fatalf("projection leaked %q: %s", forbidden, registry.PromptInput().Content) } @@ -52,6 +52,9 @@ func TestRegistryAccessorsAndLookupsAreDefensive(t *testing.T) { if item, ok := registry.LookupID(identity.DeriveID("Healer's Kit")); !ok || item.Name != "Healer's Kit" { t.Fatalf("LookupID() = %#v, %t", item, ok) } + if _, ok := registry.Lookup("Unknown Item"); ok { + t.Fatal("Lookup() accepted an unknown item") + } items := registry.Items() items[0].Name = "changed"