diff --git a/assets/dnd/item-occurrences/prompts/instructions.md b/assets/dnd/item-occurrences/prompts/instructions.md index 5bccf69..16e8b1e 100644 --- a/assets/dnd/item-occurrences/prompts/instructions.md +++ b/assets/dnd/item-occurrences/prompts/instructions.md @@ -5,8 +5,8 @@ across records, or infer ownership that the transcript does not establish. 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. +one. Preserve the stated currency denomination through the selected canonical +registry name. 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/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 2eb895b..20d25de 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -274,8 +274,9 @@ path. registry and the current `*source.SourceDocument`. Its API must provide: - a cloned `contracts.LLMInputMaterial` for the `location_registry` slot; - deterministic resolution of a returned selector to one cloned - `dnd.Location`; and - - the digest of the exact model projection. + `dnd.Location`. + The prompt material's existing `Digest` field owns the digest of the exact + model projection; do not expose a second grounding-specific digest API. 4. Construct the projection in registry order. Group entries by the existing location comparison key: - every projection entry has exactly the required fields `name`, @@ -299,10 +300,10 @@ path. 7. Separate deterministic identity fingerprinting from LLM material. Add `IdentityDigest()` over the registry's ordered `{id,name}` identity projection, and update the location normalizer and registry-validator - checkpoint consumers to use it. The new operation grounding owns the model - projection digest. Retain the old ID-bearing prompt accessor only as a - documented transitional dependency of the still-unchanged location - occurrence extractor; do not add new callers. + checkpoint consumers to use it. The new operation grounding carries the + model projection digest in its `LLMInputMaterial`. Retain the old ID-bearing + prompt accessor only as a documented transitional dependency of the + still-unchanged location occurrence extractor; do not add new callers. 8. Add focused tests for unique names, same-name context and selectors, canonical range order, deterministic projection/digest, defensive copies, exact selector resolution, nil/foreign/invalid references, selector @@ -367,10 +368,12 @@ remove durable location IDs from the prompt and private response. 7. Change `mappingPolicy` to `dnd.location_occurrences.extract_mapping.v2`. 8. Remove the legacy ID-bearing registry `PromptInput` and its model-projection - digest once the extractor uses operation grounding. Update the occurrence - checkpoint to combine `IdentityDigest()` with the new grounding projection - digest, prompt/schema fingerprint, mapping policy, and its existing inputs; - do not retain dead compatibility aliases. + digest once the extractor uses operation grounding. Keep the occurrence's + static module fingerprint based on `IdentityDigest()`, prompt/schema + fingerprints, and mapping policy. The operation-scoped projection is already + covered by source/chunk identity and configured or generated reference + dependencies, while its `LLMInputMaterial.Digest` identifies the exact model + input; do not add a second digest API or operation-aware static fingerprint. 9. Update focused schema, prompt, extractor, canonicalization, checkpoint, and generated-reference tests. Cover unique-name empty selectors, successful same-name selection, failure for an unsupported ambiguous mention, diff --git a/internal/modules/dnd/extract/itemoccurrences/canonicalize.go b/internal/modules/dnd/extract/itemoccurrences/canonicalize.go index ab9bba0..0a59060 100644 --- a/internal/modules/dnd/extract/itemoccurrences/canonicalize.go +++ b/internal/modules/dnd/extract/itemoccurrences/canonicalize.go @@ -10,26 +10,34 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" ) -type orderedItemOccurrenceResponse struct { - value itemOccurrenceResponse +type orderedItemOccurrence struct { + value dnd.ItemOccurrence earliest int hasEvidence bool } -func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOrder, sourceID string, registry *itemregistry.Registry) error { - if response == nil { - return nil +func canonicalItemOccurrenceList(response extractionResponse, order shared.SourceRefOrder, sourceID string, registry *itemregistry.Registry) (dnd.ItemOccurrenceList, error) { + if response.Occurrences == nil { + return dnd.ItemOccurrenceList{}, nil } - ordered := make([]orderedItemOccurrenceResponse, len(response.Occurrences)) - for index := range response.Occurrences { - canonical, found := registry.Lookup(response.Occurrences[index].Name) + ordered := make([]orderedItemOccurrence, len(response.Occurrences)) + for index, occurrence := range response.Occurrences { + item, found := registry.Lookup(occurrence.Name) if !found { - return fmt.Errorf("occurrences[%d].name is not in the item registry", index) + return dnd.ItemOccurrenceList{}, 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], + refs := order.Canonicalize(itemOccurrenceSourceRefs(occurrence.SourceRefs, sourceID)) + earliest, hasEvidence := order.EarliestValid(refs) + ordered[index] = orderedItemOccurrence{ + value: dnd.ItemOccurrence{ + ItemID: item.ID, + Name: item.Name, + Kind: dnd.ItemOccurrenceKind(occurrence.Kind), + Quantity: cloneQuantity(occurrence.Quantity), + From: occurrence.From, + To: occurrence.To, + SourceRefs: refs, + }, earliest: earliest, hasEvidence: hasEvidence, } @@ -43,40 +51,9 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr } return ordered[left].earliest < ordered[right].earliest }) - for index := range ordered { - response.Occurrences[index] = ordered[index].value - } - return nil -} - -func canonicalizeItemOccurrence(occurrence *itemOccurrenceResponse, order shared.SourceRefOrder, sourceID string) (int, bool) { - if occurrence == nil { - return 0, false - } - refs := order.Canonicalize(itemOccurrenceSourceRefs(occurrence.SourceRefs, sourceID)) - occurrence.SourceRefs = itemOccurrenceResponseRefs(refs) - return order.EarliestValid(refs) -} - -func canonicalItemOccurrenceList(response extractionResponse, sourceID string, registry *itemregistry.Registry) (dnd.ItemOccurrenceList, error) { - if response.Occurrences == nil { - return dnd.ItemOccurrenceList{}, nil - } - occurrences := make([]dnd.ItemOccurrence, 0, len(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: item.ID, - Name: item.Name, - Kind: dnd.ItemOccurrenceKind(occurrence.Kind), - Quantity: cloneQuantity(occurrence.Quantity), - From: occurrence.From, - To: occurrence.To, - SourceRefs: itemOccurrenceSourceRefs(occurrence.SourceRefs, sourceID), - }) + occurrences := make([]dnd.ItemOccurrence, len(ordered)) + for index, occurrence := range ordered { + occurrences[index] = occurrence.value } return dnd.ItemOccurrenceList{Occurrences: occurrences}, nil } @@ -92,17 +69,6 @@ func itemOccurrenceSourceRefs(refs []itemOccurrenceSourceRefResponse, sourceID s return values } -func itemOccurrenceResponseRefs(refs []source.SourceRef) []itemOccurrenceSourceRefResponse { - if refs == nil { - return nil - } - values := make([]itemOccurrenceSourceRefResponse, len(refs)) - for index, ref := range refs { - values[index] = itemOccurrenceSourceRefResponse{StartSegment: ref.StartUnitID, EndSegment: ref.EndUnitID} - } - return values -} - func cloneQuantity(value *int) *int { if value == nil { return nil diff --git a/internal/modules/dnd/extract/itemoccurrences/extractor.go b/internal/modules/dnd/extract/itemoccurrences/extractor.go index 4a1903f..b61fc58 100644 --- a/internal/modules/dnd/extract/itemoccurrences/extractor.go +++ b/internal/modules/dnd/extract/itemoccurrences/extractor.go @@ -161,12 +161,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe }, &response); err != nil { return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("complete structured output: %w", err) } - if err := canonicalizeResponse(&response, order, req.Source.ID, registry); err != nil { - return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("map item occurrence response: %w", err) - } - value, err := canonicalItemOccurrenceList(response, req.Source.ID, registry) + value, err := canonicalItemOccurrenceList(response, order, 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]{}, extractorErrorf("map item occurrence response: %w", err) } return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{Value: value}, nil } diff --git a/internal/modules/dnd/locations/registry/grounding.go b/internal/modules/dnd/locations/registry/grounding.go index 56803e5..db2bb78 100644 --- a/internal/modules/dnd/locations/registry/grounding.go +++ b/internal/modules/dnd/locations/registry/grounding.go @@ -36,7 +36,6 @@ type ContextUnit struct { // Its prompt input contains no durable IDs or source IDs. type Grounding struct { promptInput contracts.LLMInputMaterial - projectionDigest string locationsBySelector map[string]dnd.Location } @@ -104,7 +103,6 @@ func NewGrounding(registry *Registry, doc *source.SourceDocument) (*Grounding, e digest := semanticDigest(content) return &Grounding{ promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, locationcodec.MediaType, content, digest, ""), - projectionDigest: digest, locationsBySelector: locationsBySelector, }, nil } @@ -118,14 +116,6 @@ func (g *Grounding) PromptInput() contracts.LLMInputMaterial { return g.promptInput.Clone() } -// ProjectionDigest returns the digest of the exact contextual prompt input. -func (g *Grounding) ProjectionDigest() string { - if g == nil { - return "" - } - return g.projectionDigest -} - // Resolve maps a contextual selector to one canonical location. func (g *Grounding) Resolve(selector Selector) (dnd.Location, bool) { if g == nil { diff --git a/internal/modules/dnd/locations/registry/registry_test.go b/internal/modules/dnd/locations/registry/registry_test.go index e081837..a8a0a74 100644 --- a/internal/modules/dnd/locations/registry/registry_test.go +++ b/internal/modules/dnd/locations/registry/registry_test.go @@ -216,8 +216,8 @@ func TestGroundingProjectsAndResolvesUniqueAndSameNameLocations(t *testing.T) { if bytes.Contains(grounding.PromptInput().Content, []byte("location:sha256:")) || bytes.Contains(grounding.PromptInput().Content, []byte(`"source_id"`)) { t.Fatalf("grounding projection exposed durable identity: %s", grounding.PromptInput().Content) } - if grounding.ProjectionDigest() == "" || grounding.ProjectionDigest() == registry.IdentityDigest() || grounding.PromptInput().Digest != grounding.ProjectionDigest() { - t.Fatalf("grounding/identity digests = %q/%q", grounding.ProjectionDigest(), registry.IdentityDigest()) + if grounding.PromptInput().Digest == "" || grounding.PromptInput().Digest == registry.IdentityDigest() { + t.Fatalf("grounding/identity digests = %q/%q", grounding.PromptInput().Digest, registry.IdentityDigest()) } resolved, ok := grounding.Resolve(Selector{Name: " the tavern ", RegistryRefs: []RegistryRef{{StartUnitID: 30, EndUnitID: 30}, {StartUnitID: 20, EndUnitID: 20}}}) @@ -276,8 +276,8 @@ func TestGroundingHandlesEmptyRegistriesAndIdentityFingerprints(t *testing.T) { if err != nil { t.Fatal(err) } - if base.IdentityDigest() != expanded.IdentityDigest() || baseGrounding.ProjectionDigest() != expandedGrounding.ProjectionDigest() { - t.Fatalf("identity/model fingerprints = %q/%q and %q/%q", base.IdentityDigest(), expanded.IdentityDigest(), baseGrounding.ProjectionDigest(), expandedGrounding.ProjectionDigest()) + if base.IdentityDigest() != expanded.IdentityDigest() || baseGrounding.PromptInput().Digest != expandedGrounding.PromptInput().Digest { + t.Fatalf("identity/model fingerprints = %q/%q and %q/%q", base.IdentityDigest(), expanded.IdentityDigest(), baseGrounding.PromptInput().Digest, expandedGrounding.PromptInput().Digest) } }