package npcoccurrences 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" npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" ) const ( Key = "dnd/npc-occurrences" mappingPolicy = "dnd.npc_occurrences.extract_mapping.v3" ) const ( NPCRegistryReferenceSlot = npcregistry.ReferenceSlot NPCRegistryMaxBytes = npcregistry.MaxBytes ) var requiredCapabilities = []string{ "chunks", "source.transcript", } var providedCapabilities = []string{ "dnd.npc_occurrences", } var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{ Glossary: "Optional campaign glossary reference material used only for occurrence disambiguation.", Party: "Optional party roster reference material used only for occurrence disambiguation.", Players: "Optional player list reference material used only for occurrence disambiguation.", Roster: "Deprecated alias for party roster reference material used only for occurrence disambiguation.", } func referenceSlots() []contracts.ReferenceSlot { slots := shared.ReferenceSlots(referenceSlotDescriptions) slots = append(slots, contracts.ReferenceSlot{ Name: NPCRegistryReferenceSlot, Description: "Required normalized NPC registry used only for occurrence identity grounding, never as occurrence evidence.", Required: true, AcceptedMediaTypes: []string{"application/json"}, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCRegistryKind}, MaxBytes: NPCRegistryMaxBytes, }) sort.Slice(slots, func(i, j int) bool { return slots[i].Name < slots[j].Name }) return slots } var _ contracts.Extractor[dnd.NPCOccurrenceList] = (*Extractor)(nil) var _ contracts.ManifestMetadataProvider = (*Extractor)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil) type Options struct{} type Extractor struct { llm contracts.StructuredLLMClient npcResolver *npcregistry.Resolver promptSHA string responseSchemaSHA string } func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contracts.ReferenceSet) (*Extractor, error) { if llmClient == nil { return nil, extractorErrorf("LLM client must not be nil") } 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] } npcResolver, err := npcregistry.NewResolver(referenceSet) if err != nil { return nil, extractorErrorf("prepare NPC registry prompt input: %w", err) } promptSHA, err := promptAssetMetadata() if err != nil { return nil, extractorErrorf("load prompt metadata: %w", err) } responseSchema, err := loadResponseSchema() if err != nil { return nil, extractorErrorf("load response schema: %w", err) } return &Extractor{ llm: llmClient, npcResolver: npcResolver, promptSHA: promptSHA, responseSchemaSHA: responseSchema.SHA256, }, nil } func (e *Extractor) Key() string { return Key } func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot { return referenceSlots() } func (e *Extractor) ManifestMetadata() map[string]any { if e == nil { return nil } metadata := map[string]any{ "prompt_id": PromptID, "prompt_version": SchemaVersion, "prompt_sha256": e.promptSHA, "mapping_policy": mappingPolicy, "response_schema_key": string(ResponseSchemaKey), "response_schema_id": ResponseSchemaID, "response_schema_name": ResponseSchemaName, "response_schema_version": SchemaVersion, "response_schema_sha256": e.responseSchemaSHA, } seeded := e.npcResolver.Seeded() if seeded.Bound() { metadata["npc_registry_digest"] = seeded.Digest() metadata["npc_count"] = seeded.Count() } return metadata } func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint { if e == nil { return nil } seeded := e.npcResolver.Seeded() return []pipeline.CheckpointFingerprint{ {Name: "prompt", Value: e.promptSHA}, {Name: "response_schema", Value: e.responseSchemaSHA}, {Name: "mapping_policy", Value: mappingPolicy}, {Name: "npc_registry", Value: seeded.IdentityDigest()}, } } func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.NPCOccurrenceList], error) { if e == nil { return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("extractor must not be nil") } if e.llm == nil { return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("LLM client must not be nil") } sourceInput, err := shared.PrepareChunkExtraction(ctx, req) if err != nil { return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("%w", err) } order := shared.NewSourceRefOrder(req.Source) npcRegistry, err := e.npcResolver.Resolve(req.References) if err != nil { return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("resolve NPC registry: %w", err) } if !npcRegistry.Bound() { return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("NPC registry reference is required") } var response extractionResponse inputs := shared.PromptInputs(sourceInput, req.References) inputs[NPCRegistryReferenceSlot] = npcRegistry.PromptInput() if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: req.LLMProfile, SessionID: req.SessionID, Inputs: inputs, }, &response); err != nil { return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("complete structured output: %w", err) } canonicalizeResponse(&response, order, req.Source.ID) value, err := canonicalOccurrenceList(response, req.Source.ID, npcRegistry) if err != nil { return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("resolve NPC names against registry: %w", err) } return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{Value: value}, nil } func ModuleSpec() pipeline.ModuleSpec { return pipeline.ModuleSpec{ Key: Key, Stage: pipeline.StageExtract, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), ArtifactKind: dnd.NPCOccurrenceListKind, ReferenceSlots: referenceSlots(), } } func Register(registry *pipeline.ExtractorRegistry) error { return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.NPCOccurrenceList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err } return New(request.Dependencies.LLM, options, request.References) }) } func validateOptions(options map[string]any) error { _, err := DecodeOptions(options) return err } func DecodeOptions(options map[string]any) (Options, error) { if err := pipeline.RejectUnknownOptions(options); err != nil { return Options{}, extractorErrorf("%w", err) } return Options{}, nil } func extractorErrorf(format string, args ...any) error { return fmt.Errorf("dnd NPC occurrences extractor: "+format, args...) }