Enable correction-aware foundational D&D producers

This commit is contained in:
2026-08-26 23:55:23 +00:00
parent 1a7b20c766
commit 759d32403f
16 changed files with 168 additions and 68 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -94,23 +94,29 @@ 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 {
@@ -118,6 +124,7 @@ func ModuleSpec() pipeline.ModuleSpec {
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),

View File

@@ -25,6 +25,7 @@ func TestNewModuleSpecAndRegister(t *testing.T) {
Key: Key,
Stage: pipeline.StageChunk,
ExecutionClass: contracts.ExecutionClassLLMBacked,
CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1,
Requires: []string{"source.transcript"},
Provides: []string{"chunks"},
ReferenceSlots: wantReferenceSlots(),
@@ -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) {

View File

@@ -109,20 +109,27 @@ 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.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,
CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1,
Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.ItemRegistryKind, ReferenceSlots: referenceSlots(),
}

View File

@@ -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)
}

View File

@@ -109,20 +109,27 @@ 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.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,
CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1,
Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.LocationRegistryKind, ReferenceSlots: referenceSlots(),
}

View File

@@ -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)
}

View File

@@ -117,19 +117,25 @@ 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 {
@@ -137,6 +143,7 @@ func ModuleSpec() pipeline.ModuleSpec {
Key: Key,
Stage: pipeline.StageExtract,
ExecutionClass: contracts.ExecutionClassLLMBacked,
CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.NPCRegistryKind,

View File

@@ -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{}}})

View File

@@ -28,6 +28,7 @@ func TestModuleSpecAndReferenceSlots(t *testing.T) {
Key: Key,
Stage: pipeline.StageExtract,
ExecutionClass: contracts.ExecutionClassLLMBacked,
CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1,
Requires: []string{"chunks", "source.transcript"},
Provides: []string{"dnd.npc_registry"},
ArtifactKind: dnd.NPCRegistryKind,

View File

@@ -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 {
@@ -148,6 +154,7 @@ func ModuleSpec() pipeline.ModuleSpec {
Key: Key,
Stage: pipeline.StageExtract,
ExecutionClass: contracts.ExecutionClassLLMBacked,
CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.SceneDescriptionListKind,

View File

@@ -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"}},

View File

@@ -182,19 +182,25 @@ 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 {
@@ -202,6 +208,7 @@ func ModuleSpec() pipeline.ModuleSpec {
Key: Key,
Stage: pipeline.StageExtract,
ExecutionClass: contracts.ExecutionClassLLMBacked,
CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.SpellListKind,

View File

@@ -26,6 +26,7 @@ func TestModuleSpec(t *testing.T) {
Key: Key,
Stage: pipeline.StageExtract,
ExecutionClass: contracts.ExecutionClassLLMBacked,
CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1,
Requires: []string{
"chunks",
"source.transcript",

View File

@@ -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)
}