package spells import ( "context" "encoding/json" "fmt" "strings" "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd" ) const Key = "dnd/spells" const ArtifactType = "dnd.spell_cast" const SchemaVersion = "v1" var requiredCapabilities = []string{ "chunks", "source.transcript", } var providedCapabilities = []string{ "dnd.spell_casts", } var referenceSlotDescriptions = dnd.ReferenceSlotDescriptions{ Glossary: "Optional campaign glossary reference material used only for disambiguation.", Party: "Optional party roster reference material used only for disambiguation.", Players: "Optional player list reference material used only for disambiguation.", Roster: "Deprecated alias for party roster reference material used only for disambiguation.", } var _ contracts.Extractor = (*Extractor)(nil) type Extractor struct{} func New() *Extractor { return &Extractor{} } func (e *Extractor) Key() string { return Key } func (e *Extractor) ArtifactType() string { return ArtifactType } func (e *Extractor) SchemaVersion() string { return SchemaVersion } func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot { return dnd.ReferenceSlots(referenceSlotDescriptions) } func (e *Extractor) ManifestMetadata() map[string]any { promptSHA, err := scriptoriumPromptMetadata() if err != nil { promptSHA = "" } metadata := map[string]any{ "prompt_id": PromptID, "prompt_version": SchemaVersion, "prompt_sha256": promptSHA, "response_schema_key": string(ResponseSchemaKey), "response_schema_id": ResponseSchemaID, "response_schema_name": ResponseSchemaName, } if schema, err := loadResponseSchema(); err == nil { metadata["response_schema_version"] = schema.Version metadata["response_schema_sha256"] = schema.SHA256 } return metadata } func (e *Extractor) Validators() []contracts.Validator { return []contracts.Validator{ ShapeValidator{}, SourceRefValidator{}, } } func (e *Extractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) { if e == nil { return contracts.ExtractionResult{}, extractorErrorf("extractor must not be nil") } if ctx == nil { return contracts.ExtractionResult{}, extractorErrorf("context must not be nil") } if err := ctx.Err(); err != nil { return contracts.ExtractionResult{}, extractorErrorf("context error before extraction: %w", err) } if req.Source == nil { return contracts.ExtractionResult{}, extractorErrorf("source must not be nil") } if req.Chunk == nil { return contracts.ExtractionResult{}, extractorErrorf("chunk must not be nil") } if len(req.Chunk.Units) == 0 { return contracts.ExtractionResult{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID) } if req.LLMClient == nil { return contracts.ExtractionResult{}, extractorErrorf("LLM client must not be nil") } var response extractionResponse if _, err := req.LLMClient.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: req.LLMProfile, SessionID: req.SessionID, Inputs: dnd.PromptInputs(req.SourceInput, req.References), }, &response); err != nil { return contracts.ExtractionResult{}, extractorErrorf("complete structured output: %w", err) } if response.SpellCasts == nil { return contracts.ExtractionResult{}, extractorErrorf("malformed structured output: spell_casts must be present") } if len(response.SpellCasts) == 0 { return contracts.ExtractionResult{}, nil } candidates := make([]artifacts.ArtifactCandidate, 0, len(response.SpellCasts)) for i, spellCast := range response.SpellCasts { payload, err := spellCastPayload(spellCast) if err != nil { return contracts.ExtractionResult{}, extractorErrorf("marshal spell cast[%d]: %w", i, err) } candidates = append(candidates, artifacts.ArtifactCandidate{ Payload: payload, SourceRefs: append([]source.SourceRef(nil), spellCast.SourceRefs...), }) } return contracts.ExtractionResult{Candidates: candidates}, nil } func ModuleSpec() pipeline.ModuleSpec { return pipeline.ModuleSpec{ Key: Key, Stage: pipeline.StageExtract, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), ReferenceSlots: dnd.ReferenceSlots(referenceSlotDescriptions), } } func Register(registry *pipeline.ExtractorRegistry) error { return registry.RegisterWithSpec(ModuleSpec(), func() (contracts.Extractor, error) { return New(), nil }) } func spellCastPayload(spellCast spellCastResponse) (json.RawMessage, error) { return json.Marshal(SpellCast{ Caster: strings.TrimSpace(spellCast.Caster), Spell: strings.TrimSpace(spellCast.Spell), Effect: strings.TrimSpace(spellCast.Effect), NarrativeDescription: strings.TrimSpace(spellCast.NarrativeDescription), }) } func extractorErrorf(format string, args ...any) error { return fmt.Errorf("dnd spells extractor: "+format, args...) }