From 759d32403f5c789df05506bf786bcf8833933202 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 26 Aug 2026 23:55:23 +0000 Subject: [PATCH] Enable correction-aware foundational D&D producers --- docs/internal/dnd.md | 7 ++++ docs/roadmap/implementation.md | 2 +- internal/modules/dnd/chunk/scenes/chunker.go | 25 +++++++++------ .../modules/dnd/chunk/scenes/chunker_test.go | 20 ++++++++---- .../dnd/extract/itemregistry/extractor.go | 17 +++++++--- .../dnd/extract/itemregistry/registry_test.go | 2 +- .../dnd/extract/locationregistry/extractor.go | 17 +++++++--- .../extract/locationregistry/registry_test.go | 2 +- .../dnd/extract/npcregistry/extractor.go | 27 ++++++++++------ .../dnd/extract/npcregistry/extractor_test.go | 32 +++++++++++++++++++ .../dnd/extract/npcregistry/registry_test.go | 13 ++++---- .../extract/scenedescriptions/extractor.go | 27 ++++++++++------ .../scenedescriptions/registry_test.go | 2 +- .../modules/dnd/extract/spells/extractor.go | 27 ++++++++++------ .../dnd/extract/spells/registry_test.go | 7 ++-- .../modules/dnd/shared/model_candidate.go | 9 ++++++ 16 files changed, 168 insertions(+), 68 deletions(-) create mode 100644 internal/modules/dnd/shared/model_candidate.go diff --git a/docs/internal/dnd.md b/docs/internal/dnd.md index 91e660f5..53f79be9 100644 --- a/docs/internal/dnd.md +++ b/docs/internal/dnd.md @@ -42,6 +42,13 @@ unknown fields, while preserving semantic candidates for deterministic validation. Do not promote a private response envelope into a durable schema; the contracts above define durable data. +A D&D producer that declares `single_response_v1` forwards any supplied +semantic correction to its structured completion and returns an owned copy of +that completion's exact validated raw response as its model candidate. It does +not serialize normalized artifacts to create that candidate, so deterministic +identity, evidence, warning, and durable-schema behavior remains separate from +the model transport material. + ## Prompt Construction D&D LLM-facing content lives beneath `assets/dnd/`. Each module contributes a diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 81bc8cd7..7159d099 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -312,7 +312,7 @@ changing ordinary requests. Notarius has one safe, tested adapter path for fresh corrected requests, and no stage invokes it yet. This stage is one Terra prompt. -## Stage 6 — Migrate The Scene Chunker And Foundational Extractors +## Stage 6 — Migrate The Scene Chunker And Foundational Extractors ✅ ### Goal diff --git a/internal/modules/dnd/chunk/scenes/chunker.go b/internal/modules/dnd/chunk/scenes/chunker.go index 5aa19a26..e6cb0924 100644 --- a/internal/modules/dnd/chunk/scenes/chunker.go +++ b/internal/modules/dnd/chunk/scenes/chunker.go @@ -94,33 +94,40 @@ func (c *Chunker) Plan(ctx context.Context, req contracts.ChunkRequest) (contrac return contracts.ChunkPlanResult{}, chunkerErrorf("validate source document: %w", err) } var response chunkResponse - if _, err := c.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ + completion, err := c.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ StageName: Key, PromptID: PromptID, PromptVersion: ResponseSchemaVersion, ProfileID: req.LLMProfile, SessionID: req.SessionID, StructuredOutputRepairAttempts: req.StructuredOutputRepairAttempts, + Correction: req.Correction, Inputs: shared.PromptInputs(req.SourceInput, req.References), - }, &response); err != nil { + }, &response) + if err != nil { return contracts.ChunkPlanResult{}, chunkerErrorf("complete structured output: %w", err) } + candidate, err := shared.ModelCandidateFromResponse(completion) + if err != nil { + return contracts.ChunkPlanResult{}, chunkerErrorf("capture model candidate: %w", err) + } plan, err := planFromResponse(req.Source, response) if err != nil { return contracts.ChunkPlanResult{}, chunkerErrorf("malformed structured output: %w", err) } - return contracts.ChunkPlanResult{Plan: plan}, nil + return contracts.ChunkPlanResult{Plan: plan, ModelCandidate: candidate}, nil } func ModuleSpec() pipeline.ModuleSpec { return pipeline.ModuleSpec{ - Key: Key, - Stage: pipeline.StageChunk, - ExecutionClass: contracts.ExecutionClassLLMBacked, - Requires: append([]string(nil), requiredCapabilities...), - Provides: append([]string(nil), providedCapabilities...), - ReferenceSlots: shared.ReferenceSlots(referenceSlotDescriptions), + Key: Key, + Stage: pipeline.StageChunk, + ExecutionClass: contracts.ExecutionClassLLMBacked, + CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1, + Requires: append([]string(nil), requiredCapabilities...), + Provides: append([]string(nil), providedCapabilities...), + ReferenceSlots: shared.ReferenceSlots(referenceSlotDescriptions), } } diff --git a/internal/modules/dnd/chunk/scenes/chunker_test.go b/internal/modules/dnd/chunk/scenes/chunker_test.go index 4eae4508..46eb96cc 100644 --- a/internal/modules/dnd/chunk/scenes/chunker_test.go +++ b/internal/modules/dnd/chunk/scenes/chunker_test.go @@ -22,12 +22,13 @@ func TestNewModuleSpecAndRegister(t *testing.T) { } want := pipeline.ModuleSpec{ - Key: Key, - Stage: pipeline.StageChunk, - ExecutionClass: contracts.ExecutionClassLLMBacked, - Requires: []string{"source.transcript"}, - Provides: []string{"chunks"}, - ReferenceSlots: wantReferenceSlots(), + Key: Key, + Stage: pipeline.StageChunk, + ExecutionClass: contracts.ExecutionClassLLMBacked, + CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1, + Requires: []string{"source.transcript"}, + Provides: []string{"chunks"}, + ReferenceSlots: wantReferenceSlots(), } if got := ModuleSpec(); !reflect.DeepEqual(got, want) { t.Fatalf("ModuleSpec() = %#v, want %#v", got, want) @@ -181,6 +182,13 @@ func TestPlanReturnsAnnotationFreeSceneRangesFromStructuredOutput(t *testing.T) if len(result.Warnings) != 0 { t.Fatalf("warnings = %#v, want absent", result.Warnings) } + wantCandidate, err := json.Marshal(client.response) + if err != nil { + t.Fatalf("marshal expected candidate: %v", err) + } + if result.ModelCandidate == nil || result.ModelCandidate.Protocol != contracts.CorrectionProtocolSingleResponseV1 || !reflect.DeepEqual(result.ModelCandidate.Response, wantCandidate) { + t.Fatalf("model candidate = %#v, want exact validated response", result.ModelCandidate) + } } func TestPlanUsesDocumentOrderForNonconsecutiveUnitIDs(t *testing.T) { diff --git a/internal/modules/dnd/extract/itemregistry/extractor.go b/internal/modules/dnd/extract/itemregistry/extractor.go index 6a26ae36..d9ab5266 100644 --- a/internal/modules/dnd/extract/itemregistry/extractor.go +++ b/internal/modules/dnd/extract/itemregistry/extractor.go @@ -109,21 +109,28 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe order := shared.NewSourceRefOrder(req.Source) var response extractionResponse - if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ + completion, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: req.LLMProfile, SessionID: req.SessionID, StructuredOutputRepairAttempts: req.StructuredOutputRepairAttempts, - Inputs: shared.PromptInputs(sourceInput, req.References), - }, &response); err != nil { + Correction: req.Correction, + Inputs: shared.PromptInputs(sourceInput, req.References), + }, &response) + if err != nil { return contracts.TypedExtractionResult[dnd.ItemRegistry]{}, extractorErrorf("complete structured output: %w", err) } + candidate, err := shared.ModelCandidateFromResponse(completion) + if err != nil { + return contracts.TypedExtractionResult[dnd.ItemRegistry]{}, extractorErrorf("capture model candidate: %w", err) + } canonicalizeResponse(&response, order, req.Source.ID) - return contracts.TypedExtractionResult[dnd.ItemRegistry]{Value: canonicalItemRegistry(response, req.Source.ID)}, nil + return contracts.TypedExtractionResult[dnd.ItemRegistry]{Value: canonicalItemRegistry(response, req.Source.ID), ModelCandidate: candidate}, 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...), + CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1, + Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), ArtifactKind: dnd.ItemRegistryKind, ReferenceSlots: referenceSlots(), } } diff --git a/internal/modules/dnd/extract/itemregistry/registry_test.go b/internal/modules/dnd/extract/itemregistry/registry_test.go index cd052410..658381c4 100644 --- a/internal/modules/dnd/extract/itemregistry/registry_test.go +++ b/internal/modules/dnd/extract/itemregistry/registry_test.go @@ -19,7 +19,7 @@ func TestModuleRegistrationMetadataAndRedaction(t *testing.T) { if _, err := New(&fakeItemsLLMClient{}, Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), "at most one") { t.Fatalf("New() error = %v, want reference-set rejection", err) } - want := pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageExtract, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: []string{"chunks", "source.transcript"}, Provides: []string{"dnd.item_registry"}, ArtifactKind: dnd.ItemRegistryKind, ReferenceSlots: referenceSlots()} + want := pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageExtract, ExecutionClass: contracts.ExecutionClassLLMBacked, CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1, Requires: []string{"chunks", "source.transcript"}, Provides: []string{"dnd.item_registry"}, ArtifactKind: dnd.ItemRegistryKind, ReferenceSlots: referenceSlots()} if got := ModuleSpec(); !reflect.DeepEqual(got, want) { t.Fatalf("ModuleSpec() = %#v, want %#v", got, want) } diff --git a/internal/modules/dnd/extract/locationregistry/extractor.go b/internal/modules/dnd/extract/locationregistry/extractor.go index 2c1a1e2a..5de12842 100644 --- a/internal/modules/dnd/extract/locationregistry/extractor.go +++ b/internal/modules/dnd/extract/locationregistry/extractor.go @@ -109,21 +109,28 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe order := shared.NewSourceRefOrder(req.Source) var response extractionResponse - if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ + completion, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: req.LLMProfile, SessionID: req.SessionID, StructuredOutputRepairAttempts: req.StructuredOutputRepairAttempts, - Inputs: shared.PromptInputs(sourceInput, req.References), - }, &response); err != nil { + Correction: req.Correction, + Inputs: shared.PromptInputs(sourceInput, req.References), + }, &response) + if err != nil { return contracts.TypedExtractionResult[dnd.LocationRegistry]{}, extractorErrorf("complete structured output: %w", err) } + candidate, err := shared.ModelCandidateFromResponse(completion) + if err != nil { + return contracts.TypedExtractionResult[dnd.LocationRegistry]{}, extractorErrorf("capture model candidate: %w", err) + } canonicalizeResponse(&response, order, req.Source.ID) - return contracts.TypedExtractionResult[dnd.LocationRegistry]{Value: canonicalLocationRegistry(response, req.Source.ID)}, nil + return contracts.TypedExtractionResult[dnd.LocationRegistry]{Value: canonicalLocationRegistry(response, req.Source.ID), ModelCandidate: candidate}, 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...), + CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1, + Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), ArtifactKind: dnd.LocationRegistryKind, ReferenceSlots: referenceSlots(), } } diff --git a/internal/modules/dnd/extract/locationregistry/registry_test.go b/internal/modules/dnd/extract/locationregistry/registry_test.go index bf49ca4e..2a04caa3 100644 --- a/internal/modules/dnd/extract/locationregistry/registry_test.go +++ b/internal/modules/dnd/extract/locationregistry/registry_test.go @@ -18,7 +18,7 @@ func TestModuleRegistrationAndMetadata(t *testing.T) { if _, err := New(&fakeLocationsLLMClient{}, Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), "at most one") { t.Fatalf("New() error = %v, want reference-set rejection", err) } - want := pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageExtract, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: []string{"chunks", "source.transcript"}, Provides: []string{"dnd.location_registry"}, ArtifactKind: dnd.LocationRegistryKind, ReferenceSlots: referenceSlots()} + want := pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageExtract, ExecutionClass: contracts.ExecutionClassLLMBacked, CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1, Requires: []string{"chunks", "source.transcript"}, Provides: []string{"dnd.location_registry"}, ArtifactKind: dnd.LocationRegistryKind, ReferenceSlots: referenceSlots()} if got := ModuleSpec(); !reflect.DeepEqual(got, want) { t.Fatalf("ModuleSpec() = %#v, want %#v", got, want) } diff --git a/internal/modules/dnd/extract/npcregistry/extractor.go b/internal/modules/dnd/extract/npcregistry/extractor.go index a1b5e04b..8956cdaf 100644 --- a/internal/modules/dnd/extract/npcregistry/extractor.go +++ b/internal/modules/dnd/extract/npcregistry/extractor.go @@ -117,30 +117,37 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe order := shared.NewSourceRefOrder(req.Source) var response extractionResponse - if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ + completion, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: req.LLMProfile, SessionID: req.SessionID, StructuredOutputRepairAttempts: req.StructuredOutputRepairAttempts, + Correction: req.Correction, Inputs: shared.PromptInputs(sourceInput, req.References), - }, &response); err != nil { + }, &response) + if err != nil { return contracts.TypedExtractionResult[dnd.NPCRegistry]{}, extractorErrorf("complete structured output: %w", err) } + candidate, err := shared.ModelCandidateFromResponse(completion) + if err != nil { + return contracts.TypedExtractionResult[dnd.NPCRegistry]{}, extractorErrorf("capture model candidate: %w", err) + } canonicalizeResponse(&response, order, req.Source.ID) - return contracts.TypedExtractionResult[dnd.NPCRegistry]{Value: canonicalNPCRegistry(response, req.Source.ID)}, nil + return contracts.TypedExtractionResult[dnd.NPCRegistry]{Value: canonicalNPCRegistry(response, req.Source.ID), ModelCandidate: candidate}, 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.NPCRegistryKind, - ReferenceSlots: referenceSlots(), + Key: Key, + Stage: pipeline.StageExtract, + ExecutionClass: contracts.ExecutionClassLLMBacked, + CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1, + Requires: append([]string(nil), requiredCapabilities...), + Provides: append([]string(nil), providedCapabilities...), + ArtifactKind: dnd.NPCRegistryKind, + ReferenceSlots: referenceSlots(), } } diff --git a/internal/modules/dnd/extract/npcregistry/extractor_test.go b/internal/modules/dnd/extract/npcregistry/extractor_test.go index bb2670ea..759a6d93 100644 --- a/internal/modules/dnd/extract/npcregistry/extractor_test.go +++ b/internal/modules/dnd/extract/npcregistry/extractor_test.go @@ -173,6 +173,38 @@ func TestExtractMapsRawSemanticCandidatesWithoutRepair(t *testing.T) { } } +func TestExtractForwardsCorrectionAndOwnsModelCandidate(t *testing.T) { + rawResponse := []byte(`{"npcs":[{"name":"Mira Thorn","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`) + client := &fakeNPCsLLMClient{content: append([]byte(nil), rawResponse...)} + correction, err := contracts.NewSemanticCorrection([]byte(`{"npcs":[]}`), "Retain the transcript-grounded NPC.") + if err != nil { + t.Fatalf("NewSemanticCorrection() error = %v", err) + } + request := extractionRequest() + request.Correction = correction + result, err := newExtractor(t, client).Extract(context.Background(), request) + if err != nil { + t.Fatalf("Extract() error = %v, want nil", err) + } + if len(client.requests) != 1 || client.requests[0].Correction == nil || + string(client.requests[0].Correction.AssistantResponse) != `{"npcs":[]}` || + client.requests[0].Correction.UserGuidance != "Retain the transcript-grounded NPC." { + t.Fatalf("structured request correction = %#v, want forwarded correction", client.requests) + } + correction.AssistantResponse[0] = '[' + if got := string(client.requests[0].Correction.AssistantResponse); got != `{"npcs":[]}` { + t.Fatalf("captured correction changed after caller mutation: %q", got) + } + if result.ModelCandidate == nil || result.ModelCandidate.Protocol != contracts.CorrectionProtocolSingleResponseV1 || + string(result.ModelCandidate.Response) != string(rawResponse) { + t.Fatalf("model candidate = %#v, want exact validated response", result.ModelCandidate) + } + client.content[0] = '[' + if got := string(result.ModelCandidate.Response); got != string(rawResponse) { + t.Fatalf("model candidate changed after provider buffer mutation: %q", got) + } +} + func TestExtractRetainsLocalErrorContextAndProviderFailures(t *testing.T) { request := extractionRequest() extractor := newExtractor(t, &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}}) diff --git a/internal/modules/dnd/extract/npcregistry/registry_test.go b/internal/modules/dnd/extract/npcregistry/registry_test.go index c3835bed..b6fe52b7 100644 --- a/internal/modules/dnd/extract/npcregistry/registry_test.go +++ b/internal/modules/dnd/extract/npcregistry/registry_test.go @@ -25,12 +25,13 @@ func TestNewRequiresLLMClientAndRejectsAmbiguousReferences(t *testing.T) { func TestModuleSpecAndReferenceSlots(t *testing.T) { got := ModuleSpec() want := pipeline.ModuleSpec{ - Key: Key, - Stage: pipeline.StageExtract, - ExecutionClass: contracts.ExecutionClassLLMBacked, - Requires: []string{"chunks", "source.transcript"}, - Provides: []string{"dnd.npc_registry"}, - ArtifactKind: dnd.NPCRegistryKind, + Key: Key, + Stage: pipeline.StageExtract, + ExecutionClass: contracts.ExecutionClassLLMBacked, + CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1, + Requires: []string{"chunks", "source.transcript"}, + Provides: []string{"dnd.npc_registry"}, + ArtifactKind: dnd.NPCRegistryKind, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "glossary", Description: "Optional campaign glossary reference material used only for NPC disambiguation.", AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}}, {Name: "party", Description: "Optional party roster reference material used only for NPC disambiguation.", AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}}, diff --git a/internal/modules/dnd/extract/scenedescriptions/extractor.go b/internal/modules/dnd/extract/scenedescriptions/extractor.go index 404181b9..6abb33fd 100644 --- a/internal/modules/dnd/extract/scenedescriptions/extractor.go +++ b/internal/modules/dnd/extract/scenedescriptions/extractor.go @@ -119,18 +119,24 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe } var response extractionResponse - if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ + completion, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: req.LLMProfile, SessionID: req.SessionID, StructuredOutputRepairAttempts: req.StructuredOutputRepairAttempts, + Correction: req.Correction, Inputs: shared.PromptInputs(sourceInput, req.References), - }, &response); err != nil { + }, &response) + if err != nil { return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("complete structured output: %w", err) } - return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{Value: mapResponse(response, req.Chunk)}, nil + candidate, err := shared.ModelCandidateFromResponse(completion) + if err != nil { + return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("capture model candidate: %w", err) + } + return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{Value: mapResponse(response, req.Chunk), ModelCandidate: candidate}, nil } func mapResponse(response extractionResponse, chunk *source.Chunk) dnd.SceneDescriptionList { @@ -145,13 +151,14 @@ func mapResponse(response extractionResponse, chunk *source.Chunk) dnd.SceneDesc 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.SceneDescriptionListKind, - ReferenceSlots: referenceSlots(), + Key: Key, + Stage: pipeline.StageExtract, + ExecutionClass: contracts.ExecutionClassLLMBacked, + CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1, + Requires: append([]string(nil), requiredCapabilities...), + Provides: append([]string(nil), providedCapabilities...), + ArtifactKind: dnd.SceneDescriptionListKind, + ReferenceSlots: referenceSlots(), } } diff --git a/internal/modules/dnd/extract/scenedescriptions/registry_test.go b/internal/modules/dnd/extract/scenedescriptions/registry_test.go index d717c86f..bf4d6e66 100644 --- a/internal/modules/dnd/extract/scenedescriptions/registry_test.go +++ b/internal/modules/dnd/extract/scenedescriptions/registry_test.go @@ -24,7 +24,7 @@ func TestNewRequiresLLMClientAndRejectsAmbiguousReferences(t *testing.T) { func TestModuleSpecAndReferenceSlots(t *testing.T) { want := pipeline.ModuleSpec{ - Key: Key, Stage: pipeline.StageExtract, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: []string{"chunks", "source.transcript"}, Provides: []string{"dnd.scene_descriptions"}, ArtifactKind: dnd.SceneDescriptionListKind, + Key: Key, Stage: pipeline.StageExtract, ExecutionClass: contracts.ExecutionClassLLMBacked, CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1, Requires: []string{"chunks", "source.transcript"}, Provides: []string{"dnd.scene_descriptions"}, ArtifactKind: dnd.SceneDescriptionListKind, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "glossary", Description: "Optional campaign glossary reference material used only to disambiguate scene descriptions.", AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}}, {Name: "party", Description: "Optional party roster reference material used only to disambiguate scene descriptions.", AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}}, diff --git a/internal/modules/dnd/extract/spells/extractor.go b/internal/modules/dnd/extract/spells/extractor.go index 5cfe36ec..512ff642 100644 --- a/internal/modules/dnd/extract/spells/extractor.go +++ b/internal/modules/dnd/extract/spells/extractor.go @@ -182,30 +182,37 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe inputs := shared.PromptInputs(sourceInput, req.References) inputs[spellcatalog.SpellCatalogReferenceSlot] = e.catalogPromptInput.Clone() inputs[NPCRegistryReferenceSlot] = npcRegistry.PromptInput() - if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ + completion, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: req.LLMProfile, SessionID: req.SessionID, StructuredOutputRepairAttempts: req.StructuredOutputRepairAttempts, + Correction: req.Correction, Inputs: inputs, - }, &response); err != nil { + }, &response) + if err != nil { return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("complete structured output: %w", err) } + candidate, err := shared.ModelCandidateFromResponse(completion) + if err != nil { + return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("capture model candidate: %w", err) + } canonicalizeResponse(&response, order, req.Source.ID) - return contracts.TypedExtractionResult[dnd.SpellList]{Value: canonicalSpellList(response, req.Source.ID)}, nil + return contracts.TypedExtractionResult[dnd.SpellList]{Value: canonicalSpellList(response, req.Source.ID), ModelCandidate: candidate}, 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.SpellListKind, - ReferenceSlots: referenceSlots(), + Key: Key, + Stage: pipeline.StageExtract, + ExecutionClass: contracts.ExecutionClassLLMBacked, + CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1, + Requires: append([]string(nil), requiredCapabilities...), + Provides: append([]string(nil), providedCapabilities...), + ArtifactKind: dnd.SpellListKind, + ReferenceSlots: referenceSlots(), } } diff --git a/internal/modules/dnd/extract/spells/registry_test.go b/internal/modules/dnd/extract/spells/registry_test.go index 3f069a5c..8c45b7cc 100644 --- a/internal/modules/dnd/extract/spells/registry_test.go +++ b/internal/modules/dnd/extract/spells/registry_test.go @@ -23,9 +23,10 @@ func TestNewRequiresLLMClientAndReturnsExtractor(t *testing.T) { func TestModuleSpec(t *testing.T) { got := ModuleSpec() want := pipeline.ModuleSpec{ - Key: Key, - Stage: pipeline.StageExtract, - ExecutionClass: contracts.ExecutionClassLLMBacked, + Key: Key, + Stage: pipeline.StageExtract, + ExecutionClass: contracts.ExecutionClassLLMBacked, + CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1, Requires: []string{ "chunks", "source.transcript", diff --git a/internal/modules/dnd/shared/model_candidate.go b/internal/modules/dnd/shared/model_candidate.go new file mode 100644 index 00000000..aa52f2ce --- /dev/null +++ b/internal/modules/dnd/shared/model_candidate.go @@ -0,0 +1,9 @@ +package shared + +import "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + +// ModelCandidateFromResponse returns an owned candidate for the exact validated +// structured response produced by the shared LLM boundary. +func ModelCandidateFromResponse(response contracts.StructuredCompletionResponse) (*contracts.ModelCandidate, error) { + return contracts.NewModelCandidate(response.Content, contracts.CorrectionProtocolSingleResponseV1) +}