Centralize D&D chunk prompt material preparation
This commit is contained in:
@@ -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
|
NPC prompt appends its task and instructions. Stage contracts expose only
|
||||||
Notarius structured-completion types, not Scriptorium public types.
|
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
|
Reference material may inform a module or prompt but must not become source
|
||||||
evidence. The resolver and materializer behavior is described in
|
evidence. The resolver and materializer behavior is described in
|
||||||
[Pipeline Internals](pipeline.md#reference-materialization).
|
[Pipeline Internals](pipeline.md#reference-materialization).
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package combatturns
|
package combatturns
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -159,9 +158,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
if len(req.Chunk.Units) == 0 {
|
if len(req.Chunk.Units) == 0 {
|
||||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
|
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 {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, err
|
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("%w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var response extractionResponse
|
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
|
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 {
|
func ModuleSpec() pipeline.ModuleSpec {
|
||||||
return pipeline.ModuleSpec{
|
return pipeline.ModuleSpec{
|
||||||
Key: Key,
|
Key: Key,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package npcs
|
package npcs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
@@ -122,9 +121,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
if len(req.Chunk.Units) == 0 {
|
if len(req.Chunk.Units) == 0 {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
|
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 {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCList]{}, err
|
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("%w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var response extractionResponse
|
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
|
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 {
|
func ModuleSpec() pipeline.ModuleSpec {
|
||||||
return pipeline.ModuleSpec{
|
return pipeline.ModuleSpec{
|
||||||
Key: Key,
|
Key: Key,
|
||||||
|
|||||||
@@ -133,7 +133,11 @@ func TestExtractHandlesCancellationAndProviderErrors(t *testing.T) {
|
|||||||
if _, err := extractor.Extract(canceled, request); err == nil || !strings.Contains(err.Error(), "context") {
|
if _, err := extractor.Extract(canceled, request); err == nil || !strings.Contains(err.Error(), "context") {
|
||||||
t.Fatalf("canceled Extract() error = %v, want context error", err)
|
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") {
|
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)
|
t.Fatalf("provider Extract() error = %v, want contextual provider error", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package spells
|
package spells
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -177,9 +176,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
if len(req.Chunk.Units) == 0 {
|
if len(req.Chunk.Units) == 0 {
|
||||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
|
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 {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, err
|
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("%w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var response extractionResponse
|
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
|
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 {
|
func ModuleSpec() pipeline.ModuleSpec {
|
||||||
return pipeline.ModuleSpec{
|
return pipeline.ModuleSpec{
|
||||||
Key: Key,
|
Key: Key,
|
||||||
|
|||||||
30
internal/modules/dnd/shared/extraction_inputs.go
Normal file
30
internal/modules/dnd/shared/extraction_inputs.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
101
internal/modules/dnd/shared/extraction_inputs_test.go
Normal file
101
internal/modules/dnd/shared/extraction_inputs_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user