2626 lines
99 KiB
Go
2626 lines
99 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
)
|
|
|
|
func TestNewAndDataTypes(t *testing.T) {
|
|
runner := New(Registries{})
|
|
if runner == nil {
|
|
t.Fatal("New() = nil, want runner")
|
|
}
|
|
|
|
input := RunInput{
|
|
Pipeline: resolvedPipeline(),
|
|
SourceID: "source-1",
|
|
Path: "input.txt",
|
|
RawInput: []byte("source text"),
|
|
LLMClient: fakeLLMClient{},
|
|
Metadata: map[string]any{"request": "test"},
|
|
}
|
|
output := RunOutput{
|
|
Manifest: artifacts.RunManifest{PipelineID: "pipeline-1"},
|
|
NormalizeOutputs: []contracts.NormalizeOutput{{NormalizerKey: "normalize"}},
|
|
Rejected: []contracts.RejectedOutput{{ValidatorName: "validator"}},
|
|
Warnings: []contracts.Warning{{ReasonCode: "note", Message: "message"}},
|
|
OutputFiles: []contracts.OutputFile{{Name: "outputs/generic.json", ContentType: "application/json", Bytes: []byte(`{}`)}},
|
|
}
|
|
|
|
if input.Pipeline.ID != "pipeline-1" || input.SourceID != "source-1" {
|
|
t.Fatalf("RunInput = %#v, want constructed fields", input)
|
|
}
|
|
if output.Manifest.PipelineID != "pipeline-1" || len(output.NormalizeOutputs) != 1 || len(output.Rejected) != 1 || len(output.Warnings) != 1 || len(output.OutputFiles) != 1 {
|
|
t.Fatalf("RunOutput = %#v, want constructed fields", output)
|
|
}
|
|
}
|
|
|
|
func TestRunRejectsInvalidSetup(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
run func() (RunOutput, error)
|
|
error string
|
|
}{
|
|
{
|
|
name: "nil runner",
|
|
run: func() (RunOutput, error) { return (*Runner)(nil).Run(context.Background(), RunInput{}) },
|
|
error: "runner must not be nil",
|
|
},
|
|
{
|
|
name: "empty pipeline id",
|
|
run: func() (RunOutput, error) {
|
|
return New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{Pipeline: ResolvedPipeline{Digest: "sha256:pipeline"}})
|
|
},
|
|
error: "pipeline id",
|
|
},
|
|
{
|
|
name: "empty pipeline digest",
|
|
run: func() (RunOutput, error) {
|
|
pipeline := resolvedPipeline()
|
|
pipeline.Digest = ""
|
|
return New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{Pipeline: pipeline})
|
|
},
|
|
error: "pipeline digest",
|
|
},
|
|
{
|
|
name: "empty artifact lanes",
|
|
run: func() (RunOutput, error) {
|
|
pipeline := resolvedPipeline()
|
|
pipeline.ArtifactLanes = nil
|
|
return New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{Pipeline: pipeline})
|
|
},
|
|
error: "artifact lanes",
|
|
},
|
|
{
|
|
name: "missing input registry",
|
|
run: func() (RunOutput, error) {
|
|
registries := newRunnerRegistries(t, nil)
|
|
registries.Inputs = nil
|
|
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
},
|
|
error: "input registry",
|
|
},
|
|
{
|
|
name: "missing chunker registry",
|
|
run: func() (RunOutput, error) {
|
|
registries := newRunnerRegistries(t, nil)
|
|
registries.Chunkers = nil
|
|
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
},
|
|
error: "chunker registry",
|
|
},
|
|
{
|
|
name: "missing extractor registry",
|
|
run: func() (RunOutput, error) {
|
|
registries := newRunnerRegistries(t, nil)
|
|
registries.Extractors = nil
|
|
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
},
|
|
error: "extractor registry",
|
|
},
|
|
{
|
|
name: "missing merger registry",
|
|
run: func() (RunOutput, error) {
|
|
registries := newRunnerRegistries(t, nil)
|
|
registries.Mergers = nil
|
|
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
},
|
|
error: "merger registry",
|
|
},
|
|
{
|
|
name: "missing normalizer registry",
|
|
run: func() (RunOutput, error) {
|
|
registries := newRunnerRegistries(t, nil)
|
|
registries.Normalizers = nil
|
|
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
},
|
|
error: "normalizer registry",
|
|
},
|
|
{
|
|
name: "missing output registry",
|
|
run: func() (RunOutput, error) {
|
|
registries := newRunnerRegistries(t, nil)
|
|
registries.Outputs = nil
|
|
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
},
|
|
error: "output encoder registry",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
_, err := test.run()
|
|
assertRunError(t, err, test.error)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunAllowsNilValidatorRegistryWithoutConfiguredValidators(t *testing.T) {
|
|
registries := newRunnerRegistries(t, nil)
|
|
registries.Validators = nil
|
|
|
|
_, err := New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestRunRejectsInputBuildParseAndInvalidSourceErrors(t *testing.T) {
|
|
buildErr := errors.New("build failed")
|
|
parseErr := errors.New("parse failed")
|
|
|
|
tests := []struct {
|
|
name string
|
|
configure func(*runnerModules)
|
|
want string
|
|
}{
|
|
{
|
|
name: "input build",
|
|
configure: func(modules *runnerModules) {
|
|
modules.inputBuildErr = buildErr
|
|
},
|
|
want: "build input adapter",
|
|
},
|
|
{
|
|
name: "input parse",
|
|
configure: func(modules *runnerModules) {
|
|
modules.input.err = parseErr
|
|
},
|
|
want: "parse input",
|
|
},
|
|
{
|
|
name: "invalid source",
|
|
configure: func(modules *runnerModules) {
|
|
modules.input.doc = &source.SourceDocument{}
|
|
},
|
|
want: "validate source document",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
test.configure(modules)
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
|
|
assertRunError(t, err, test.want)
|
|
if output.Manifest.ValidationStatus != "failed" {
|
|
t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunRejectsChunkerBuildChunkAndEmptyChunkErrors(t *testing.T) {
|
|
chunkErr := errors.New("chunk failed")
|
|
|
|
tests := []struct {
|
|
name string
|
|
configure func(*runnerModules)
|
|
want string
|
|
}{
|
|
{
|
|
name: "chunker build",
|
|
configure: func(modules *runnerModules) {
|
|
modules.chunkerBuildErr = errors.New("build failed")
|
|
},
|
|
want: "build chunker",
|
|
},
|
|
{
|
|
name: "chunker chunk",
|
|
configure: func(modules *runnerModules) {
|
|
modules.chunker.err = chunkErr
|
|
},
|
|
want: "chunk source",
|
|
},
|
|
{
|
|
name: "empty chunks",
|
|
configure: func(modules *runnerModules) {
|
|
modules.chunker.chunks = nil
|
|
},
|
|
want: "returned no chunks",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
test.configure(modules)
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
|
|
assertRunError(t, err, test.want)
|
|
if output.Manifest.ValidationStatus != "failed" {
|
|
t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunRejectsInvalidChunks(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
chunks []contracts.SourceChunk
|
|
want string
|
|
}{
|
|
{
|
|
name: "empty chunk id",
|
|
chunks: []contracts.SourceChunk{chunkWithUnits("", "source-1", 0, unitWithID("u1"))},
|
|
want: "id must not be empty",
|
|
},
|
|
{
|
|
name: "duplicate chunk id",
|
|
chunks: []contracts.SourceChunk{
|
|
chunkWithUnits("chunk-0", "source-1", 0, unitWithID("u1")),
|
|
chunkWithUnits("chunk-0", "source-1", 1, unitWithID("u2")),
|
|
},
|
|
want: "duplicated",
|
|
},
|
|
{
|
|
name: "wrong source id",
|
|
chunks: []contracts.SourceChunk{chunkWithUnits("chunk-0", "other-source", 0, unitWithID("u1"))},
|
|
want: "source_id",
|
|
},
|
|
{
|
|
name: "wrong index",
|
|
chunks: []contracts.SourceChunk{chunkWithUnits("chunk-0", "source-1", 1, unitWithID("u1"))},
|
|
want: "index",
|
|
},
|
|
{
|
|
name: "unknown start id",
|
|
chunks: []contracts.SourceChunk{chunkWithBounds("chunk-0", "source-1", 0, 9, 1, unitWithID("u1"))},
|
|
want: "start_unit_id",
|
|
},
|
|
{
|
|
name: "unknown end id",
|
|
chunks: []contracts.SourceChunk{chunkWithBounds("chunk-0", "source-1", 0, 1, 9, unitWithID("u1"))},
|
|
want: "end_unit_id",
|
|
},
|
|
{
|
|
name: "reversed bounds",
|
|
chunks: []contracts.SourceChunk{chunkWithBounds("chunk-0", "source-1", 0, 2, 1, unitWithID("u1"), unitWithID("u2"))},
|
|
want: "appears after",
|
|
},
|
|
{
|
|
name: "empty units",
|
|
chunks: []contracts.SourceChunk{{ID: "chunk-0", SourceID: "source-1", Index: 0, StartUnitID: 1, EndUnitID: 1, Content: []byte(`{"units":[]}`), MediaType: "application/json"}},
|
|
want: "units must not be empty",
|
|
},
|
|
{
|
|
name: "empty content",
|
|
chunks: []contracts.SourceChunk{{ID: "chunk-0", SourceID: "source-1", Index: 0, StartUnitID: 1, EndUnitID: 1, MediaType: "application/json", Units: []source.SourceUnit{unitWithID("u1")}}},
|
|
want: "content must not be empty",
|
|
},
|
|
{
|
|
name: "empty media type",
|
|
chunks: []contracts.SourceChunk{{ID: "chunk-0", SourceID: "source-1", Index: 0, StartUnitID: 1, EndUnitID: 1, Content: []byte(`{"units":[1]}`), Units: []source.SourceUnit{unitWithID("u1")}}},
|
|
want: "media_type must not be empty",
|
|
},
|
|
{
|
|
name: "repeated unit inside chunk",
|
|
chunks: []contracts.SourceChunk{chunkWithUnits("chunk-0", "source-1", 0, unitWithID("u1"), unitWithID("u1"))},
|
|
want: "repeats source unit",
|
|
},
|
|
{
|
|
name: "unknown unit",
|
|
chunks: []contracts.SourceChunk{chunkWithUnits("chunk-0", "source-1", 0, unitWithID("u9"))},
|
|
want: "was not found",
|
|
},
|
|
{
|
|
name: "units out of source order",
|
|
chunks: []contracts.SourceChunk{chunkWithBounds("chunk-0", "source-1", 0, 1, 2, unitWithID("u2"), unitWithID("u1"))},
|
|
want: "source document order",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
modules.chunker.chunks = test.chunks
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
|
|
assertRunError(t, err, test.want)
|
|
if output.Manifest.ValidationStatus != "failed" {
|
|
t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus)
|
|
}
|
|
if len(modules.extractors["extract-alpha"].requests) != 0 {
|
|
t.Fatalf("extractor calls = %d, want none after invalid chunks", len(modules.extractors["extract-alpha"].requests))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunAllowsPartialCoverageAndOverlappingChunks(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
modules.chunker.chunks = []contracts.SourceChunk{
|
|
chunkWithUnits("chunk-0", "source-1", 0, unitWithID("u1"), unitWithID("u2")),
|
|
chunkWithUnits("chunk-1", "source-1", 1, unitWithID("u2")),
|
|
}
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
if len(output.NormalizeOutputs) != 1 {
|
|
t.Fatalf("len(NormalizeOutputs) = %d, want one lane output", len(output.NormalizeOutputs))
|
|
}
|
|
}
|
|
|
|
func TestRunCanonicalizesChunkUnitsBeforeExtraction(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
modules.input.doc = sourceDocumentWithUnitMetadata()
|
|
modules.chunker.chunks = []contracts.SourceChunk{
|
|
{
|
|
ID: "chunk-0",
|
|
SourceID: "source-1",
|
|
Index: 0,
|
|
StartUnitID: 1,
|
|
EndUnitID: 1,
|
|
Content: []byte(`{"units":[{"id":1}]}`),
|
|
MediaType: "application/json",
|
|
Units: []source.SourceUnit{
|
|
{
|
|
ID: 1,
|
|
Kind: "mutated-kind",
|
|
Text: "mutated text",
|
|
Metadata: map[string]any{
|
|
"speaker": "chunker-speaker",
|
|
"note": "chunker note",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
if len(output.NormalizeOutputs) != 1 {
|
|
t.Fatalf("len(NormalizeOutputs) = %d, want 1", len(output.NormalizeOutputs))
|
|
}
|
|
|
|
extractor := modules.extractors["extract-alpha"]
|
|
if len(extractor.requests) != 1 {
|
|
t.Fatalf("len(extractor requests) = %d, want 1", len(extractor.requests))
|
|
}
|
|
chunk := extractor.requests[0].Chunk
|
|
if chunk == nil {
|
|
t.Fatal("extractor chunk = nil, want canonical chunk")
|
|
}
|
|
if chunk.Units[0].ID != 1 || chunk.Units[0].Kind != "source-kind" || chunk.Units[0].Text != "source text" {
|
|
t.Fatalf("chunk unit = %#v, want source document unit values", chunk.Units[0])
|
|
}
|
|
if got := chunk.Units[0].Metadata["speaker"]; got != "source-speaker" {
|
|
t.Fatalf("chunk unit metadata = %#v, want source document metadata", chunk.Units[0].Metadata)
|
|
}
|
|
if got := chunk.Units[0].Metadata["topic"]; got != "source-topic" {
|
|
t.Fatalf("chunk unit metadata = %#v, want cloned source document metadata", chunk.Units[0].Metadata)
|
|
}
|
|
|
|
modules.input.doc.Units[0].Kind = "changed-kind"
|
|
modules.input.doc.Units[0].Text = "changed text"
|
|
modules.input.doc.Units[0].Metadata["speaker"] = "changed-speaker"
|
|
if chunk.Units[0].Kind != "source-kind" || chunk.Units[0].Text != "source text" || chunk.Units[0].Metadata["speaker"] != "source-speaker" {
|
|
t.Fatalf("chunk unit changed after source mutation: %#v", chunk.Units[0])
|
|
}
|
|
}
|
|
|
|
func TestRunPreservesChunkMetadataDuringCanonicalization(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
modules.chunker.chunks = []contracts.SourceChunk{
|
|
{
|
|
ID: "chunk-0",
|
|
SourceID: "source-1",
|
|
Index: 0,
|
|
StartUnitID: 1,
|
|
EndUnitID: 1,
|
|
Content: []byte(`{"units":[{"id":1}]}`),
|
|
MediaType: "application/json",
|
|
Units: []source.SourceUnit{
|
|
unitWithID("u1"),
|
|
},
|
|
Metadata: map[string]any{
|
|
"scene_title": "Original scene",
|
|
"boundary_note": "Chunker note",
|
|
},
|
|
},
|
|
}
|
|
|
|
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
extractor := modules.extractors["extract-alpha"]
|
|
if len(extractor.requests) != 1 || extractor.requests[0].Chunk == nil {
|
|
t.Fatalf("extractor requests = %#v, want one canonical chunk", extractor.requests)
|
|
}
|
|
if got := extractor.requests[0].Chunk.Metadata["scene_title"]; got != "Original scene" {
|
|
t.Fatalf("chunk metadata = %#v, want chunker metadata", extractor.requests[0].Chunk.Metadata)
|
|
}
|
|
if got := extractor.requests[0].Chunk.Metadata["boundary_note"]; got != "Chunker note" {
|
|
t.Fatalf("chunk metadata = %#v, want chunker metadata", extractor.requests[0].Chunk.Metadata)
|
|
}
|
|
|
|
modules.chunker.chunks[0].Metadata["scene_title"] = "changed"
|
|
modules.chunker.chunks[0].Metadata["boundary_note"] = "changed"
|
|
if got := extractor.requests[0].Chunk.Metadata["scene_title"]; got != "Original scene" {
|
|
t.Fatalf("chunk metadata aliased to chunker map: %#v", extractor.requests[0].Chunk.Metadata)
|
|
}
|
|
if got := extractor.requests[0].Chunk.Metadata["boundary_note"]; got != "Chunker note" {
|
|
t.Fatalf("chunk metadata aliased to chunker map: %#v", extractor.requests[0].Chunk.Metadata)
|
|
}
|
|
}
|
|
|
|
func TestRunExecutesChunksAndPassesChunkAndLLMClient(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
llmClient := fakeLLMClient{}
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
|
|
Pipeline: resolvedPipeline(),
|
|
LLMClient: llmClient,
|
|
Metadata: map[string]any{"request": "test"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
extractor := modules.extractors["extract-alpha"]
|
|
if !reflect.DeepEqual(extractor.seenChunkIDs, []string{"chunk-0", "chunk-1"}) {
|
|
t.Fatalf("seen chunks = %#v, want both chunks", extractor.seenChunkIDs)
|
|
}
|
|
if len(modules.chunker.requests) != 1 || modules.chunker.requests[0].LLMClient == nil {
|
|
t.Fatalf("chunker LLM client = %#v, want client on chunk request", modules.chunker.requests)
|
|
}
|
|
if len(extractor.seenLLMClients) != 2 || extractor.seenLLMClients[0] == nil || extractor.seenLLMClients[1] == nil {
|
|
t.Fatalf("seen LLM clients = %#v, want client for each chunk", extractor.seenLLMClients)
|
|
}
|
|
normalizer := modules.normalizers["normalize"]
|
|
if len(normalizer.requests) != 1 || normalizer.requests[0].LLMClient == nil {
|
|
t.Fatalf("normalizer LLM client = %#v, want client on normalize request", normalizer.requests)
|
|
}
|
|
if len(modules.mergers["merge"].requests) != 1 || modules.mergers["merge"].requests[0].LLMClient == nil {
|
|
t.Fatalf("merger LLM client = %#v, want client on merge request", modules.mergers["merge"].requests)
|
|
}
|
|
if extractor.seenMetadata[0]["request"] != "test" {
|
|
t.Fatalf("seen metadata = %#v, want request metadata", extractor.seenMetadata)
|
|
}
|
|
if len(output.NormalizeOutputs) != 1 {
|
|
t.Fatalf("len(NormalizeOutputs) = %d, want one lane output", len(output.NormalizeOutputs))
|
|
}
|
|
}
|
|
|
|
func TestRunPassesSourceInputAndSessionIDToPromptCapableStages(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
rawInput := []byte("{\"source\":\"exact bytes\"}")
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
|
|
Pipeline: resolvedPipeline(),
|
|
Path: "session.json",
|
|
RawInput: rawInput,
|
|
SessionID: " explicit-session ",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
if got := output.Manifest.Metadata["session_id"]; got != "explicit-session" {
|
|
t.Fatalf("manifest metadata = %#v, want session_id", output.Manifest.Metadata)
|
|
}
|
|
|
|
sourceRequests := []struct {
|
|
name string
|
|
material contracts.LLMInputMaterial
|
|
sessionID string
|
|
}{
|
|
{name: "chunk", material: modules.chunker.requests[0].SourceInput, sessionID: modules.chunker.requests[0].SessionID},
|
|
{name: "merge", material: modules.mergers["merge"].requests[0].SourceInput, sessionID: modules.mergers["merge"].requests[0].SessionID},
|
|
{name: "normalize", material: modules.normalizers["normalize"].requests[0].SourceInput, sessionID: modules.normalizers["normalize"].requests[0].SessionID},
|
|
}
|
|
for _, req := range sourceRequests {
|
|
if req.sessionID != "explicit-session" {
|
|
t.Fatalf("%s session ID = %q, want explicit-session", req.name, req.sessionID)
|
|
}
|
|
if got := string(req.material.Content); got != string(rawInput) {
|
|
t.Fatalf("%s source input content = %q, want exact raw input", req.name, got)
|
|
}
|
|
if req.material.Name != "source" || req.material.MediaType != "application/json" || req.material.SizeBytes != int64(len(rawInput)) {
|
|
t.Fatalf("%s source input = %#v, want source metadata", req.name, req.material)
|
|
}
|
|
if req.material.Digest != sourceInputDigest(rawInput) {
|
|
t.Fatalf("%s digest = %q, want %q", req.name, req.material.Digest, sourceInputDigest(rawInput))
|
|
}
|
|
if !strings.HasPrefix(req.material.OriginURI, "file://") || !strings.HasSuffix(req.material.OriginURI, "/session.json") {
|
|
t.Fatalf("%s origin URI = %q, want file URI ending in session.json", req.name, req.material.OriginURI)
|
|
}
|
|
}
|
|
for i, req := range modules.extractors["extract-alpha"].requests {
|
|
if req.SessionID != "explicit-session" {
|
|
t.Fatalf("extract %d session ID = %q, want explicit-session", i, req.SessionID)
|
|
}
|
|
if req.Chunk == nil {
|
|
t.Fatalf("extract %d chunk = nil, want chunk", i)
|
|
}
|
|
if got := string(req.SourceInput.Content); got != string(req.Chunk.Content) {
|
|
t.Fatalf("extract %d source input content = %q, want chunk content %q", i, got, req.Chunk.Content)
|
|
}
|
|
if req.SourceInput.Name != "source" || req.SourceInput.MediaType != req.Chunk.MediaType || req.SourceInput.SizeBytes != int64(len(req.Chunk.Content)) {
|
|
t.Fatalf("extract %d source input = %#v, want chunk metadata", i, req.SourceInput)
|
|
}
|
|
if req.SourceInput.Digest != sourceInputDigest(req.Chunk.Content) {
|
|
t.Fatalf("extract %d digest = %q, want %q", i, req.SourceInput.Digest, sourceInputDigest(req.Chunk.Content))
|
|
}
|
|
if !strings.HasPrefix(req.SourceInput.OriginURI, "file://") || !strings.HasSuffix(req.SourceInput.OriginURI, "/session.json") {
|
|
t.Fatalf("extract %d origin URI = %q, want file URI ending in session.json", i, req.SourceInput.OriginURI)
|
|
}
|
|
}
|
|
|
|
modules.chunker.requests[0].SourceInput.Content[0] = 'X'
|
|
if got := string(modules.extractors["extract-alpha"].requests[0].SourceInput.Content); got != string(modules.extractors["extract-alpha"].requests[0].Chunk.Content) {
|
|
t.Fatalf("source input content aliased across requests: %q", got)
|
|
}
|
|
if got := string(rawInput); got != "{\"source\":\"exact bytes\"}" {
|
|
t.Fatalf("raw input mutated through request material: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunDefaultsSessionIDFromParsedSourceDocumentID(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
|
|
Pipeline: resolvedPipeline(),
|
|
Path: "notes.unknown",
|
|
RawInput: []byte("notes"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
if got := modules.chunker.requests[0].SessionID; got != "source-1" {
|
|
t.Fatalf("chunk session ID = %q, want parsed source document ID", got)
|
|
}
|
|
if got := output.Manifest.Metadata["session_id"]; got != "source-1" {
|
|
t.Fatalf("manifest metadata = %#v, want default session id", output.Manifest.Metadata)
|
|
}
|
|
if got := modules.chunker.requests[0].SourceInput.MediaType; got != unknownMediaType {
|
|
t.Fatalf("source input media type = %q, want fallback %q", got, unknownMediaType)
|
|
}
|
|
}
|
|
|
|
func TestRunPassesInputRequestFields(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
metadata := map[string]any{"request": "test"}
|
|
|
|
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
|
|
Pipeline: resolvedPipeline(),
|
|
SourceID: "source-1",
|
|
Path: "input.txt",
|
|
RawInput: []byte("source text"),
|
|
Metadata: metadata,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
if len(modules.input.requests) != 1 {
|
|
t.Fatalf("len(input requests) = %d, want 1", len(modules.input.requests))
|
|
}
|
|
req := modules.input.requests[0]
|
|
if req.SourceID != "source-1" || req.Path != "input.txt" || string(req.Raw) != "source text" {
|
|
t.Fatalf("ParseRequest = %#v, want source id, path, and raw input", req)
|
|
}
|
|
if req.Metadata["request"] != "test" {
|
|
t.Fatalf("ParseRequest.Metadata = %#v, want request metadata", req.Metadata)
|
|
}
|
|
}
|
|
|
|
func TestRunPassesModuleBindingConfigToStageRequests(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
pipeline := resolvedPipeline()
|
|
pipeline.Input = ModuleBinding{Module: "input", LLMProfile: "input-profile", Options: map[string]any{"input_option": "input-value"}}
|
|
pipeline.Chunk = ModuleBinding{Module: "chunk", LLMProfile: "chunk-profile", Options: map[string]any{"chunk_option": "chunk-value"}}
|
|
pipeline.Output = ModuleBinding{Module: "output", LLMProfile: "output-profile", Options: map[string]any{"output_option": "output-value"}}
|
|
pipeline.ArtifactLanes[0].Extract = ModuleBinding{Module: "extract-alpha", LLMProfile: "extract-profile", Options: map[string]any{"extract_option": "extract-value"}}
|
|
pipeline.ArtifactLanes[0].Merge = ModuleBinding{Module: "merge", LLMProfile: "merge-profile", Options: map[string]any{"merge_option": "merge-value"}}
|
|
pipeline.ArtifactLanes[0].Normalize = ModuleBinding{Module: "normalize", LLMProfile: "normalize-profile", Options: map[string]any{"normalize_option": "normalize-value"}}
|
|
|
|
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
if got := modules.input.requests[0].LLMProfile; got != "input-profile" {
|
|
t.Fatalf("input LLMProfile = %q, want input-profile", got)
|
|
}
|
|
if got := modules.input.requests[0].Options["input_option"]; got != "input-value" {
|
|
t.Fatalf("input Options = %#v, want input option", modules.input.requests[0].Options)
|
|
}
|
|
if got := modules.chunker.requests[0].LLMProfile; got != "chunk-profile" {
|
|
t.Fatalf("chunk LLMProfile = %q, want chunk-profile", got)
|
|
}
|
|
if got := modules.chunker.requests[0].Options["chunk_option"]; got != "chunk-value" {
|
|
t.Fatalf("chunk Options = %#v, want chunk option", modules.chunker.requests[0].Options)
|
|
}
|
|
if got := modules.extractors["extract-alpha"].requests[0].LLMProfile; got != "extract-profile" {
|
|
t.Fatalf("extract LLMProfile = %q, want extract-profile", got)
|
|
}
|
|
if got := modules.extractors["extract-alpha"].requests[0].Options["extract_option"]; got != "extract-value" {
|
|
t.Fatalf("extract Options = %#v, want extract option", modules.extractors["extract-alpha"].requests[0].Options)
|
|
}
|
|
if got := modules.mergers["merge"].requests[0].LLMProfile; got != "merge-profile" {
|
|
t.Fatalf("merge LLMProfile = %q, want merge-profile", got)
|
|
}
|
|
if got := modules.mergers["merge"].requests[0].Options["merge_option"]; got != "merge-value" {
|
|
t.Fatalf("merge Options = %#v, want merge option", modules.mergers["merge"].requests[0].Options)
|
|
}
|
|
if got := modules.normalizers["normalize"].requests[0].LLMProfile; got != "normalize-profile" {
|
|
t.Fatalf("normalize LLMProfile = %q, want normalize-profile", got)
|
|
}
|
|
if got := modules.normalizers["normalize"].requests[0].Options["normalize_option"]; got != "normalize-value" {
|
|
t.Fatalf("normalize Options = %#v, want normalize option", modules.normalizers["normalize"].requests[0].Options)
|
|
}
|
|
if got := modules.output.requests[0].LLMProfile; got != "output-profile" {
|
|
t.Fatalf("output LLMProfile = %q, want output-profile", got)
|
|
}
|
|
if got := modules.output.requests[0].Options["output_option"]; got != "output-value" {
|
|
t.Fatalf("output Options = %#v, want output option", modules.output.requests[0].Options)
|
|
}
|
|
}
|
|
|
|
func TestRunPassesLaneReferencesToExtractorRequests(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
pipeline := resolvedPipeline()
|
|
pipeline.ArtifactLanes[0].ExtractReferences.ReferenceSet = testReferenceSet("roster", "reference text")
|
|
|
|
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
req := modules.extractors["extract-alpha"].requests[0]
|
|
item := req.References.Slots["roster"].Items[0]
|
|
if string(item.Content) != "reference text" {
|
|
t.Fatalf("reference content = %q, want reference text", item.Content)
|
|
}
|
|
item.Content[0] = 'R'
|
|
if got := string(pipeline.ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["roster"].Items[0].Content); got != "reference text" {
|
|
t.Fatalf("runner mutated reference set content = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunPassesMergeReferencesToMergerRequest(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
pipeline := resolvedPipeline()
|
|
pipeline.ArtifactLanes[0].MergeReferences.ReferenceSet = testReferenceSet("merge_notes", "merge reference text")
|
|
|
|
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
req := modules.mergers["merge"].requests[0]
|
|
item := req.References.Slots["merge_notes"].Items[0]
|
|
if string(item.Content) != "merge reference text" {
|
|
t.Fatalf("merge reference content = %q, want merge reference text", item.Content)
|
|
}
|
|
item.Content[0] = 'M'
|
|
if got := string(pipeline.ArtifactLanes[0].MergeReferences.ReferenceSet.Slots["merge_notes"].Items[0].Content); got != "merge reference text" {
|
|
t.Fatalf("runner mutated merge reference set content = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunPassesChunkReferencesToChunkerRequest(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
pipeline := resolvedPipeline()
|
|
pipeline.ChunkReferences.ReferenceSet = testReferenceSet("scene_guide", "chunk reference text")
|
|
|
|
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
req := modules.chunker.requests[0]
|
|
item := req.References.Slots["scene_guide"].Items[0]
|
|
if string(item.Content) != "chunk reference text" {
|
|
t.Fatalf("chunk reference content = %q, want chunk reference text", item.Content)
|
|
}
|
|
item.Content[0] = 'C'
|
|
if got := string(pipeline.ChunkReferences.ReferenceSet.Slots["scene_guide"].Items[0].Content); got != "chunk reference text" {
|
|
t.Fatalf("runner mutated chunk reference set content = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunPassesNormalizeReferencesToNormalizerRequest(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
pipeline := resolvedPipeline()
|
|
pipeline.ArtifactLanes[0].NormalizeReferences.ReferenceSet = testReferenceSet("normalization_notes", "normalize reference text")
|
|
|
|
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
req := modules.normalizers["normalize"].requests[0]
|
|
item := req.References.Slots["normalization_notes"].Items[0]
|
|
if string(item.Content) != "normalize reference text" {
|
|
t.Fatalf("normalize reference content = %q, want normalize reference text", item.Content)
|
|
}
|
|
item.Content[0] = 'N'
|
|
if got := string(pipeline.ArtifactLanes[0].NormalizeReferences.ReferenceSet.Slots["normalization_notes"].Items[0].Content); got != "normalize reference text" {
|
|
t.Fatalf("runner mutated normalize reference set content = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunPassesValidationRequestContextToValidators(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
chunkValidator := &runnerChainValidator{name: "chain-chunk"}
|
|
extractValidator := &runnerChainValidator{name: "chain-extract", executionClass: contracts.ExecutionClassLLMBacked}
|
|
mergeValidator := &runnerChainValidator{name: "chain-merge"}
|
|
normalizeValidator := &runnerChainValidator{name: "chain-normalize"}
|
|
modules.validators[chunkValidator.name] = chunkValidator
|
|
modules.validators[extractValidator.name] = extractValidator
|
|
modules.validators[mergeValidator.name] = mergeValidator
|
|
modules.validators[normalizeValidator.name] = normalizeValidator
|
|
|
|
pipeline := resolvedPipeline()
|
|
pipeline.ChunkReferences.ReferenceSet = testReferenceSet("scene_guide", "chunk reference text")
|
|
pipeline.ArtifactLanes[0].ExtractReferences.ReferenceSet = testReferenceSet("roster", "extract reference text")
|
|
pipeline.ArtifactLanes[0].MergeReferences.ReferenceSet = testReferenceSet("merge_notes", "merge reference text")
|
|
pipeline.ArtifactLanes[0].NormalizeReferences.ReferenceSet = testReferenceSet("normalization_notes", "normalize reference text")
|
|
setResolvedValidatorChain(t, &pipeline, StageChunk, "", "chunk", resolvedValidatorForTest(chunkValidator))
|
|
setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", ResolvedValidator{
|
|
Binding: ModuleBinding{Module: extractValidator.name, LLMProfile: "validator-profile", Options: map[string]any{"strict": true}},
|
|
ExecutionClass: extractValidator.ExecutionClass(),
|
|
})
|
|
setResolvedValidatorChain(t, &pipeline, StageMerge, "alpha", "merge", resolvedValidatorForTest(mergeValidator))
|
|
setResolvedValidatorChain(t, &pipeline, StageNormalize, "alpha", "normalize", resolvedValidatorForTest(normalizeValidator))
|
|
|
|
rawInput := []byte("{\"source\":\"exact bytes\"}")
|
|
llmClient := fakeLLMClient{}
|
|
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
|
|
Pipeline: pipeline,
|
|
Path: "session.json",
|
|
RawInput: rawInput,
|
|
LLMClient: llmClient,
|
|
SessionID: "session-123",
|
|
Metadata: map[string]any{"request": "test"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
if len(chunkValidator.requests) != 1 {
|
|
t.Fatalf("chunk validator requests = %d, want one collection request", len(chunkValidator.requests))
|
|
}
|
|
chunkReq := chunkValidator.requests[0]
|
|
if chunkReq.Stage != string(StageChunk) || chunkReq.ModuleKey != "chunk" || chunkReq.SourceID != "source-1" || chunkReq.SessionID != "session-123" {
|
|
t.Fatalf("chunk validation request = %#v, want stage/module/source/session provenance", chunkReq)
|
|
}
|
|
if chunkReq.LLMClient == nil || string(chunkReq.SourceInput.Content) != string(rawInput) {
|
|
t.Fatalf("chunk validation source/client = %#v, want full source input and LLM client", chunkReq.SourceInput)
|
|
}
|
|
if chunkReq.Chunk != nil || chunkReq.ChunkID != "" || len(chunkReq.Chunks) != 2 || chunkReq.Chunks[0].ID != "chunk-0" || chunkReq.Chunks[1].ID != "chunk-1" {
|
|
t.Fatalf("chunk validation chunk fields = chunk=%#v chunk_id=%q chunks=%#v, want whole chunk collection", chunkReq.Chunk, chunkReq.ChunkID, chunkReq.Chunks)
|
|
}
|
|
chunkReq.Chunks[0].Content[0] = 'X'
|
|
if got := string(modules.chunker.chunks[0].Content); got == string(chunkReq.Chunks[0].Content) {
|
|
t.Fatalf("chunk validation chunks alias module output content = %q", got)
|
|
}
|
|
if item := chunkReq.References.Slots["scene_guide"].Items[0]; string(item.Content) != "chunk reference text" {
|
|
t.Fatalf("chunk validation references = %#v, want chunk references", chunkReq.References)
|
|
}
|
|
|
|
if len(extractValidator.requests) != 2 {
|
|
t.Fatalf("extract validator requests = %d, want one per chunk", len(extractValidator.requests))
|
|
}
|
|
extractReq := extractValidator.requests[0]
|
|
if extractReq.Stage != string(StageExtract) || extractReq.LaneID != "alpha" || extractReq.ModuleKey != "extract-alpha" || extractReq.ChunkID != "chunk-0" || extractReq.ChunkIndex != 0 {
|
|
t.Fatalf("extract validation request = %#v, want extract provenance", extractReq)
|
|
}
|
|
if extractReq.LLMProfile != "validator-profile" || extractReq.Options["strict"] != true || extractReq.Metadata["request"] != "test" {
|
|
t.Fatalf("extract validator binding fields = profile %q options %#v metadata %#v", extractReq.LLMProfile, extractReq.Options, extractReq.Metadata)
|
|
}
|
|
if extractReq.Chunk == nil || string(extractReq.SourceInput.Content) != string(extractReq.Chunk.Content) {
|
|
t.Fatalf("extract source input = %#v chunk=%#v, want chunk material", extractReq.SourceInput, extractReq.Chunk)
|
|
}
|
|
if item := extractReq.References.Slots["roster"].Items[0]; string(item.Content) != "extract reference text" {
|
|
t.Fatalf("extract validation references = %#v, want extract references", extractReq.References)
|
|
}
|
|
|
|
if len(mergeValidator.requests) != 1 {
|
|
t.Fatalf("merge validator requests = %d, want one", len(mergeValidator.requests))
|
|
}
|
|
mergeReq := mergeValidator.requests[0]
|
|
if mergeReq.Stage != string(StageMerge) || mergeReq.LaneID != "alpha" || len(mergeReq.ExtractOutputs) != 2 {
|
|
t.Fatalf("merge validation request = %#v, want lane and extract outputs", mergeReq)
|
|
}
|
|
if mergeReq.ExtractOutputs[0].ChunkID != "chunk-0" || string(mergeReq.SourceInput.Content) != string(rawInput) {
|
|
t.Fatalf("merge validation upstream/source = %#v source=%#v, want ordered extracts and source input", mergeReq.ExtractOutputs, mergeReq.SourceInput)
|
|
}
|
|
if item := mergeReq.References.Slots["merge_notes"].Items[0]; string(item.Content) != "merge reference text" {
|
|
t.Fatalf("merge validation references = %#v, want merge references", mergeReq.References)
|
|
}
|
|
|
|
if len(normalizeValidator.requests) != 1 {
|
|
t.Fatalf("normalize validator requests = %d, want one", len(normalizeValidator.requests))
|
|
}
|
|
normalizeReq := normalizeValidator.requests[0]
|
|
if normalizeReq.Stage != string(StageNormalize) || normalizeReq.LaneID != "alpha" || string(normalizeReq.MergeOutput.Payload.Content) != `{"merged":true}` {
|
|
t.Fatalf("normalize validation request = %#v, want merge output context", normalizeReq)
|
|
}
|
|
if item := normalizeReq.References.Slots["normalization_notes"].Items[0]; string(item.Content) != "normalize reference text" {
|
|
t.Fatalf("normalize validation references = %#v, want normalize references", normalizeReq.References)
|
|
}
|
|
|
|
chunkReq.Chunks[0].Content[0] = 'Y'
|
|
if got := string(modules.chunker.chunks[0].Content); got != `{"units":[{"id":1,"kind":"unit","text":"Source unit."}]}` {
|
|
t.Fatalf("validator request mutated original chunk content: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunAllowsNilLLMClientWhenModulesDoNotUseIt(t *testing.T) {
|
|
_, err := New(newRunnerRegistries(t, defaultRunnerModules())).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil with nil LLM client when modules do not use it", err)
|
|
}
|
|
}
|
|
|
|
func TestRunIncludesInputWarnings(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
warning := contracts.Warning{Scope: "reference", ReasonCode: "empty_reference", Message: "empty reference"}
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
|
|
Pipeline: resolvedPipeline(),
|
|
Warnings: []contracts.Warning{warning},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
if len(output.Warnings) != 1 || output.Warnings[0] != warning {
|
|
t.Fatalf("warnings = %#v, want input warning", output.Warnings)
|
|
}
|
|
}
|
|
|
|
func TestRunRecordsTopLevelModuleMetadataForSingletonModules(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
modules.input.manifestMetadata = map[string]any{
|
|
"input_profile": "input-metadata",
|
|
}
|
|
modules.chunker.manifestMetadata = map[string]any{
|
|
"prompt_id": "dnd.scenes",
|
|
"prompt_version": "v1",
|
|
"prompt_sha256": "sha256:chunker-prompt",
|
|
"response_schema_key": "dnd_scenes",
|
|
"response_schema_id": "schema-dnd-scenes",
|
|
"response_schema_name": "dnd_scenes",
|
|
}
|
|
modules.output.manifestMetadata = map[string]any{
|
|
"output_profile": "output-metadata",
|
|
}
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
if output.Manifest.ModuleMetadata == nil {
|
|
t.Fatal("ModuleMetadata = nil, want module metadata map")
|
|
}
|
|
if got := output.Manifest.ModuleMetadata["input"]; !reflect.DeepEqual(got, modules.input.manifestMetadata) {
|
|
t.Fatalf("input module metadata = %#v, want %#v", got, modules.input.manifestMetadata)
|
|
}
|
|
if got := output.Manifest.ModuleMetadata["chunker"]; !reflect.DeepEqual(got, modules.chunker.manifestMetadata) {
|
|
t.Fatalf("chunker module metadata = %#v, want %#v", got, modules.chunker.manifestMetadata)
|
|
}
|
|
if got := output.Manifest.ModuleMetadata["output"]; !reflect.DeepEqual(got, modules.output.manifestMetadata) {
|
|
t.Fatalf("output module metadata = %#v, want %#v", got, modules.output.manifestMetadata)
|
|
}
|
|
|
|
modules.chunker.manifestMetadata["prompt_id"] = "changed"
|
|
if output.Manifest.ModuleMetadata["chunker"]["prompt_id"] != "dnd.scenes" {
|
|
t.Fatalf("chunker module metadata aliased to provider map: %#v", output.Manifest.ModuleMetadata["chunker"])
|
|
}
|
|
}
|
|
|
|
func TestRunPassesPerChunkRawOutputsToMergeAndNormalize(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
|
|
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
merger := modules.mergers["merge"]
|
|
if len(merger.requests) != 1 {
|
|
t.Fatalf("len(merge requests) = %d, want 1", len(merger.requests))
|
|
}
|
|
extractOutputs := merger.requests[0].ExtractOutputs
|
|
if len(extractOutputs) != 2 {
|
|
t.Fatalf("len(ExtractOutputs) = %d, want 2", len(extractOutputs))
|
|
}
|
|
if extractOutputs[0].ChunkID != "chunk-0" || extractOutputs[1].ChunkID != "chunk-1" {
|
|
t.Fatalf("merge chunks = %#v, want chunk order", extractOutputs)
|
|
}
|
|
if extractOutputs[0].ChunkIndex != 0 || string(extractOutputs[0].Payload.Content) != `{"chunk":"chunk-0"}` {
|
|
t.Fatalf("first extract output = %#v, want first chunk payload", extractOutputs[0])
|
|
}
|
|
if extractOutputs[1].ChunkIndex != 1 || string(extractOutputs[1].Payload.Content) != `{"chunk":"chunk-1"}` {
|
|
t.Fatalf("second extract output = %#v, want second chunk payload", extractOutputs[1])
|
|
}
|
|
|
|
normalizer := modules.normalizers["normalize"]
|
|
if len(normalizer.requests) != 1 {
|
|
t.Fatalf("len(normalize requests) = %d, want 1", len(normalizer.requests))
|
|
}
|
|
if string(normalizer.requests[0].MergeOutput.Payload.Content) != `{"merged":true}` {
|
|
t.Fatalf("normalize merge output = %#v, want merged raw output", normalizer.requests[0].MergeOutput)
|
|
}
|
|
}
|
|
|
|
func TestRunPassesChunkContentAndMediaTypeToExtractors(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
modules.chunker.chunks = []contracts.SourceChunk{
|
|
sourceChunkWithContent("chunk-0", 0, []byte(`{"chunk":0}`), "application/vnd.test+json"),
|
|
}
|
|
|
|
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
req := modules.extractors["extract-alpha"].requests[0]
|
|
if req.Chunk == nil {
|
|
t.Fatal("extractor chunk = nil, want chunk")
|
|
}
|
|
if got := string(req.Chunk.Content); got != `{"chunk":0}` {
|
|
t.Fatalf("chunk content = %q, want raw chunk content", got)
|
|
}
|
|
if req.Chunk.MediaType != "application/vnd.test+json" {
|
|
t.Fatalf("chunk media type = %q, want application/vnd.test+json", req.Chunk.MediaType)
|
|
}
|
|
}
|
|
|
|
func TestRunDoesNotPassCheckpointPathsToModules(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
|
|
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
|
|
Pipeline: resolvedPipeline(),
|
|
Checkpoints: NoopCheckpointRecorder(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
for _, req := range modules.input.requests {
|
|
assertNoCheckpointMetadata(t, req.Metadata)
|
|
}
|
|
for _, req := range modules.chunker.requests {
|
|
assertNoCheckpointMetadata(t, req.Metadata)
|
|
}
|
|
for _, req := range modules.extractors["extract-alpha"].requests {
|
|
assertNoCheckpointMetadata(t, req.Metadata)
|
|
}
|
|
for _, req := range modules.mergers["merge"].requests {
|
|
assertNoCheckpointMetadata(t, req.Metadata)
|
|
}
|
|
for _, req := range modules.normalizers["normalize"].requests {
|
|
assertNoCheckpointMetadata(t, req.Metadata)
|
|
}
|
|
for _, req := range modules.output.requests {
|
|
assertNoCheckpointMetadata(t, req.Metadata)
|
|
}
|
|
}
|
|
|
|
func TestRunReusesCheckpointedWorkflowOutputs(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
doc := validSourceDocument()
|
|
chunks := []contracts.SourceChunk{sourceChunkWithID("chunk-0", 0)}
|
|
extractOutput := contracts.ExtractOutput{
|
|
LaneID: "alpha",
|
|
ExtractorKey: "extract-alpha",
|
|
SourceID: doc.ID,
|
|
ChunkID: "chunk-0",
|
|
ChunkIndex: 0,
|
|
Payload: contracts.RawPayload{
|
|
Content: []byte(`{"cached_extract":true}`),
|
|
MediaType: "application/json",
|
|
},
|
|
}
|
|
mergeOutput := contracts.MergeOutput{
|
|
LaneID: "alpha",
|
|
MergerKey: "merge",
|
|
SourceID: doc.ID,
|
|
Payload: contracts.RawPayload{
|
|
Content: []byte(`{"cached_merge":true}`),
|
|
MediaType: "application/json",
|
|
},
|
|
}
|
|
normalizeOutput := contracts.NormalizeOutput{
|
|
LaneID: "alpha",
|
|
NormalizerKey: "normalize",
|
|
SourceID: doc.ID,
|
|
Payload: contracts.RawPayload{
|
|
Content: []byte(`{"cached_normalize":true}`),
|
|
MediaType: "application/json",
|
|
},
|
|
}
|
|
loader := &runnerCheckpointLoader{
|
|
source: SourceCheckpoint{Document: doc},
|
|
chunk: ChunkCheckpoint{Chunks: chunks},
|
|
extract: ExtractCheckpoint{Outputs: []contracts.ExtractOutput{extractOutput}},
|
|
merge: MergeCheckpoint{Output: mergeOutput},
|
|
normalize: NormalizeCheckpoint{Output: normalizeOutput},
|
|
reuse: map[string]bool{
|
|
"source": true,
|
|
"chunk": true,
|
|
"extract": true,
|
|
"merge": true,
|
|
"normalize": true,
|
|
},
|
|
}
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
|
|
Pipeline: resolvedPipeline(),
|
|
Checkpoint: loader,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
if len(modules.input.requests) != 0 || len(modules.chunker.requests) != 0 || len(modules.extractors["extract-alpha"].requests) != 0 || len(modules.mergers["merge"].requests) != 0 || len(modules.normalizers["normalize"].requests) != 0 {
|
|
t.Fatalf("module requests = input:%d chunk:%d extract:%d merge:%d normalize:%d, want all skipped", len(modules.input.requests), len(modules.chunker.requests), len(modules.extractors["extract-alpha"].requests), len(modules.mergers["merge"].requests), len(modules.normalizers["normalize"].requests))
|
|
}
|
|
if len(output.NormalizeOutputs) != 1 || string(output.NormalizeOutputs[0].Payload.Content) != `{"cached_normalize":true}` {
|
|
t.Fatalf("NormalizeOutputs = %#v, want cached normalize output", output.NormalizeOutputs)
|
|
}
|
|
if len(output.CheckpointEvents) != 5 {
|
|
t.Fatalf("checkpoint events = %#v, want one per reusable workflow step", output.CheckpointEvents)
|
|
}
|
|
for _, event := range output.CheckpointEvents {
|
|
if event.Action != "reused" {
|
|
t.Fatalf("checkpoint event = %#v, want reused", event)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunPreservesCheckpointedExtractRejections(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
extractOutput := contracts.ExtractOutput{
|
|
LaneID: "alpha",
|
|
ExtractorKey: "extract-alpha",
|
|
SourceID: "source-1",
|
|
ChunkID: "chunk-1",
|
|
ChunkIndex: 1,
|
|
Payload: contracts.RawPayload{
|
|
Content: []byte(`{"cached_extract":true}`),
|
|
MediaType: "application/json",
|
|
},
|
|
}
|
|
rejected := contracts.RejectedOutput{
|
|
Stage: string(StageExtract),
|
|
LaneID: "alpha",
|
|
ModuleKey: "extract-alpha",
|
|
ChunkID: "chunk-0",
|
|
ReasonCode: "invalid_shape",
|
|
Message: "invalid extract",
|
|
}
|
|
loader := &runnerCheckpointLoader{
|
|
extract: ExtractCheckpoint{
|
|
Outputs: []contracts.ExtractOutput{extractOutput},
|
|
Rejected: []contracts.RejectedOutput{rejected},
|
|
},
|
|
reuse: map[string]bool{"extract": true},
|
|
}
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
|
|
Pipeline: resolvedPipeline(),
|
|
Checkpoint: loader,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
if len(modules.extractors["extract-alpha"].requests) != 0 {
|
|
t.Fatalf("extract requests = %d, want reused checkpoint", len(modules.extractors["extract-alpha"].requests))
|
|
}
|
|
if len(output.Rejected) != 1 || output.Rejected[0].ChunkID != "chunk-0" {
|
|
t.Fatalf("rejected outputs = %#v, want checkpointed extract rejection", output.Rejected)
|
|
}
|
|
mergeRequests := modules.mergers["merge"].requests
|
|
if len(mergeRequests) != 1 || len(mergeRequests[0].ExtractOutputs) != 1 || mergeRequests[0].ExtractOutputs[0].ChunkID != "chunk-1" {
|
|
t.Fatalf("merge extract outputs = %#v, want only checkpointed accepted extract", mergeRequests)
|
|
}
|
|
}
|
|
|
|
func TestRunOmitsRejectedExtractOutputsFromMerge(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
validator := &runnerChainValidator{name: "chain-extract", approved: []bool{false, true}, reason: "bad_extract", message: "extract rejected"}
|
|
modules.validators[validator.name] = validator
|
|
pipeline := resolvedPipeline()
|
|
setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator))
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
if len(output.Rejected) != 1 || output.Rejected[0].Stage != string(StageExtract) || output.Rejected[0].ChunkID != "chunk-0" {
|
|
t.Fatalf("rejected outputs = %#v, want rejected first extract", output.Rejected)
|
|
}
|
|
extractOutputs := modules.mergers["merge"].requests[0].ExtractOutputs
|
|
if len(extractOutputs) != 1 || extractOutputs[0].ChunkID != "chunk-1" {
|
|
t.Fatalf("merge extract outputs = %#v, want only accepted second chunk", extractOutputs)
|
|
}
|
|
if output.Manifest.ValidationStatus != "rejected" {
|
|
t.Fatalf("ValidationStatus = %q, want rejected", output.Manifest.ValidationStatus)
|
|
}
|
|
}
|
|
|
|
func TestRunOmitsLaneWithNoAcceptedExtractOutputs(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
validator := &runnerChainValidator{name: "chain-extract", approved: []bool{false}, reason: "bad_extract", message: "extract rejected"}
|
|
modules.validators[validator.name] = validator
|
|
pipeline := resolvedPipeline()
|
|
setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator))
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
if len(output.Rejected) != 2 {
|
|
t.Fatalf("len(Rejected) = %d, want one rejected record per chunk", len(output.Rejected))
|
|
}
|
|
if len(modules.mergers["merge"].requests) != 0 {
|
|
t.Fatalf("merge requests = %d, want none", len(modules.mergers["merge"].requests))
|
|
}
|
|
if len(modules.normalizers["normalize"].requests) != 0 {
|
|
t.Fatalf("normalize requests = %d, want none", len(modules.normalizers["normalize"].requests))
|
|
}
|
|
if len(modules.output.requests) != 1 || len(modules.output.requests[0].NormalizeOutputs) != 0 {
|
|
t.Fatalf("output normalize outputs = %#v, want none", modules.output.requests)
|
|
}
|
|
}
|
|
|
|
func TestRunRejectedMergePreventsNormalizeForLane(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
validator := &runnerChainValidator{name: "chain-merge", approved: []bool{false}, reason: "bad_merge", message: "merge rejected"}
|
|
modules.validators[validator.name] = validator
|
|
pipeline := resolvedPipeline()
|
|
setResolvedValidatorChain(t, &pipeline, StageMerge, "alpha", "merge", resolvedValidatorForTest(validator))
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
if len(output.Rejected) != 1 || output.Rejected[0].Stage != string(StageMerge) {
|
|
t.Fatalf("rejected outputs = %#v, want rejected merge", output.Rejected)
|
|
}
|
|
if len(modules.normalizers["normalize"].requests) != 0 {
|
|
t.Fatalf("normalize requests = %d, want none", len(modules.normalizers["normalize"].requests))
|
|
}
|
|
if len(modules.output.requests[0].NormalizeOutputs) != 0 {
|
|
t.Fatalf("output normalize outputs = %#v, want none", modules.output.requests[0].NormalizeOutputs)
|
|
}
|
|
}
|
|
|
|
func TestRunRejectedNormalizePreventsOutputForLane(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
validator := &runnerChainValidator{name: "chain-normalize", approved: []bool{false}, reason: "bad_normalize", message: "normalize rejected"}
|
|
modules.validators[validator.name] = validator
|
|
pipeline := resolvedPipeline()
|
|
setResolvedValidatorChain(t, &pipeline, StageNormalize, "alpha", "normalize", resolvedValidatorForTest(validator))
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
if len(output.Rejected) != 1 || output.Rejected[0].Stage != string(StageNormalize) {
|
|
t.Fatalf("rejected outputs = %#v, want rejected normalize", output.Rejected)
|
|
}
|
|
if len(output.NormalizeOutputs) != 0 {
|
|
t.Fatalf("NormalizeOutputs = %#v, want none", output.NormalizeOutputs)
|
|
}
|
|
if len(modules.output.requests[0].NormalizeOutputs) != 0 {
|
|
t.Fatalf("output normalize outputs = %#v, want none", modules.output.requests[0].NormalizeOutputs)
|
|
}
|
|
}
|
|
|
|
func TestRunRetriesSameModuleInputAfterFrameworkError(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
modules.extractors["extract-alpha"].failuresBeforeSuccess = 1
|
|
modules.extractors["extract-alpha"].failureErr = errors.New("transient extract failure")
|
|
pipeline := resolvedPipeline()
|
|
pipeline.ArtifactLanes[0].Extract.Retries = 1
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
requests := modules.extractors["extract-alpha"].requests
|
|
if len(requests) != 3 {
|
|
t.Fatalf("extract requests = %d, want retry plus remaining chunk", len(requests))
|
|
}
|
|
if requests[0].Chunk.ID != "chunk-0" || requests[1].Chunk.ID != "chunk-0" {
|
|
t.Fatalf("retried chunks = %q, %q; want same first chunk input", requests[0].Chunk.ID, requests[1].Chunk.ID)
|
|
}
|
|
if output.Manifest.ValidationStatus != "approved" {
|
|
t.Fatalf("ValidationStatus = %q, want approved", output.Manifest.ValidationStatus)
|
|
}
|
|
}
|
|
|
|
func TestRunRetriesSameModuleInputAfterValidatorRejection(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
validator := &runnerChainValidator{name: "chain-extract", approved: []bool{false, true, true}, reason: "bad_extract", message: "extract rejected"}
|
|
modules.validators[validator.name] = validator
|
|
pipeline := resolvedPipeline()
|
|
setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator))
|
|
pipeline.ArtifactLanes[0].Extract.Retries = 1
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
requests := modules.extractors["extract-alpha"].requests
|
|
if len(requests) != 3 {
|
|
t.Fatalf("extract requests = %d, want retry plus remaining chunk", len(requests))
|
|
}
|
|
if requests[0].Chunk.ID != "chunk-0" || requests[1].Chunk.ID != "chunk-0" {
|
|
t.Fatalf("retried chunks = %q, %q; want same first chunk input", requests[0].Chunk.ID, requests[1].Chunk.ID)
|
|
}
|
|
if len(output.Rejected) != 0 {
|
|
t.Fatalf("Rejected = %#v, want transient rejection omitted after retry approval", output.Rejected)
|
|
}
|
|
}
|
|
|
|
func TestRunDebugFailedChunkAttemptReferencesScopedLLMOutput(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
modules.chunker.callLLM = true
|
|
modules.chunker.llmPromptID = "runner.chunk"
|
|
modules.chunker.err = errors.New("malformed structured output")
|
|
recorder := newMemoryDebugRecorder()
|
|
|
|
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
|
|
Pipeline: resolvedPipeline(),
|
|
LLMClient: debugResponseLLMClient{content: []byte(`{"raw":true}`), profileID: "debug-profile"},
|
|
Debug: recorder,
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "malformed structured output") {
|
|
t.Fatalf("Run() error = %v, want chunk failure", err)
|
|
}
|
|
|
|
attempt := recorder.envelope(t, "chunk/attempt-01.json")
|
|
if len(attempt.LLMCalls) != 1 {
|
|
t.Fatalf("llm_calls = %#v, want one scoped call", attempt.LLMCalls)
|
|
}
|
|
call := attempt.LLMCalls[0]
|
|
if call.CallID != "0001" || call.PromptPath != "chunk/attempt-01/prompt-0001.json" || call.ResponsePath != "chunk/attempt-01/response-0001.json" || call.ResponseContentPath != "chunk/attempt-01/response-content-0001.json" {
|
|
t.Fatalf("llm call reference = %#v, want prompt and response paths", call)
|
|
}
|
|
if call.PromptID != "runner.chunk" || call.ProfileID != "debug-profile" || call.Model != "debug-model" || call.Error {
|
|
t.Fatalf("llm call metadata = %#v, want prompt/profile and no call error", call)
|
|
}
|
|
|
|
prompt := recorder.envelope(t, call.PromptPath)
|
|
promptPayload, ok := prompt.Payload.(debugLLMPromptArtifact)
|
|
if !ok {
|
|
t.Fatalf("prompt payload type = %T, want debugLLMPromptArtifact", prompt.Payload)
|
|
}
|
|
if promptPayload.Prompt == nil || len(promptPayload.Prompt.Messages) != 1 || promptPayload.Prompt.Messages[0].Content != "raw prompt text" {
|
|
t.Fatalf("prompt payload = %#v, want raw prompt message", promptPayload)
|
|
}
|
|
|
|
response := recorder.envelope(t, call.ResponsePath)
|
|
responsePayload, ok := response.Payload.(debugLLMResponseArtifact)
|
|
if !ok {
|
|
t.Fatalf("response payload type = %T, want debugLLMResponseArtifact", response.Payload)
|
|
}
|
|
if responsePayload.ContentPath != call.ResponseContentPath {
|
|
t.Fatalf("response content path = %q, want %q", responsePayload.ContentPath, call.ResponseContentPath)
|
|
}
|
|
if responsePayload.Response == nil || responsePayload.Response.Content != "" {
|
|
t.Fatalf("response payload = %#v, want metadata without inline content", responsePayload)
|
|
}
|
|
if got := string(recorder.bytes[call.ResponseContentPath]); got != "{\n \"raw\": true\n}\n" {
|
|
t.Fatalf("response content file = %q, want pretty JSON", got)
|
|
}
|
|
if _, ok := recorder.payloads["llm/call-0001.json"]; ok {
|
|
t.Fatalf("old canonical LLM debug artifact was written")
|
|
}
|
|
if _, ok := recorder.payloads["chunk/attempt-01/llm-call-0001.json"]; ok {
|
|
t.Fatalf("old scoped LLM debug artifact was written")
|
|
}
|
|
}
|
|
|
|
func TestRunDebugWritesNonJSONLLMResponseContentAsText(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
modules.chunker.callLLM = true
|
|
modules.chunker.llmPromptID = "runner.chunk"
|
|
modules.chunker.err = errors.New("malformed structured output")
|
|
recorder := newMemoryDebugRecorder()
|
|
|
|
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
|
|
Pipeline: resolvedPipeline(),
|
|
LLMClient: debugResponseLLMClient{content: []byte("plain text response"), profileID: "debug-profile"},
|
|
Debug: recorder,
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "malformed structured output") {
|
|
t.Fatalf("Run() error = %v, want chunk failure", err)
|
|
}
|
|
|
|
attempt := recorder.envelope(t, "chunk/attempt-01.json")
|
|
if len(attempt.LLMCalls) != 1 {
|
|
t.Fatalf("llm_calls = %#v, want one scoped call", attempt.LLMCalls)
|
|
}
|
|
call := attempt.LLMCalls[0]
|
|
if call.ResponseContentPath != "chunk/attempt-01/response-content-0001.txt" {
|
|
t.Fatalf("response content path = %q, want .txt file", call.ResponseContentPath)
|
|
}
|
|
if got := string(recorder.bytes[call.ResponseContentPath]); got != "plain text response" {
|
|
t.Fatalf("response text content = %q, want raw text", got)
|
|
}
|
|
response := recorder.envelope(t, call.ResponsePath)
|
|
responsePayload, ok := response.Payload.(debugLLMResponseArtifact)
|
|
if !ok {
|
|
t.Fatalf("response payload type = %T, want debugLLMResponseArtifact", response.Payload)
|
|
}
|
|
if responsePayload.Response == nil || responsePayload.Response.Content != "" {
|
|
t.Fatalf("response payload = %#v, want metadata without inline content", responsePayload)
|
|
}
|
|
}
|
|
|
|
func TestRunStopsRetryAfterConfiguredAttemptsAndRecordsAttemptCount(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
validator := &runnerChainValidator{name: "chain-extract", approved: []bool{false}, reason: "bad_extract", message: "extract rejected"}
|
|
modules.validators[validator.name] = validator
|
|
pipeline := resolvedPipeline()
|
|
setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator))
|
|
pipeline.ArtifactLanes[0].Extract.Retries = 1
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
if len(output.Rejected) != 2 {
|
|
t.Fatalf("len(Rejected) = %d, want rejected record per chunk", len(output.Rejected))
|
|
}
|
|
if output.Rejected[0].AttemptCount != 2 || output.Rejected[1].AttemptCount != 2 {
|
|
t.Fatalf("attempt counts = %#v, want final attempt count 2", output.Rejected)
|
|
}
|
|
if len(modules.extractors["extract-alpha"].requests) != 4 {
|
|
t.Fatalf("extract requests = %d, want two attempts per chunk", len(modules.extractors["extract-alpha"].requests))
|
|
}
|
|
if len(output.Manifest.RejectedOutputs) != 2 {
|
|
t.Fatalf("manifest rejected outputs = %#v, want rejected records", output.Manifest.RejectedOutputs)
|
|
}
|
|
if output.Manifest.RejectedOutputs[0].AttemptCount != 2 || output.Manifest.RejectedOutputs[0].ChunkID != "chunk-0" {
|
|
t.Fatalf("manifest rejected output = %#v, want final attempt count and chunk provenance", output.Manifest.RejectedOutputs[0])
|
|
}
|
|
}
|
|
|
|
func TestRunContextCancellationStopsRetries(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
modules.extractors["extract-alpha"].err = errors.New("extract failed")
|
|
pipeline := resolvedPipeline()
|
|
pipeline.ArtifactLanes[0].Extract.Retries = 2
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(ctx, RunInput{Pipeline: pipeline})
|
|
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Run() error = %v, want context.Canceled", err)
|
|
}
|
|
if len(modules.extractors["extract-alpha"].requests) != 0 {
|
|
t.Fatalf("extract requests = %d, want none after cancellation", len(modules.extractors["extract-alpha"].requests))
|
|
}
|
|
if output.Manifest.ValidationStatus != "failed" {
|
|
t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus)
|
|
}
|
|
}
|
|
|
|
func TestRunRejectsConfiguredValidators(t *testing.T) {
|
|
_, err := New(newRunnerRegistries(t, defaultRunnerModules())).Run(context.Background(), RunInput{
|
|
Pipeline: resolvedPipelineWithValidators("configured", "second-validator"),
|
|
})
|
|
assertRunError(t, err, "extract.validators")
|
|
}
|
|
|
|
func TestRunCollectsStageWarnings(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
modules.chunker.warnings = []contracts.Warning{{ReasonCode: "chunk-warning", Message: "chunk warning"}}
|
|
modules.extractors["extract-alpha"].warnings = []contracts.Warning{{ReasonCode: "extract-warning", Message: "extract warning"}}
|
|
modules.mergers["merge"].warnings = []contracts.Warning{{ReasonCode: "merge-warning", Message: "merge warning"}}
|
|
modules.normalizers["normalize"].warnings = []contracts.Warning{{ReasonCode: "normalize-warning", Message: "normalize warning"}}
|
|
modules.output.warnings = []contracts.Warning{{ReasonCode: "output-warning", Message: "output warning"}}
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
want := []string{"chunk-warning", "extract-warning", "extract-warning", "merge-warning", "normalize-warning", "output-warning"}
|
|
if got := warningReasons(output.Warnings); !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("warning reasons = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRunCollectsChunkValidatorWarnings(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
modules.chunker.chunks = []contracts.SourceChunk{sourceChunkWithID("chunk-0", 0)}
|
|
validator := &runnerChainValidator{
|
|
name: "chain-chunk",
|
|
warnings: []contracts.Warning{{ReasonCode: "chunk-validator-warning", Message: "chunk validator warning"}},
|
|
}
|
|
modules.validators[validator.name] = validator
|
|
pipeline := resolvedPipeline()
|
|
setResolvedValidatorChain(t, &pipeline, StageChunk, "", "chunk", resolvedValidatorForTest(validator))
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
if got := warningReasons(output.Warnings); !reflect.DeepEqual(got, []string{"chunk-validator-warning"}) {
|
|
t.Fatalf("warning reasons = %#v, want chunk validator warning", got)
|
|
}
|
|
}
|
|
|
|
func TestRunOutputEncoderReceivesManifestAndRawOutputs(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
if len(output.OutputFiles) != 1 {
|
|
t.Fatalf("len(OutputFiles) = %d, want 1", len(output.OutputFiles))
|
|
}
|
|
file := output.OutputFiles[0]
|
|
if file.Name != "outputs/generic.json" {
|
|
t.Fatalf("OutputFiles[0].Name = %q, want outputs/generic.json", file.Name)
|
|
}
|
|
if file.ContentType != "application/json" {
|
|
t.Fatalf("ContentType = %q, want application/json", file.ContentType)
|
|
}
|
|
if string(file.Bytes) != `{"encoded":true}` {
|
|
t.Fatalf("OutputFiles[0].Bytes = %s, want encoded payload", file.Bytes)
|
|
}
|
|
if len(modules.output.requests) != 1 {
|
|
t.Fatalf("len(output requests) = %d, want 1", len(modules.output.requests))
|
|
}
|
|
req := modules.output.requests[0]
|
|
if req.Manifest.PipelineID != "pipeline-1" || req.Manifest.PipelineDigest != "sha256:pipeline" {
|
|
t.Fatalf("output manifest = %#v, want pipeline details", req.Manifest)
|
|
}
|
|
if len(req.NormalizeOutputs) != 1 {
|
|
t.Fatalf("len(output NormalizeOutputs) = %d, want 1", len(req.NormalizeOutputs))
|
|
}
|
|
if req.NormalizeOutputs[0].LaneID != "alpha" || req.NormalizeOutputs[0].NormalizerKey != "normalize" {
|
|
t.Fatalf("NormalizeOutputs[0] = %#v, want normalized alpha output", req.NormalizeOutputs[0])
|
|
}
|
|
}
|
|
|
|
func TestRunRejectsUnsafeOutputFileNames(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
fileName string
|
|
}{
|
|
{name: "empty", fileName: ""},
|
|
{name: "absolute", fileName: "/tmp/output.json"},
|
|
{name: "parent", fileName: "outputs/../manifest.json"},
|
|
{name: "backslash", fileName: `outputs\manifest.json`},
|
|
{name: "unclean", fileName: "outputs//manifest.json"},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
modules.output.files = []contracts.OutputFile{
|
|
{Name: test.fileName, ContentType: "application/json", Bytes: []byte(`{}`)},
|
|
}
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
|
|
assertRunError(t, err, "output file name")
|
|
if output.Manifest.ValidationStatus != "failed" {
|
|
t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunReturnsFailedManifestWhenOutputEncoderFails(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
modules.output.err = errors.New("encode failed")
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
|
|
assertRunError(t, err, "encode failed")
|
|
if output.Manifest.ValidationStatus != "failed" {
|
|
t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus)
|
|
}
|
|
if output.Manifest.CompletedAt == nil {
|
|
t.Fatal("CompletedAt = nil, want failed run completion timestamp")
|
|
}
|
|
if len(output.NormalizeOutputs) != 1 {
|
|
t.Fatalf("len(NormalizeOutputs) = %d, want partial normalized output", len(output.NormalizeOutputs))
|
|
}
|
|
}
|
|
|
|
func TestRunManifestIncludesPipelineAndLaneDetails(t *testing.T) {
|
|
resolved := resolvedPipeline()
|
|
resolved.ChunkReferences.ReferenceSet = contracts.ReferenceSet{
|
|
Slots: map[string]contracts.ResolvedReferenceSlot{
|
|
"scene_guide": {
|
|
Slot: contracts.ReferenceSlot{Name: "scene_guide"},
|
|
Items: []contracts.ReferenceItem{
|
|
{
|
|
SlotName: "scene_guide",
|
|
MediaType: "text/plain; charset=utf-8",
|
|
Content: []byte("chunk reference content"),
|
|
Digest: "sha256:chunk-reference",
|
|
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/scene-guide.txt"},
|
|
SizeBytes: int64(len("chunk reference content")),
|
|
BindingSource: contracts.ReferenceBindingSourceCLI,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
resolved.ArtifactLanes[0].ExtractReferences.ReferenceSet = contracts.ReferenceSet{
|
|
Slots: map[string]contracts.ResolvedReferenceSlot{
|
|
"roster": {
|
|
Slot: contracts.ReferenceSlot{Name: "roster"},
|
|
Items: []contracts.ReferenceItem{
|
|
{
|
|
SlotName: "roster",
|
|
MediaType: "text/plain; charset=utf-8",
|
|
Content: []byte("reference content"),
|
|
Digest: "sha256:reference",
|
|
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/roster.txt"},
|
|
SizeBytes: int64(len("reference content")),
|
|
BindingSource: contracts.ReferenceBindingSourceConfig,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
resolved.ArtifactLanes[0].MergeReferences.ReferenceSet = contracts.ReferenceSet{
|
|
Slots: map[string]contracts.ResolvedReferenceSlot{
|
|
"merge_notes": {
|
|
Slot: contracts.ReferenceSlot{Name: "merge_notes"},
|
|
Items: []contracts.ReferenceItem{
|
|
{
|
|
SlotName: "merge_notes",
|
|
MediaType: "text/plain; charset=utf-8",
|
|
Content: []byte("merge reference content"),
|
|
Digest: "sha256:merge-reference",
|
|
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/merge.txt"},
|
|
SizeBytes: int64(len("merge reference content")),
|
|
BindingSource: contracts.ReferenceBindingSourceConfig,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
resolved.ArtifactLanes[0].NormalizeReferences.ReferenceSet = contracts.ReferenceSet{
|
|
Slots: map[string]contracts.ResolvedReferenceSlot{
|
|
"normalization_notes": {
|
|
Slot: contracts.ReferenceSlot{Name: "normalization_notes"},
|
|
Items: []contracts.ReferenceItem{
|
|
{
|
|
SlotName: "normalization_notes",
|
|
MediaType: "text/plain; charset=utf-8",
|
|
Content: []byte("normalize reference content"),
|
|
Digest: "sha256:normalize-reference",
|
|
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/normalize.txt"},
|
|
SizeBytes: int64(len("normalize reference content")),
|
|
BindingSource: contracts.ReferenceBindingSourceConfig,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
output, err := New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{Pipeline: resolved})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
manifest := output.Manifest
|
|
if manifest.PipelineID != "pipeline-1" || manifest.PipelineDigest != "sha256:pipeline" {
|
|
t.Fatalf("manifest pipeline fields = %#v, want pipeline details", manifest)
|
|
}
|
|
if manifest.InputModule != "input" || manifest.Chunker != "chunk" || manifest.OutputEncoder != "output" {
|
|
t.Fatalf("manifest modules = %#v, want input/chunk/output modules", manifest)
|
|
}
|
|
if !reflect.DeepEqual(manifest.SourceDigests, []string{"sha256:source"}) {
|
|
t.Fatalf("SourceDigests = %#v, want source digest", manifest.SourceDigests)
|
|
}
|
|
if len(manifest.References) != 4 {
|
|
t.Fatalf("References = %#v, want four reference provenance entries", manifest.References)
|
|
}
|
|
chunkReference := manifest.References[0]
|
|
if chunkReference.Stage != string(StageChunk) || chunkReference.LaneID != "" || chunkReference.SlotName != "scene_guide" || chunkReference.Digest != "sha256:chunk-reference" {
|
|
t.Fatalf("chunk reference provenance = %#v, want chunk slot digest", chunkReference)
|
|
}
|
|
if chunkReference.OriginType != "file" || chunkReference.OriginURI != "file:///tmp/scene-guide.txt" || chunkReference.MediaType != "text/plain; charset=utf-8" || chunkReference.SizeBytes != int64(len("chunk reference content")) || chunkReference.BindingSource != contracts.ReferenceBindingSourceCLI {
|
|
t.Fatalf("chunk reference provenance = %#v, want origin/media/size/source", chunkReference)
|
|
}
|
|
extractReference := manifest.References[1]
|
|
if extractReference.Stage != string(StageExtract) || extractReference.LaneID != "alpha" || extractReference.SlotName != "roster" || extractReference.Digest != "sha256:reference" {
|
|
t.Fatalf("extract reference provenance = %#v, want lane slot digest", extractReference)
|
|
}
|
|
if extractReference.OriginType != "file" || extractReference.OriginURI != "file:///tmp/roster.txt" || extractReference.MediaType != "text/plain; charset=utf-8" || extractReference.SizeBytes != int64(len("reference content")) || extractReference.BindingSource != contracts.ReferenceBindingSourceConfig {
|
|
t.Fatalf("extract reference provenance = %#v, want origin/media/size/source", extractReference)
|
|
}
|
|
mergeReference := manifest.References[2]
|
|
if mergeReference.Stage != string(StageMerge) || mergeReference.LaneID != "alpha" || mergeReference.SlotName != "merge_notes" || mergeReference.Digest != "sha256:merge-reference" {
|
|
t.Fatalf("merge reference provenance = %#v, want lane slot digest", mergeReference)
|
|
}
|
|
normalizeReference := manifest.References[3]
|
|
if normalizeReference.Stage != string(StageNormalize) || normalizeReference.LaneID != "alpha" || normalizeReference.SlotName != "normalization_notes" || normalizeReference.Digest != "sha256:normalize-reference" {
|
|
t.Fatalf("normalize reference provenance = %#v, want lane slot digest", normalizeReference)
|
|
}
|
|
if manifest.ValidationStatus != "approved" {
|
|
t.Fatalf("ValidationStatus = %q, want approved", manifest.ValidationStatus)
|
|
}
|
|
if len(manifest.NormalizedOutputs) != 1 {
|
|
t.Fatalf("NormalizedOutputs = %#v, want one raw output manifest", manifest.NormalizedOutputs)
|
|
}
|
|
normalized := manifest.NormalizedOutputs[0]
|
|
if normalized.LaneID != "alpha" || normalized.ModuleKey != "normalize" || normalized.MediaType != "application/json" || normalized.Schema.ID != "runner.raw" {
|
|
t.Fatalf("normalized output manifest = %#v, want lane/module/media/schema provenance", normalized)
|
|
}
|
|
if len(manifest.ArtifactLanes) != 1 {
|
|
t.Fatalf("len(ArtifactLanes) = %d, want 1", len(manifest.ArtifactLanes))
|
|
}
|
|
lane := manifest.ArtifactLanes[0]
|
|
if lane.ID != "alpha" || lane.Extractor != "extract-alpha" || lane.Merger != "merge" || lane.Normalizer != "normalize" {
|
|
t.Fatalf("ArtifactLanes[0] = %#v, want lane details", lane)
|
|
}
|
|
if len(manifest.ValidatorChains) != 4 {
|
|
t.Fatalf("ValidatorChains = %#v, want four validation points", manifest.ValidatorChains)
|
|
}
|
|
if manifest.ValidatorChains[0].Stage != string(StageChunk) || manifest.ValidatorChains[0].ModuleKey != "chunk" || len(manifest.ValidatorChains[0].Validators) != 0 {
|
|
t.Fatalf("chunk validator chain = %#v, want explicit empty chunk chain", manifest.ValidatorChains[0])
|
|
}
|
|
}
|
|
|
|
func TestRunManifestIncludesRunTimingAndLLMProfiles(t *testing.T) {
|
|
startedAt := time.Now().Add(-time.Minute).UTC()
|
|
profiles := []artifacts.LLMProfileManifest{
|
|
{ID: "default", Provider: "scriptorium", Model: "model-a"},
|
|
}
|
|
|
|
output, err := New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{
|
|
Pipeline: resolvedPipeline(),
|
|
RunID: "run-test",
|
|
StartedAt: startedAt,
|
|
LLMProfiles: profiles,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
manifest := output.Manifest
|
|
if manifest.RunID != "run-test" {
|
|
t.Fatalf("RunID = %q, want run-test", manifest.RunID)
|
|
}
|
|
if manifest.StartedAt == nil || !manifest.StartedAt.Equal(startedAt) {
|
|
t.Fatalf("StartedAt = %v, want %s", manifest.StartedAt, startedAt)
|
|
}
|
|
if manifest.CompletedAt == nil || manifest.CompletedAt.Before(startedAt) {
|
|
t.Fatalf("CompletedAt = %v, want timestamp after start", manifest.CompletedAt)
|
|
}
|
|
if !reflect.DeepEqual(manifest.LLMProfiles, profiles) {
|
|
t.Fatalf("LLMProfiles = %#v, want %#v", manifest.LLMProfiles, profiles)
|
|
}
|
|
}
|
|
|
|
func TestRunManifestIncludesProfilesReportedByLLMClient(t *testing.T) {
|
|
output, err := New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{
|
|
Pipeline: resolvedPipeline(),
|
|
LLMClient: manifestReportingLLMClient{profiles: []artifacts.LLMProfileManifest{
|
|
{ID: "profile-b", Provider: "scriptorium", Model: "model-b"},
|
|
{ID: "profile-a", Provider: "scriptorium", Model: "model-a"},
|
|
{ID: "profile-b", Provider: "scriptorium", Model: "model-b"},
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
want := []artifacts.LLMProfileManifest{
|
|
{ID: "profile-a", Provider: "scriptorium", Model: "model-a"},
|
|
{ID: "profile-b", Provider: "scriptorium", Model: "model-b"},
|
|
}
|
|
if !reflect.DeepEqual(output.Manifest.LLMProfiles, want) {
|
|
t.Fatalf("LLMProfiles = %#v, want %#v", output.Manifest.LLMProfiles, want)
|
|
}
|
|
}
|
|
|
|
func TestRunManifestGeneratesRunIDAndTimestamps(t *testing.T) {
|
|
output, err := New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
if !strings.HasPrefix(output.Manifest.RunID, "run-") {
|
|
t.Fatalf("RunID = %q, want generated run ID", output.Manifest.RunID)
|
|
}
|
|
if output.Manifest.StartedAt == nil {
|
|
t.Fatal("StartedAt = nil, want generated timestamp")
|
|
}
|
|
if output.Manifest.CompletedAt == nil {
|
|
t.Fatal("CompletedAt = nil, want generated timestamp")
|
|
}
|
|
}
|
|
|
|
func TestRunManifestIncludesExtractorMetadata(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
modules.extractors["extract-alpha"].manifestMetadata = map[string]any{
|
|
"prompt_id": "test.prompt",
|
|
"response_schema_name": "test_schema",
|
|
}
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want nil", err)
|
|
}
|
|
|
|
lane := output.Manifest.ArtifactLanes[0]
|
|
extractorMetadata, ok := lane.Metadata["extractor"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("lane metadata = %#v, want extractor metadata", lane.Metadata)
|
|
}
|
|
if extractorMetadata["prompt_id"] != "test.prompt" || extractorMetadata["response_schema_name"] != "test_schema" {
|
|
t.Fatalf("extractor metadata = %#v, want prompt and schema metadata", extractorMetadata)
|
|
}
|
|
if output.Manifest.ModuleMetadata != nil {
|
|
if _, ok := output.Manifest.ModuleMetadata["extractor"]; ok {
|
|
t.Fatalf("top-level module metadata includes lane metadata key: %#v", output.Manifest.ModuleMetadata)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunReturnsPartialOutputWhenLaterLaneFails(t *testing.T) {
|
|
modules := defaultRunnerModules()
|
|
modules.extractors["extract-beta"] = &runnerExtractor{key: "extract-beta", err: errors.New("extract failed")}
|
|
pipeline := resolvedPipeline()
|
|
pipeline.ArtifactLanes = append(pipeline.ArtifactLanes, ResolvedArtifactLane{
|
|
ID: "beta",
|
|
Extract: Binding("extract-beta"),
|
|
Merge: Binding("merge"),
|
|
Normalize: Binding("normalize"),
|
|
})
|
|
|
|
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
|
|
|
|
assertRunError(t, err, "extract failed")
|
|
if output.Manifest.ValidationStatus != "failed" {
|
|
t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus)
|
|
}
|
|
if len(output.NormalizeOutputs) != 1 {
|
|
t.Fatalf("len(NormalizeOutputs) = %d, want first lane output", len(output.NormalizeOutputs))
|
|
}
|
|
if len(modules.output.requests) != 0 {
|
|
t.Fatalf("output requests = %d, want framework error to abort before output", len(modules.output.requests))
|
|
}
|
|
}
|
|
|
|
func resolvedPipeline() ResolvedPipeline {
|
|
return ResolvedPipeline{
|
|
ID: "pipeline-1",
|
|
Digest: "sha256:pipeline",
|
|
Input: Binding("input"),
|
|
Chunk: Binding("chunk"),
|
|
ChunkReferences: referenceTarget(StageChunk, "", "chunk", nil),
|
|
ArtifactLanes: []ResolvedArtifactLane{
|
|
{
|
|
ID: "alpha",
|
|
Extract: Binding("extract-alpha"),
|
|
Merge: Binding("merge"),
|
|
Normalize: Binding("normalize"),
|
|
ExtractReferences: referenceTarget(StageExtract, "alpha", "extract-alpha", nil),
|
|
MergeReferences: referenceTarget(StageMerge, "alpha", "merge", nil),
|
|
NormalizeReferences: referenceTarget(StageNormalize, "alpha", "normalize", nil),
|
|
},
|
|
},
|
|
ValidatorChains: []ResolvedValidatorChain{
|
|
{Stage: StageChunk, ModuleKey: "chunk"},
|
|
{Stage: StageExtract, LaneID: "alpha", ModuleKey: "extract-alpha"},
|
|
{Stage: StageMerge, LaneID: "alpha", ModuleKey: "merge"},
|
|
{Stage: StageNormalize, LaneID: "alpha", ModuleKey: "normalize"},
|
|
},
|
|
Output: Binding("output"),
|
|
}
|
|
}
|
|
|
|
func resolvedPipelineWithValidators(validators ...string) ResolvedPipeline {
|
|
pipeline := resolvedPipeline()
|
|
for _, validator := range validators {
|
|
pipeline.ArtifactLanes[0].Validators = append(pipeline.ArtifactLanes[0].Validators, Binding(validator))
|
|
}
|
|
return pipeline
|
|
}
|
|
|
|
func testReferenceSet(slotName string, content string) contracts.ReferenceSet {
|
|
return contracts.ReferenceSet{
|
|
Slots: map[string]contracts.ResolvedReferenceSlot{
|
|
slotName: {
|
|
Slot: contracts.ReferenceSlot{Name: slotName},
|
|
Items: []contracts.ReferenceItem{
|
|
{
|
|
SlotName: slotName,
|
|
MediaType: "text/plain; charset=utf-8",
|
|
Content: []byte(content),
|
|
Digest: "sha256:test",
|
|
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/reference.txt"},
|
|
SizeBytes: int64(len(content)),
|
|
BindingSource: contracts.ReferenceBindingSourceConfig,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
type runnerModules struct {
|
|
input *runnerInputAdapter
|
|
chunker *runnerChunker
|
|
extractors map[string]*runnerExtractor
|
|
mergers map[string]*runnerMerger
|
|
normalizers map[string]*runnerNormalizer
|
|
validators map[string]contracts.Validator
|
|
output *runnerOutputEncoder
|
|
inputBuildErr error
|
|
chunkerBuildErr error
|
|
}
|
|
|
|
func defaultRunnerModules() *runnerModules {
|
|
return &runnerModules{
|
|
input: &runnerInputAdapter{key: "input", doc: validSourceDocument()},
|
|
chunker: &runnerChunker{key: "chunk", chunks: []contracts.SourceChunk{sourceChunkWithID("chunk-0", 0), sourceChunkWithID("chunk-1", 1)}},
|
|
extractors: map[string]*runnerExtractor{
|
|
"extract-alpha": {key: "extract-alpha"},
|
|
},
|
|
mergers: map[string]*runnerMerger{
|
|
"merge": {key: "merge"},
|
|
},
|
|
normalizers: map[string]*runnerNormalizer{
|
|
"normalize": {key: "normalize"},
|
|
},
|
|
validators: map[string]contracts.Validator{
|
|
"configured": &runnerValidator{name: "configured"},
|
|
"second-validator": &runnerValidator{name: "second-validator"},
|
|
},
|
|
output: &runnerOutputEncoder{
|
|
key: "output",
|
|
files: []contracts.OutputFile{
|
|
{Name: "outputs/generic.json", ContentType: "application/json", Bytes: []byte(`{"encoded":true}`)},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func newRunnerRegistries(t *testing.T, modules *runnerModules) Registries {
|
|
t.Helper()
|
|
if modules == nil {
|
|
modules = defaultRunnerModules()
|
|
}
|
|
|
|
registries := Registries{
|
|
Inputs: NewInputAdapterRegistry(),
|
|
Chunkers: NewChunkerRegistry(),
|
|
Extractors: NewExtractorRegistry(),
|
|
Mergers: NewMergerRegistry(),
|
|
Normalizers: NewNormalizerRegistry(),
|
|
Validators: NewValidatorRegistry(),
|
|
ValidatorChains: NewValidatorChainRegistry(),
|
|
Outputs: NewOutputEncoderRegistry(),
|
|
}
|
|
if err := registries.Inputs.Register("input", func() (contracts.InputAdapter, error) {
|
|
if modules.inputBuildErr != nil {
|
|
return nil, modules.inputBuildErr
|
|
}
|
|
return modules.input, nil
|
|
}); err != nil {
|
|
t.Fatalf("register input: %v", err)
|
|
}
|
|
if err := registries.Chunkers.Register("chunk", func() (contracts.Chunker, error) {
|
|
if modules.chunkerBuildErr != nil {
|
|
return nil, modules.chunkerBuildErr
|
|
}
|
|
return modules.chunker, nil
|
|
}); err != nil {
|
|
t.Fatalf("register chunker: %v", err)
|
|
}
|
|
for key, extractor := range modules.extractors {
|
|
extractor := extractor
|
|
if err := registries.Extractors.Register(key, func() (contracts.Extractor, error) { return extractor, nil }); err != nil {
|
|
t.Fatalf("register extractor %q: %v", key, err)
|
|
}
|
|
}
|
|
for key, merger := range modules.mergers {
|
|
merger := merger
|
|
if err := registries.Mergers.Register(key, func() (contracts.Merger, error) { return merger, nil }); err != nil {
|
|
t.Fatalf("register merger %q: %v", key, err)
|
|
}
|
|
}
|
|
for key, normalizer := range modules.normalizers {
|
|
normalizer := normalizer
|
|
if err := registries.Normalizers.Register(key, func() (contracts.Normalizer, error) { return normalizer, nil }); err != nil {
|
|
t.Fatalf("register normalizer %q: %v", key, err)
|
|
}
|
|
}
|
|
for key, validator := range modules.validators {
|
|
validator := validator
|
|
spec := ValidatorSpec{Key: key, ExecutionClass: validator.ExecutionClass()}
|
|
if err := registries.Validators.RegisterWithSpec(spec, func() (contracts.Validator, error) { return validator, nil }); err != nil {
|
|
t.Fatalf("register validator %q: %v", key, err)
|
|
}
|
|
}
|
|
if err := registries.Outputs.Register("output", func() (contracts.OutputEncoder, error) { return modules.output, nil }); err != nil {
|
|
t.Fatalf("register output: %v", err)
|
|
}
|
|
return registries
|
|
}
|
|
|
|
type runnerInputAdapter struct {
|
|
key string
|
|
doc *source.SourceDocument
|
|
err error
|
|
manifestMetadata map[string]any
|
|
requests []contracts.ParseRequest
|
|
}
|
|
|
|
func (adapter *runnerInputAdapter) Key() string {
|
|
return adapter.key
|
|
}
|
|
|
|
func (adapter *runnerInputAdapter) Parse(ctx context.Context, req contracts.ParseRequest) (*source.SourceDocument, error) {
|
|
adapter.requests = append(adapter.requests, req)
|
|
return adapter.doc, adapter.err
|
|
}
|
|
|
|
func (adapter *runnerInputAdapter) ManifestMetadata() map[string]any {
|
|
return adapter.manifestMetadata
|
|
}
|
|
|
|
type runnerChunker struct {
|
|
key string
|
|
chunks []contracts.SourceChunk
|
|
warnings []contracts.Warning
|
|
err error
|
|
failureErr error
|
|
failuresBeforeSuccess int
|
|
callLLM bool
|
|
llmPromptID string
|
|
manifestMetadata map[string]any
|
|
requests []contracts.ChunkRequest
|
|
}
|
|
|
|
func (chunker *runnerChunker) Key() string {
|
|
return chunker.key
|
|
}
|
|
|
|
func (chunker *runnerChunker) ReferenceSlots() []contracts.ReferenceSlot {
|
|
return nil
|
|
}
|
|
|
|
func (chunker *runnerChunker) Chunk(ctx context.Context, req contracts.ChunkRequest) (contracts.ChunkResult, error) {
|
|
chunker.requests = append(chunker.requests, req)
|
|
if chunker.failuresBeforeSuccess > 0 {
|
|
chunker.failuresBeforeSuccess--
|
|
err := chunker.failureErr
|
|
if err == nil {
|
|
err = errors.New("transient chunk failure")
|
|
}
|
|
return contracts.ChunkResult{}, err
|
|
}
|
|
if chunker.callLLM && req.LLMClient != nil {
|
|
promptID := strings.TrimSpace(chunker.llmPromptID)
|
|
if promptID == "" {
|
|
promptID = "runner.chunk"
|
|
}
|
|
var out map[string]any
|
|
if _, err := req.LLMClient.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
|
StageName: chunker.key,
|
|
PromptID: promptID,
|
|
ProfileID: req.LLMProfile,
|
|
}, &out); err != nil {
|
|
return contracts.ChunkResult{}, err
|
|
}
|
|
}
|
|
return contracts.ChunkResult{
|
|
Chunks: chunker.chunks,
|
|
Warnings: chunker.warnings,
|
|
}, chunker.err
|
|
}
|
|
|
|
func (chunker *runnerChunker) ManifestMetadata() map[string]any {
|
|
return chunker.manifestMetadata
|
|
}
|
|
|
|
type runnerExtractor struct {
|
|
key string
|
|
manifestMetadata map[string]any
|
|
output *contracts.ExtractOutput
|
|
warnings []contracts.Warning
|
|
err error
|
|
failureErr error
|
|
failuresBeforeSuccess int
|
|
requests []contracts.ExtractionRequest
|
|
seenChunkIDs []string
|
|
seenLLMClients []contracts.StructuredLLMClient
|
|
seenMetadata []map[string]any
|
|
}
|
|
|
|
func (extractor *runnerExtractor) Key() string {
|
|
return extractor.key
|
|
}
|
|
|
|
func (extractor *runnerExtractor) ReferenceSlots() []contracts.ReferenceSlot {
|
|
return nil
|
|
}
|
|
|
|
func (extractor *runnerExtractor) ManifestMetadata() map[string]any {
|
|
return extractor.manifestMetadata
|
|
}
|
|
|
|
func (extractor *runnerExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) {
|
|
extractor.requests = append(extractor.requests, req)
|
|
if req.Chunk != nil {
|
|
extractor.seenChunkIDs = append(extractor.seenChunkIDs, req.Chunk.ID)
|
|
}
|
|
extractor.seenLLMClients = append(extractor.seenLLMClients, req.LLMClient)
|
|
extractor.seenMetadata = append(extractor.seenMetadata, req.Metadata)
|
|
|
|
if extractor.failuresBeforeSuccess > 0 {
|
|
extractor.failuresBeforeSuccess--
|
|
err := extractor.failureErr
|
|
if err == nil {
|
|
err = errors.New("transient extract failure")
|
|
}
|
|
return contracts.ExtractionResult{}, err
|
|
}
|
|
|
|
output := contracts.ExtractOutput{
|
|
Schema: contracts.ResponseSchema{ID: "runner.raw", Name: "runner_raw", Version: "v1"},
|
|
Payload: contracts.RawPayload{
|
|
Content: []byte(`{"value":true}`),
|
|
MediaType: "application/json",
|
|
},
|
|
}
|
|
if req.Chunk != nil {
|
|
output.Payload.Content = []byte(`{"chunk":"` + req.Chunk.ID + `"}`)
|
|
}
|
|
if extractor.output != nil {
|
|
output = *extractor.output
|
|
}
|
|
return contracts.ExtractionResult{
|
|
Output: output,
|
|
Warnings: extractor.warnings,
|
|
}, extractor.err
|
|
}
|
|
|
|
type runnerMerger struct {
|
|
key string
|
|
result *contracts.MergeOutput
|
|
warnings []contracts.Warning
|
|
err error
|
|
failureErr error
|
|
failuresBeforeSuccess int
|
|
requests []contracts.MergeRequest
|
|
}
|
|
|
|
func (merger *runnerMerger) Key() string {
|
|
return merger.key
|
|
}
|
|
|
|
func (merger *runnerMerger) Merge(ctx context.Context, req contracts.MergeRequest) (contracts.MergeResult, error) {
|
|
merger.requests = append(merger.requests, req)
|
|
if merger.failuresBeforeSuccess > 0 {
|
|
merger.failuresBeforeSuccess--
|
|
err := merger.failureErr
|
|
if err == nil {
|
|
err = errors.New("transient merge failure")
|
|
}
|
|
return contracts.MergeResult{}, err
|
|
}
|
|
output := contracts.MergeOutput{
|
|
LaneID: req.LaneID,
|
|
SourceID: req.Source.ID,
|
|
Schema: contracts.ResponseSchema{ID: "runner.raw", Name: "runner_raw", Version: "v1"},
|
|
Payload: contracts.RawPayload{
|
|
Content: []byte(`{"merged":true}`),
|
|
MediaType: "application/json",
|
|
},
|
|
}
|
|
if merger.result != nil {
|
|
output = *merger.result
|
|
}
|
|
return contracts.MergeResult{
|
|
Output: output,
|
|
Warnings: merger.warnings,
|
|
}, merger.err
|
|
}
|
|
|
|
type runnerNormalizer struct {
|
|
key string
|
|
result *contracts.NormalizeOutput
|
|
warnings []contracts.Warning
|
|
err error
|
|
failureErr error
|
|
failuresBeforeSuccess int
|
|
requests []contracts.NormalizeRequest
|
|
}
|
|
|
|
func (normalizer *runnerNormalizer) Key() string {
|
|
return normalizer.key
|
|
}
|
|
|
|
func (normalizer *runnerNormalizer) ReferenceSlots() []contracts.ReferenceSlot {
|
|
return nil
|
|
}
|
|
|
|
func (normalizer *runnerNormalizer) Normalize(ctx context.Context, req contracts.NormalizeRequest) (contracts.NormalizeResult, error) {
|
|
normalizer.requests = append(normalizer.requests, req)
|
|
if normalizer.failuresBeforeSuccess > 0 {
|
|
normalizer.failuresBeforeSuccess--
|
|
err := normalizer.failureErr
|
|
if err == nil {
|
|
err = errors.New("transient normalize failure")
|
|
}
|
|
return contracts.NormalizeResult{}, err
|
|
}
|
|
output := contracts.NormalizeOutput{
|
|
LaneID: req.LaneID,
|
|
SourceID: req.MergeOutput.SourceID,
|
|
Schema: req.MergeOutput.Schema,
|
|
Payload: req.MergeOutput.Payload,
|
|
}
|
|
if normalizer.result != nil {
|
|
output = *normalizer.result
|
|
}
|
|
return contracts.NormalizeResult{
|
|
Output: output,
|
|
Warnings: normalizer.warnings,
|
|
}, normalizer.err
|
|
}
|
|
|
|
type runnerValidator struct {
|
|
name string
|
|
executionClass contracts.ExecutionClass
|
|
approved []bool
|
|
reason string
|
|
message string
|
|
warnings []contracts.Warning
|
|
err error
|
|
order *[]string
|
|
calls int
|
|
requests []contracts.ValidationRequest
|
|
}
|
|
|
|
type runnerChainValidator struct {
|
|
name string
|
|
executionClass contracts.ExecutionClass
|
|
approved []bool
|
|
reason string
|
|
message string
|
|
warnings []contracts.Warning
|
|
err error
|
|
calls int
|
|
requests []contracts.ValidationRequest
|
|
}
|
|
|
|
func (validator *runnerChainValidator) Name() string {
|
|
return validator.name
|
|
}
|
|
|
|
func (validator *runnerChainValidator) ExecutionClass() contracts.ExecutionClass {
|
|
if validator.executionClass != "" {
|
|
return validator.executionClass
|
|
}
|
|
return contracts.ExecutionClassDeterministic
|
|
}
|
|
|
|
func (validator *runnerChainValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) {
|
|
validator.calls++
|
|
validator.requests = append(validator.requests, req)
|
|
if validator.err != nil {
|
|
return contracts.ValidationResult{}, validator.err
|
|
}
|
|
approved := true
|
|
if len(validator.approved) > 0 {
|
|
index := validator.calls - 1
|
|
if index >= len(validator.approved) {
|
|
index = len(validator.approved) - 1
|
|
}
|
|
approved = validator.approved[index]
|
|
}
|
|
return contracts.ValidationResult{
|
|
Approved: approved,
|
|
ReasonCode: validator.reason,
|
|
Message: validator.message,
|
|
Warnings: validator.warnings,
|
|
}, nil
|
|
}
|
|
|
|
func (validator *runnerValidator) Name() string {
|
|
return validator.name
|
|
}
|
|
|
|
func (validator *runnerValidator) ExecutionClass() contracts.ExecutionClass {
|
|
if validator.executionClass != "" {
|
|
return validator.executionClass
|
|
}
|
|
return contracts.ExecutionClassDeterministic
|
|
}
|
|
|
|
func (validator *runnerValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) {
|
|
validator.calls++
|
|
validator.requests = append(validator.requests, req)
|
|
if validator.order != nil {
|
|
*validator.order = append(*validator.order, validator.name)
|
|
}
|
|
approved := true
|
|
if len(validator.approved) > 0 {
|
|
index := validator.calls - 1
|
|
if index >= len(validator.approved) {
|
|
index = len(validator.approved) - 1
|
|
}
|
|
approved = validator.approved[index]
|
|
}
|
|
return contracts.ValidationResult{
|
|
Approved: approved,
|
|
ReasonCode: validator.reason,
|
|
Message: validator.message,
|
|
Warnings: validator.warnings,
|
|
}, validator.err
|
|
}
|
|
|
|
type runnerOutputEncoder struct {
|
|
key string
|
|
files []contracts.OutputFile
|
|
warnings []contracts.Warning
|
|
err error
|
|
manifestMetadata map[string]any
|
|
requests []contracts.OutputRequest
|
|
}
|
|
|
|
func (encoder *runnerOutputEncoder) Key() string {
|
|
return encoder.key
|
|
}
|
|
|
|
func (encoder *runnerOutputEncoder) Encode(ctx context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) {
|
|
encoder.requests = append(encoder.requests, req)
|
|
return contracts.OutputResult{
|
|
Files: encoder.files,
|
|
Warnings: encoder.warnings,
|
|
}, encoder.err
|
|
}
|
|
|
|
func (encoder *runnerOutputEncoder) ManifestMetadata() map[string]any {
|
|
return encoder.manifestMetadata
|
|
}
|
|
|
|
type runnerCheckpointLoader struct {
|
|
source SourceCheckpoint
|
|
chunk ChunkCheckpoint
|
|
extract ExtractCheckpoint
|
|
merge MergeCheckpoint
|
|
normalize NormalizeCheckpoint
|
|
reuse map[string]bool
|
|
}
|
|
|
|
func (loader *runnerCheckpointLoader) Enabled() bool {
|
|
return true
|
|
}
|
|
|
|
func (loader *runnerCheckpointLoader) Source(string) (SourceCheckpoint, CheckpointDecision) {
|
|
if loader.reuse["source"] {
|
|
return loader.source, CheckpointDecision{Reused: true, Reason: "test checkpoint"}
|
|
}
|
|
return SourceCheckpoint{}, CheckpointDecision{Reason: "test checkpoint missing"}
|
|
}
|
|
|
|
func (loader *runnerCheckpointLoader) Chunk(string, string) (ChunkCheckpoint, CheckpointDecision) {
|
|
if loader.reuse["chunk"] {
|
|
return loader.chunk, CheckpointDecision{Reused: true, Reason: "test checkpoint"}
|
|
}
|
|
return ChunkCheckpoint{}, CheckpointDecision{Reason: "test checkpoint missing"}
|
|
}
|
|
|
|
func (loader *runnerCheckpointLoader) Extract(string, string, []CheckpointFingerprint) (ExtractCheckpoint, CheckpointDecision) {
|
|
if loader.reuse["extract"] {
|
|
return loader.extract, CheckpointDecision{Reused: true, Reason: "test checkpoint"}
|
|
}
|
|
return ExtractCheckpoint{}, CheckpointDecision{Reason: "test checkpoint missing"}
|
|
}
|
|
|
|
func (loader *runnerCheckpointLoader) Merge(string, string, []CheckpointFingerprint) (MergeCheckpoint, CheckpointDecision) {
|
|
if loader.reuse["merge"] {
|
|
return loader.merge, CheckpointDecision{Reused: true, Reason: "test checkpoint"}
|
|
}
|
|
return MergeCheckpoint{}, CheckpointDecision{Reason: "test checkpoint missing"}
|
|
}
|
|
|
|
func (loader *runnerCheckpointLoader) Normalize(string, string, []CheckpointFingerprint) (NormalizeCheckpoint, CheckpointDecision) {
|
|
if loader.reuse["normalize"] {
|
|
return loader.normalize, CheckpointDecision{Reused: true, Reason: "test checkpoint"}
|
|
}
|
|
return NormalizeCheckpoint{}, CheckpointDecision{Reason: "test checkpoint missing"}
|
|
}
|
|
|
|
type fakeLLMClient struct{}
|
|
|
|
func (client fakeLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
|
return contracts.StructuredCompletionResponse{}, nil
|
|
}
|
|
|
|
type debugResponseLLMClient struct {
|
|
content []byte
|
|
profileID string
|
|
err error
|
|
}
|
|
|
|
func (client debugResponseLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
|
profileID := client.profileID
|
|
if profileID == "" {
|
|
profileID = req.ProfileID
|
|
}
|
|
return contracts.StructuredCompletionResponse{
|
|
Content: append([]byte(nil), client.content...),
|
|
Model: "debug-model",
|
|
ProfileID: profileID,
|
|
Debug: &contracts.LLMDebugMaterial{
|
|
Prompt: &contracts.LLMDebugPrompt{
|
|
PromptID: req.PromptID,
|
|
SelectedProfileID: profileID,
|
|
Messages: []contracts.LLMDebugMessage{
|
|
{Role: "user", Content: "raw prompt text"},
|
|
},
|
|
},
|
|
Response: &contracts.LLMDebugResponse{
|
|
Content: string(client.content),
|
|
SelectedProfileID: profileID,
|
|
ModelName: "debug-model",
|
|
},
|
|
},
|
|
}, client.err
|
|
}
|
|
|
|
type memoryDebugRecorder struct {
|
|
payloads map[string]any
|
|
bytes map[string][]byte
|
|
}
|
|
|
|
func newMemoryDebugRecorder() *memoryDebugRecorder {
|
|
return &memoryDebugRecorder{
|
|
payloads: map[string]any{},
|
|
bytes: map[string][]byte{},
|
|
}
|
|
}
|
|
|
|
func (recorder *memoryDebugRecorder) Enabled() bool { return true }
|
|
|
|
func (recorder *memoryDebugRecorder) WriteJSON(name string, payload any) error {
|
|
recorder.payloads[name] = payload
|
|
return nil
|
|
}
|
|
|
|
func (recorder *memoryDebugRecorder) WriteBytes(name string, data []byte) error {
|
|
recorder.bytes[name] = append([]byte(nil), data...)
|
|
return nil
|
|
}
|
|
|
|
func (recorder *memoryDebugRecorder) envelope(t *testing.T, name string) debugTimedEnvelope {
|
|
t.Helper()
|
|
payload, ok := recorder.payloads[name]
|
|
if !ok {
|
|
t.Fatalf("debug artifact %q not written; got %#v", name, recorder.payloads)
|
|
}
|
|
envelope, ok := payload.(debugTimedEnvelope)
|
|
if !ok {
|
|
t.Fatalf("debug artifact %q type = %T, want debugTimedEnvelope", name, payload)
|
|
}
|
|
return envelope
|
|
}
|
|
|
|
type manifestReportingLLMClient struct {
|
|
fakeLLMClient
|
|
profiles []artifacts.LLMProfileManifest
|
|
}
|
|
|
|
func (client manifestReportingLLMClient) LLMProfileManifests() []artifacts.LLMProfileManifest {
|
|
return append([]artifacts.LLMProfileManifest(nil), client.profiles...)
|
|
}
|
|
|
|
func validSourceDocument() *source.SourceDocument {
|
|
return &source.SourceDocument{
|
|
ID: "source-1",
|
|
Kind: "document",
|
|
Format: "text/plain",
|
|
Digest: "sha256:source",
|
|
Units: []source.SourceUnit{
|
|
{ID: 1, Kind: "unit", Text: "Source unit."},
|
|
{ID: 2, Kind: "unit", Text: "Second source unit."},
|
|
{ID: 3, Kind: "unit", Text: "Third source unit."},
|
|
},
|
|
}
|
|
}
|
|
|
|
func sourceDocumentWithUnitMetadata() *source.SourceDocument {
|
|
return &source.SourceDocument{
|
|
ID: "source-1",
|
|
Kind: "document",
|
|
Format: "text/plain",
|
|
Digest: "sha256:source",
|
|
Units: []source.SourceUnit{
|
|
{
|
|
ID: 1,
|
|
Kind: "source-kind",
|
|
Text: "source text",
|
|
Metadata: map[string]any{
|
|
"speaker": "source-speaker",
|
|
"topic": "source-topic",
|
|
},
|
|
},
|
|
{
|
|
ID: 2,
|
|
Kind: "source-kind",
|
|
Text: "second source text",
|
|
Metadata: map[string]any{
|
|
"speaker": "source-speaker-2",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func sourceChunkWithID(id string, index int) contracts.SourceChunk {
|
|
unit := unitWithID("u1")
|
|
return contracts.SourceChunk{
|
|
ID: id,
|
|
SourceID: "source-1",
|
|
Index: index,
|
|
StartUnitID: unit.ID,
|
|
EndUnitID: unit.ID,
|
|
Content: []byte(`{"units":[{"id":1,"kind":"unit","text":"Source unit."}]}`),
|
|
MediaType: "application/json",
|
|
Units: []source.SourceUnit{unit},
|
|
}
|
|
}
|
|
|
|
func sourceChunkWithContent(id string, index int, content []byte, mediaType string) contracts.SourceChunk {
|
|
chunk := sourceChunkWithID(id, index)
|
|
chunk.Content = append([]byte(nil), content...)
|
|
chunk.MediaType = mediaType
|
|
return chunk
|
|
}
|
|
|
|
func unitWithID(id string) source.SourceUnit {
|
|
switch id {
|
|
case "u1":
|
|
return source.SourceUnit{ID: 1, Kind: "unit", Text: "Source unit."}
|
|
case "u2":
|
|
return source.SourceUnit{ID: 2, Kind: "unit", Text: "Second source unit."}
|
|
case "u3":
|
|
return source.SourceUnit{ID: 3, Kind: "unit", Text: "Third source unit."}
|
|
case "u9":
|
|
return source.SourceUnit{ID: 9, Kind: "unit", Text: "Unknown source unit."}
|
|
default:
|
|
return source.SourceUnit{ID: 99, Kind: "unit", Text: "Unknown source unit."}
|
|
}
|
|
}
|
|
|
|
func chunkWithUnits(id string, sourceID string, index int, units ...source.SourceUnit) contracts.SourceChunk {
|
|
startUnitID, endUnitID := 1, 1
|
|
if len(units) > 0 {
|
|
startUnitID = units[0].ID
|
|
endUnitID = units[len(units)-1].ID
|
|
}
|
|
return chunkWithBounds(id, sourceID, index, startUnitID, endUnitID, units...)
|
|
}
|
|
|
|
func chunkWithBounds(id string, sourceID string, index int, startUnitID int, endUnitID int, units ...source.SourceUnit) contracts.SourceChunk {
|
|
return contracts.SourceChunk{
|
|
ID: id,
|
|
SourceID: sourceID,
|
|
Index: index,
|
|
StartUnitID: startUnitID,
|
|
EndUnitID: endUnitID,
|
|
Content: []byte(`{"units":[1]}`),
|
|
MediaType: "application/json",
|
|
Units: append([]source.SourceUnit(nil), units...),
|
|
}
|
|
}
|
|
|
|
func warningReasons(warnings []contracts.Warning) []string {
|
|
reasons := make([]string, 0, len(warnings))
|
|
for _, warning := range warnings {
|
|
reasons = append(reasons, warning.ReasonCode)
|
|
}
|
|
return reasons
|
|
}
|
|
|
|
func assertNoCheckpointMetadata(t *testing.T, metadata map[string]any) {
|
|
t.Helper()
|
|
|
|
for key, value := range metadata {
|
|
lowerKey := strings.ToLower(key)
|
|
if strings.Contains(lowerKey, "checkpoint") || strings.Contains(lowerKey, "workspace") {
|
|
t.Fatalf("metadata key %q exposes checkpoint/workspace state", key)
|
|
}
|
|
text, ok := value.(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
lowerValue := strings.ToLower(text)
|
|
if strings.Contains(lowerValue, "checkpoint") || strings.Contains(lowerValue, "workspace") {
|
|
t.Fatalf("metadata value for %q exposes checkpoint/workspace state: %q", key, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertRunError(t *testing.T, err error, want string) {
|
|
t.Helper()
|
|
|
|
if err == nil {
|
|
t.Fatal("Run() error = nil, want error")
|
|
}
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("Run() error = %q, want substring %q", err.Error(), want)
|
|
}
|
|
}
|
|
|
|
func resolvedValidatorForTest(validator contracts.Validator) ResolvedValidator {
|
|
return ResolvedValidator{
|
|
Binding: Binding(validator.Name()),
|
|
ExecutionClass: validator.ExecutionClass(),
|
|
}
|
|
}
|
|
|
|
func setResolvedValidatorChain(t *testing.T, resolved *ResolvedPipeline, stage ModuleStage, laneID string, module string, validators ...ResolvedValidator) {
|
|
t.Helper()
|
|
|
|
if resolved == nil {
|
|
t.Fatal("resolved pipeline must not be nil")
|
|
}
|
|
chain := ResolvedValidatorChain{
|
|
Stage: stage,
|
|
LaneID: laneID,
|
|
ModuleKey: module,
|
|
Validators: append([]ResolvedValidator(nil), validators...),
|
|
}
|
|
for index := range resolved.ValidatorChains {
|
|
existing := resolved.ValidatorChains[index]
|
|
if existing.Stage == stage && existing.LaneID == laneID && existing.ModuleKey == module {
|
|
resolved.ValidatorChains[index] = chain
|
|
return
|
|
}
|
|
}
|
|
resolved.ValidatorChains = append(resolved.ValidatorChains, chain)
|
|
}
|