package enemyevents import ( "context" "fmt" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" sceneregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/scenedescriptions/registry" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" ) const ( Key = "dnd/enemy-events" mappingPolicy = "dnd.enemy_events.extract_mapping.v1" sceneGatePolicy = "dnd.enemy_events.scene_gate.v1" ) var requiredCapabilities = []string{ "chunks", "source.transcript", } var providedCapabilities = []string{ "dnd.enemy_events", } var _ contracts.Extractor[dnd.EnemyEventList] = (*Extractor)(nil) var _ contracts.ManifestMetadataProvider = (*Extractor)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil) type Options struct{} type Extractor struct { llm contracts.StructuredLLMClient grounding *groundingResolver 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] } grounding, err := newGroundingResolver(referenceSet) if err != nil { return nil, extractorErrorf("prepare grounding: %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, grounding: grounding, 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 } return map[string]any{ "prompt_id": PromptID, "prompt_version": SchemaVersion, "prompt_sha256": e.promptSHA, "mapping_policy": mappingPolicy, "scene_gate_policy": sceneGatePolicy, "response_schema_key": string(ResponseSchemaKey), "response_schema_id": ResponseSchemaID, "response_schema_name": ResponseSchemaName, "response_schema_version": SchemaVersion, "response_schema_sha256": e.responseSchemaSHA, } } func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint { if e == nil { return nil } return []pipeline.CheckpointFingerprint{ {Name: "prompt", Value: e.promptSHA}, {Name: "response_schema", Value: e.responseSchemaSHA}, {Name: "mapping_policy", Value: mappingPolicy}, {Name: "scene_gate_policy", Value: sceneGatePolicy}, } } func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.EnemyEventList], error) { if e == nil { return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("extractor must not be nil") } if e.llm == nil { return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("LLM client must not be nil") } if e.grounding == nil { return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("grounding resolver must not be nil") } sourceInput, err := shared.PrepareChunkExtraction(ctx, req) if err != nil { return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("%w", err) } match, err := e.grounding.SceneMatch(req.References, req.Chunk) if err != nil { return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("resolve scene eligibility: %w", err) } switch match.State { case sceneregistry.MatchExact: if match.Kind != dnd.SceneKindCombat { return emptyResult(), nil } case sceneregistry.MatchMissing, sceneregistry.MatchMismatched: return unavailableSceneResult(), nil default: return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("unsupported scene eligibility match state %q", match.State) } grounding, err := e.grounding.Resolve(req.References) if err != nil { return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("resolve enemy-event grounding: %w", err) } inputs := shared.PromptInputs(sourceInput, req.References) for name, input := range grounding.PromptInputs() { inputs[name] = input } var response extractionResponse 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.EnemyEventList]{}, extractorErrorf("complete structured output: %w", err) } return contracts.TypedExtractionResult[dnd.EnemyEventList]{ Value: canonicalEnemyEventList(response, shared.NewSourceRefOrder(req.Source), req.Source.ID), }, nil } func emptyResult() contracts.TypedExtractionResult[dnd.EnemyEventList] { return contracts.TypedExtractionResult[dnd.EnemyEventList]{Value: dnd.EnemyEventList{Events: []dnd.EnemyEvent{}}} } func unavailableSceneResult() contracts.TypedExtractionResult[dnd.EnemyEventList] { result := emptyResult() result.Warnings = []contracts.Warning{{ Scope: SceneDescriptionReferenceSlot, ReasonCode: "scene_classification_unavailable", Message: "No exact scene classification was available; enemy-event extraction was skipped.", }} return result } 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.EnemyEventListKind, ReferenceSlots: referenceSlots(), } } func Register(registry *pipeline.ExtractorRegistry) error { return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.EnemyEventList], 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 enemy events extractor: "+format, args...) }