diff --git a/docs/internal/modules.md b/docs/internal/modules.md index 887712a..8a28e51 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -58,6 +58,13 @@ resolution and an ephemeral NPC registry before lane-specific material; the NPC prompt appends its task and instructions. Stage contracts expose only Notarius structured-completion types, not Scriptorium public types. +The shared `ChunkPromptMaterial` helper owns common transcript material +preparation for the spell, NPC, and combat-turn extractors. It clones supplied +source metadata, falls back to the materialized chunk when content is absent, +checks that content remains chunk-identical, and fills only the common default +fields. Extractors retain their request validation and wrap helper errors with +their module context. + Reference material may inform a module or prompt but must not become source evidence. The resolver and materializer behavior is described in [Pipeline Internals](pipeline.md#reference-materialization). diff --git a/internal/modules/dnd/extract/combatturns/extractor.go b/internal/modules/dnd/extract/combatturns/extractor.go index 8782671..51ce4ce 100644 --- a/internal/modules/dnd/extract/combatturns/extractor.go +++ b/internal/modules/dnd/extract/combatturns/extractor.go @@ -1,7 +1,6 @@ package combatturns import ( - "bytes" "context" "fmt" "sort" @@ -159,9 +158,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe if len(req.Chunk.Units) == 0 { return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID) } - sourceInput, err := chunkSourceInput(req) + sourceInput, err := shared.ChunkPromptMaterial(req) if err != nil { - return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, err + return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("%w", err) } var response extractionResponse @@ -181,26 +180,6 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe return contracts.TypedExtractionResult[dnd.CombatTurnList]{Value: canonicalCombatTurnList(response, req.Source.ID)}, nil } -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, "", "") - } - if !bytes.Equal(material.Content, req.Chunk.Content) { - return contracts.LLMInputMaterial{}, extractorErrorf("source input must match chunk %q content", req.Chunk.ID) - } - if material.Name == "" { - material.Name = "source" - } - if material.MediaType == "" { - material.MediaType = req.Chunk.MediaType - } - if material.SizeBytes == 0 { - material.SizeBytes = int64(len(material.Content)) - } - return material, nil -} - func ModuleSpec() pipeline.ModuleSpec { return pipeline.ModuleSpec{ Key: Key, diff --git a/internal/modules/dnd/extract/npcs/extractor.go b/internal/modules/dnd/extract/npcs/extractor.go index 53f2b43..4201105 100644 --- a/internal/modules/dnd/extract/npcs/extractor.go +++ b/internal/modules/dnd/extract/npcs/extractor.go @@ -1,7 +1,6 @@ package npcs import ( - "bytes" "context" "fmt" @@ -122,9 +121,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe if len(req.Chunk.Units) == 0 { return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID) } - sourceInput, err := chunkSourceInput(req) + sourceInput, err := shared.ChunkPromptMaterial(req) if err != nil { - return contracts.TypedExtractionResult[dnd.NPCList]{}, err + return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("%w", err) } var response extractionResponse @@ -142,26 +141,6 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe return contracts.TypedExtractionResult[dnd.NPCList]{Value: canonicalNPCList(response, req.Source.ID)}, nil } -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, "", "") - } - if !bytes.Equal(material.Content, req.Chunk.Content) { - return contracts.LLMInputMaterial{}, extractorErrorf("source input must match chunk %q content", req.Chunk.ID) - } - if material.Name == "" { - material.Name = "source" - } - if material.MediaType == "" { - material.MediaType = req.Chunk.MediaType - } - if material.SizeBytes == 0 { - material.SizeBytes = int64(len(material.Content)) - } - return material, nil -} - func ModuleSpec() pipeline.ModuleSpec { return pipeline.ModuleSpec{ Key: Key, diff --git a/internal/modules/dnd/extract/npcs/extractor_test.go b/internal/modules/dnd/extract/npcs/extractor_test.go index 05c4ef9..f7ec8dc 100644 --- a/internal/modules/dnd/extract/npcs/extractor_test.go +++ b/internal/modules/dnd/extract/npcs/extractor_test.go @@ -133,7 +133,11 @@ func TestExtractHandlesCancellationAndProviderErrors(t *testing.T) { if _, err := extractor.Extract(canceled, request); err == nil || !strings.Contains(err.Error(), "context") { t.Fatalf("canceled Extract() error = %v, want context error", err) } - _, err := newExtractor(t, &fakeNPCsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), request) + _, err := extractor.Extract(context.Background(), mismatchedSourceInputRequest(request)) + if err == nil || !strings.Contains(err.Error(), "dnd npcs") || !strings.Contains(err.Error(), "must match chunk") { + t.Fatalf("source input error = %v, want contextual source input error", err) + } + _, err = newExtractor(t, &fakeNPCsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), request) if err == nil || !strings.Contains(err.Error(), "dnd npcs") || !strings.Contains(err.Error(), "provider unavailable") { t.Fatalf("provider Extract() error = %v, want contextual provider error", err) } diff --git a/internal/modules/dnd/extract/spells/extractor.go b/internal/modules/dnd/extract/spells/extractor.go index 9f43c57..6a9c335 100644 --- a/internal/modules/dnd/extract/spells/extractor.go +++ b/internal/modules/dnd/extract/spells/extractor.go @@ -1,7 +1,6 @@ package spells import ( - "bytes" "context" "fmt" "sort" @@ -177,9 +176,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe if len(req.Chunk.Units) == 0 { return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID) } - sourceInput, err := chunkSourceInput(req) + sourceInput, err := shared.ChunkPromptMaterial(req) if err != nil { - return contracts.TypedExtractionResult[dnd.SpellList]{}, err + return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("%w", err) } var response extractionResponse @@ -200,26 +199,6 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe return contracts.TypedExtractionResult[dnd.SpellList]{Value: canonicalSpellList(response, req.Source.ID)}, nil } -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, "", "") - } - if !bytes.Equal(material.Content, req.Chunk.Content) { - return contracts.LLMInputMaterial{}, extractorErrorf("source input must match chunk %q content", req.Chunk.ID) - } - if material.Name == "" { - material.Name = "source" - } - if material.MediaType == "" { - material.MediaType = req.Chunk.MediaType - } - if material.SizeBytes == 0 { - material.SizeBytes = int64(len(material.Content)) - } - return material, nil -} - func ModuleSpec() pipeline.ModuleSpec { return pipeline.ModuleSpec{ Key: Key, diff --git a/internal/modules/dnd/shared/extraction_inputs.go b/internal/modules/dnd/shared/extraction_inputs.go new file mode 100644 index 0000000..3189069 --- /dev/null +++ b/internal/modules/dnd/shared/extraction_inputs.go @@ -0,0 +1,30 @@ +package shared + +import ( + "bytes" + "fmt" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +// ChunkPromptMaterial prepares the chunk-scoped source material used by D&D +// extractors when constructing their prompt inputs. +func ChunkPromptMaterial(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, "", "") + } + if !bytes.Equal(material.Content, req.Chunk.Content) { + return contracts.LLMInputMaterial{}, fmt.Errorf("source input must match chunk %q content", req.Chunk.ID) + } + if material.Name == "" { + material.Name = "source" + } + if material.MediaType == "" { + material.MediaType = req.Chunk.MediaType + } + if material.SizeBytes == 0 { + material.SizeBytes = int64(len(material.Content)) + } + return material, nil +} diff --git a/internal/modules/dnd/shared/extraction_inputs_test.go b/internal/modules/dnd/shared/extraction_inputs_test.go new file mode 100644 index 0000000..d37e011 --- /dev/null +++ b/internal/modules/dnd/shared/extraction_inputs_test.go @@ -0,0 +1,101 @@ +package shared + +import ( + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +func TestChunkPromptMaterial(t *testing.T) { + chunk := &source.Chunk{ + ID: "session-alpha:chunk:0", + Content: []byte(`{"units":[1,2]}`), + MediaType: "application/json", + } + tests := []struct { + name string + sourceInput contracts.LLMInputMaterial + want contracts.LLMInputMaterial + wantErr string + mutateOutput bool + }{ + { + name: "fallback to chunk content", + want: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "", ""), + }, + { + name: "clone isolation", + sourceInput: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:source", "file:///source.json"), + want: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:source", "file:///source.json"), + mutateOutput: true, + }, + { + name: "mismatched content", + sourceInput: contracts.NewLLMInputMaterial("source", chunk.MediaType, []byte(`{"units":[9]}`), "sha256:other", "file:///other.json"), + wantErr: "source input must match chunk", + }, + { + name: "default fields", + sourceInput: contracts.LLMInputMaterial{ + Content: append([]byte(nil), chunk.Content...), + Digest: "sha256:source", + OriginURI: "file:///source.json", + }, + want: contracts.LLMInputMaterial{ + Name: "source", + MediaType: chunk.MediaType, + Content: append([]byte(nil), chunk.Content...), + Digest: "sha256:source", + OriginURI: "file:///source.json", + SizeBytes: int64(len(chunk.Content)), + }, + }, + { + name: "preserve explicit metadata", + sourceInput: contracts.LLMInputMaterial{ + Name: "transcript", + MediaType: "text/plain", + Content: append([]byte(nil), chunk.Content...), + Digest: "sha256:explicit", + OriginURI: "file:///explicit.txt", + SizeBytes: 42, + }, + want: contracts.LLMInputMaterial{ + Name: "transcript", + MediaType: "text/plain", + Content: append([]byte(nil), chunk.Content...), + Digest: "sha256:explicit", + OriginURI: "file:///explicit.txt", + SizeBytes: 42, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := contracts.TypedExtractionRequest{Chunk: chunk, SourceInput: test.sourceInput} + got, err := ChunkPromptMaterial(req) + if test.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("ChunkPromptMaterial() error = %v, want %q", err, test.wantErr) + } + return + } + if err != nil { + t.Fatalf("ChunkPromptMaterial() error = %v, want nil", err) + } + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("ChunkPromptMaterial() = %#v, want %#v", got, test.want) + } + if test.mutateOutput { + got.Content[0] = 'x' + if string(test.sourceInput.Content) != string(chunk.Content) { + t.Fatalf("ChunkPromptMaterial() output shares content with source input") + } + } + }) + } +}