Introduce typed D&D spell artifacts
This commit is contained in:
@@ -3,11 +3,11 @@ package spells
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
@@ -31,12 +31,31 @@ var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
|
||||
Roster: "Deprecated alias for party roster reference material used only for disambiguation.",
|
||||
}
|
||||
|
||||
var _ contracts.LegacyRawExtractor = (*Extractor)(nil)
|
||||
var _ contracts.Extractor[dnd.SpellList] = (*Extractor)(nil)
|
||||
|
||||
type Extractor struct{}
|
||||
type Options struct{}
|
||||
|
||||
func New() *Extractor {
|
||||
return &Extractor{}
|
||||
type Extractor struct {
|
||||
llm contracts.StructuredLLMClient
|
||||
}
|
||||
|
||||
type rawAdapter struct {
|
||||
extractor *Extractor
|
||||
codec RawAdapterCodec
|
||||
}
|
||||
|
||||
type RawAdapterCodec interface {
|
||||
contracts.ArtifactCodec[dnd.SpellList]
|
||||
EncodeCandidate(dnd.SpellList) ([]byte, error)
|
||||
}
|
||||
|
||||
var _ contracts.LegacyRawExtractor = (*rawAdapter)(nil)
|
||||
|
||||
func New(llmClient contracts.StructuredLLMClient, _ Options) (*Extractor, error) {
|
||||
if llmClient == nil {
|
||||
return nil, extractorErrorf("LLM client must not be nil")
|
||||
}
|
||||
return &Extractor{llm: llmClient}, nil
|
||||
}
|
||||
|
||||
func (e *Extractor) Key() string {
|
||||
@@ -67,35 +86,35 @@ func (e *Extractor) ManifestMetadata() map[string]any {
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (e *Extractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) {
|
||||
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.SpellList], error) {
|
||||
if e == nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("extractor must not be nil")
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("extractor must not be nil")
|
||||
}
|
||||
if e.llm == nil {
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("LLM client must not be nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("context must not be nil")
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("context must not be nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("context error before extraction: %w", err)
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("context error before extraction: %w", err)
|
||||
}
|
||||
if req.Source == nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("source must not be nil")
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("source must not be nil")
|
||||
}
|
||||
if req.Chunk == nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("chunk must not be nil")
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, 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")
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
|
||||
}
|
||||
sourceInput, err := chunkSourceInput(req)
|
||||
if err != nil {
|
||||
return contracts.ExtractionResult{}, err
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, err
|
||||
}
|
||||
|
||||
var response extractionResponse
|
||||
if _, err := req.LLMClient.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||
StageName: Key,
|
||||
PromptID: PromptID,
|
||||
PromptVersion: SchemaVersion,
|
||||
@@ -103,37 +122,13 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.ExtractionRequest
|
||||
SessionID: req.SessionID,
|
||||
Inputs: shared.PromptInputs(sourceInput, req.References),
|
||||
}, &response); err != nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("complete structured output: %w", err)
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("complete structured output: %w", err)
|
||||
}
|
||||
canonicalizeResponse(&response, req.Source.ID)
|
||||
content, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("marshal canonical output: %w", err)
|
||||
}
|
||||
schema, err := loadResponseSchema()
|
||||
if err != nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("load response schema: %w", err)
|
||||
}
|
||||
return contracts.ExtractionResult{
|
||||
Output: contracts.ExtractOutput{
|
||||
Schema: contracts.ResponseSchema{
|
||||
ID: ResponseSchemaID,
|
||||
Name: ResponseSchemaName,
|
||||
Version: SchemaVersion,
|
||||
JSONSchema: append([]byte(nil), schema.JSONSchema...),
|
||||
},
|
||||
Payload: contracts.RawPayload{
|
||||
Content: content,
|
||||
MediaType: "application/json",
|
||||
Metadata: map[string]any{
|
||||
"spell_cast_count": len(response.SpellCasts),
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
canonicalizeResponse(&response)
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{Value: canonicalSpellList(response, req.Source.ID)}, nil
|
||||
}
|
||||
|
||||
func chunkSourceInput(req contracts.ExtractionRequest) (contracts.LLMInputMaterial, error) {
|
||||
func chunkSourceInput(req contracts.TypedExtractionRequest) (contracts.LLMInputMaterial, error) {
|
||||
material := req.SourceInput.Clone()
|
||||
if len(material.Content) == 0 {
|
||||
material = contracts.NewLLMInputMaterial("source", req.Chunk.MediaType, req.Chunk.Content, "", "")
|
||||
@@ -159,16 +154,93 @@ func ModuleSpec() pipeline.ModuleSpec {
|
||||
Stage: pipeline.StageExtract,
|
||||
Requires: append([]string(nil), requiredCapabilities...),
|
||||
Provides: append([]string(nil), providedCapabilities...),
|
||||
ArtifactKind: dnd.SpellListKind,
|
||||
ReferenceSlots: shared.ReferenceSlots(referenceSlotDescriptions),
|
||||
}
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ExtractorRegistry) error {
|
||||
return registry.RegisterLegacyRawWithSpec(ModuleSpec(), func() (contracts.LegacyRawExtractor, error) {
|
||||
return New(), nil
|
||||
return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.SpellList], error) {
|
||||
options, err := DecodeOptions(request.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return New(request.Dependencies.LLM, options)
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterWithRawAdapter keeps existing raw downstream implementations usable
|
||||
// while the extractor itself produces the canonical typed artifact.
|
||||
func RegisterWithRawAdapter(registry *pipeline.ExtractorRegistry, codec RawAdapterCodec) error {
|
||||
if codec == nil {
|
||||
return extractorErrorf("artifact codec must not be nil")
|
||||
}
|
||||
build := func(request pipeline.BuildRequest) (*Extractor, error) {
|
||||
options, err := DecodeOptions(request.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return New(request.Dependencies.LLM, options)
|
||||
}
|
||||
return pipeline.RegisterExtractorBuilderWithRawAdapter(registry, ModuleSpec(), validateOptions,
|
||||
func(request pipeline.BuildRequest) (contracts.Extractor[dnd.SpellList], error) {
|
||||
return build(request)
|
||||
},
|
||||
func(request pipeline.BuildRequest) (contracts.LegacyRawExtractor, error) {
|
||||
extractor, err := build(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rawAdapter{extractor: extractor, codec: codec}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (adapter *rawAdapter) Key() string { return Key }
|
||||
|
||||
func (adapter *rawAdapter) ReferenceSlots() []contracts.ReferenceSlot {
|
||||
return adapter.extractor.ReferenceSlots()
|
||||
}
|
||||
|
||||
func (adapter *rawAdapter) ManifestMetadata() map[string]any {
|
||||
return adapter.extractor.ManifestMetadata()
|
||||
}
|
||||
|
||||
func (adapter *rawAdapter) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) {
|
||||
result, err := adapter.extractor.Extract(ctx, contracts.TypedExtractionRequest{
|
||||
Source: req.Source, Chunk: req.Chunk, AmbientContext: req.AmbientContext,
|
||||
SourceInput: req.SourceInput, SessionID: req.SessionID, References: req.References,
|
||||
LLMProfile: req.LLMProfile, Metadata: req.Metadata,
|
||||
})
|
||||
if err != nil {
|
||||
return contracts.ExtractionResult{}, err
|
||||
}
|
||||
content, err := adapter.codec.EncodeCandidate(result.Value)
|
||||
if err != nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("encode canonical output: %w", err)
|
||||
}
|
||||
schema := adapter.codec.Schema()
|
||||
return contracts.ExtractionResult{
|
||||
Output: contracts.ExtractOutput{
|
||||
Schema: contracts.ResponseSchema{ID: schema.ID, Name: schema.Name, Version: schema.Version, JSONSchema: append([]byte(nil), schema.JSONSchema...)},
|
||||
Payload: contracts.RawPayload{Content: content, MediaType: adapter.codec.MediaType(), Metadata: map[string]any{"spell_cast_count": len(result.Value.SpellCasts)}},
|
||||
},
|
||||
Warnings: result.Warnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
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 spells extractor: "+format, args...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user