Adopt registry-backed item occurrences

This commit is contained in:
2026-08-05 19:55:34 +00:00
parent f91237e9c0
commit 3dfefd0e14
46 changed files with 996 additions and 1034 deletions

View File

@@ -1,18 +1,25 @@
// Package itemevents extracts source-grounded D&D item events.
// Package itemevents extracts source-grounded D&D item occurrences.
package itemevents
import (
"context"
"fmt"
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const Key = "dnd/item-events"
const (
ItemRegistryReferenceSlot = itemregistry.ReferenceSlot
ItemRegistryMaxBytes = itemregistry.MaxBytes
)
const mappingPolicy = "dnd.item_events.extract_mapping.v1"
var requiredCapabilities = []string{
@@ -32,10 +39,20 @@ var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
}
func referenceSlots() []contracts.ReferenceSlot {
return shared.ReferenceSlots(referenceSlotDescriptions)
slots := shared.ReferenceSlots(referenceSlotDescriptions)
slots = append(slots, contracts.ReferenceSlot{
Name: ItemRegistryReferenceSlot,
Description: "Required normalized item registry used only for item identity grounding, never as occurrence evidence.",
Required: true,
AcceptedMediaTypes: []string{"application/json"},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.ItemRegistryKind},
MaxBytes: ItemRegistryMaxBytes,
})
sort.Slice(slots, func(left, right int) bool { return slots[left].Name < slots[right].Name })
return slots
}
var _ contracts.Extractor[dnd.ItemEventList] = (*Extractor)(nil)
var _ contracts.Extractor[dnd.ItemOccurrenceList] = (*Extractor)(nil)
var _ contracts.ManifestMetadataProvider = (*Extractor)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil)
@@ -43,6 +60,7 @@ type Options struct{}
type Extractor struct {
llm contracts.StructuredLLMClient
itemResolver *itemregistry.Resolver
promptSHA string
responseSchemaSHA string
}
@@ -54,6 +72,14 @@ func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contr
if len(references) > 1 {
return nil, extractorErrorf("at most one reference set may be supplied")
}
var referenceSet contracts.ReferenceSet
if len(references) == 1 {
referenceSet = references[0]
}
itemResolver, err := itemregistry.NewResolver(referenceSet)
if err != nil {
return nil, extractorErrorf("prepare item registry prompt input: %w", err)
}
promptSHA, err := promptAssetMetadata()
if err != nil {
return nil, extractorErrorf("load prompt metadata: %w", err)
@@ -62,7 +88,7 @@ func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contr
if err != nil {
return nil, extractorErrorf("load response schema: %w", err)
}
return &Extractor{llm: llmClient, promptSHA: promptSHA, responseSchemaSHA: responseSchema.SHA256}, nil
return &Extractor{llm: llmClient, itemResolver: itemResolver, promptSHA: promptSHA, responseSchemaSHA: responseSchema.SHA256}, nil
}
func (e *Extractor) Key() string { return Key }
@@ -73,7 +99,7 @@ func (e *Extractor) ManifestMetadata() map[string]any {
if e == nil {
return nil
}
return map[string]any{
metadata := map[string]any{
"prompt_id": PromptID,
"prompt_version": SchemaVersion,
"prompt_sha256": e.promptSHA,
@@ -84,6 +110,12 @@ func (e *Extractor) ManifestMetadata() map[string]any {
"response_schema_sha256": e.responseSchemaSHA,
"mapping_policy": mappingPolicy,
}
seeded := e.itemResolver.Seeded()
if seeded.Bound() {
metadata["item_registry_digest"] = seeded.Digest()
metadata["item_count"] = seeded.Count()
}
return metadata
}
func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
@@ -94,35 +126,41 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
{Name: "prompt", Value: e.promptSHA},
{Name: "response_schema", Value: e.responseSchemaSHA},
{Name: "mapping_policy", Value: mappingPolicy},
{Name: "item_registry", Value: e.itemResolver.Seeded().ProjectionDigest()},
}
}
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.ItemEventList], error) {
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.ItemOccurrenceList], error) {
if e == nil {
return contracts.TypedExtractionResult[dnd.ItemEventList]{}, extractorErrorf("extractor must not be nil")
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("extractor must not be nil")
}
if e.llm == nil {
return contracts.TypedExtractionResult[dnd.ItemEventList]{}, extractorErrorf("LLM client must not be nil")
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("LLM client must not be nil")
}
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
if err != nil {
return contracts.TypedExtractionResult[dnd.ItemEventList]{}, extractorErrorf("%w", err)
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("%w", err)
}
order := shared.NewSourceRefOrder(req.Source)
registry, err := e.itemResolver.Resolve(req.References)
if err != nil {
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("resolve item registry: %w", err)
}
if !registry.Bound() {
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("item registry reference is required")
}
var response extractionResponse
inputs := shared.PromptInputs(sourceInput, req.References)
inputs[ItemRegistryReferenceSlot] = registry.PromptInput()
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: Key,
PromptID: PromptID,
PromptVersion: SchemaVersion,
ProfileID: req.LLMProfile,
SessionID: req.SessionID,
Inputs: shared.PromptInputs(sourceInput, req.References),
StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion,
ProfileID: req.LLMProfile, SessionID: req.SessionID, Inputs: inputs,
}, &response); err != nil {
return contracts.TypedExtractionResult[dnd.ItemEventList]{}, extractorErrorf("complete structured output: %w", err)
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("complete structured output: %w", err)
}
canonicalizeResponse(&response, order, req.Source.ID)
return contracts.TypedExtractionResult[dnd.ItemEventList]{Value: canonicalItemEventList(response, req.Source.ID)}, nil
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{Value: canonicalItemOccurrenceList(response, req.Source.ID, registry)}, nil
}
func ModuleSpec() pipeline.ModuleSpec {
@@ -132,13 +170,13 @@ func ModuleSpec() pipeline.ModuleSpec {
ExecutionClass: contracts.ExecutionClassLLMBacked,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.ItemEventListKind,
ArtifactKind: dnd.ItemOccurrenceListKind,
ReferenceSlots: referenceSlots(),
}
}
func Register(registry *pipeline.ExtractorRegistry) error {
return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.ItemEventList], error) {
return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.ItemOccurrenceList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
@@ -160,5 +198,5 @@ func DecodeOptions(options map[string]any) (Options, error) {
}
func extractorErrorf(format string, args ...any) error {
return fmt.Errorf("dnd item events extractor: "+format, args...)
return fmt.Errorf("dnd item occurrences extractor: "+format, args...)
}