diff --git a/docs/cli.md b/docs/cli.md index 0374f38..e4d9cff 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -60,10 +60,11 @@ Reference flags are resolved against selected chunk, extractor, and normalizer targets before the run starts. Flat slot names are accepted only when exactly one selected target declares that slot. Bound reference files are read before pipeline work starts, validated as UTF-8 text, and recorded as provenance for -the target that declares the slot. Runtime reference content is currently passed -only to lane extractors. Notarius infers reference media types from file -extensions for provenance and for optional slot checks. Reference content is not -written to diagnostics, logs, errors, or manifests. +the target that declares the slot. Runtime reference content is passed to the +chunker, extractor, or normalizer target that declares the slot. Notarius infers +reference media types from file extensions for provenance and for optional slot +checks. Reference content is not written to diagnostics, logs, errors, or +manifests. Reference binding precedence is: diff --git a/docs/config.md b/docs/config.md index 417633c..b3eaf7d 100644 --- a/docs/config.md +++ b/docs/config.md @@ -149,10 +149,10 @@ compatibility bindings, and run-time `--reference` or `--without-reference` overrides are applied. Config-relative paths are resolved relative to the config file; CLI reference paths are resolved relative to the current working directory. Materialized bound files must be UTF-8 text. Materialized reference -provenance is recorded for chunk, extractor, and normalizer targets; runtime -reference content is currently passed only to lane extractors that declare the -slot. Reference media types are inferred from file extensions, recorded as -canonical base media types, and checked only when a module declares +provenance is recorded for chunk, extractor, and normalizer targets, and runtime +reference content is passed to the target that declares the slot. Reference +media types are inferred from file extensions, recorded as canonical base media +types, and checked only when a module declares `AcceptedMediaTypes`; unknown extensions are recorded as `application/octet-stream`. Reference content is not written to diagnostics, logs, errors, or manifests. diff --git a/docs/internal/modules.md b/docs/internal/modules.md index b1afb73..30636b7 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -32,14 +32,15 @@ compares the canonical base media type inferred from the file extension, case-insensitively and without parameters. The resolver materializes reference content for chunk, extractor, and -normalizer targets. Runtime delivery is currently implemented only for lane -extractors through `contracts.ExtractionRequest.References`. Reference material -is not source evidence and must not be converted into `SourceRef` values. If a -module prompt uses references, load the prompt bundle with the same declared -slots and render with `RenderUserSystemWithReferences`. Prompt templates may use -the `reference` function for content and the `hasreference` function for -conditional sections. Prompt metadata hashes remain based on template source, -not rendered reference bytes. +normalizer targets. Runtime delivery uses `contracts.ChunkRequest.References`, +`contracts.ExtractionRequest.References`, and +`contracts.NormalizeRequest.References`. Reference material is not source +evidence and must not be converted into `SourceRef` values. If a module prompt +uses references, load the prompt bundle with the same declared slots and render +with `RenderUserSystemWithReferences`. Prompt templates may use the `reference` +function for content and the `hasreference` function for conditional sections. +Prompt metadata hashes remain based on template source, not rendered reference +bytes. Chunk modules receive the structured LLM client through `contracts.ChunkRequest` when they need model-backed chunking. The pipeline runner validates generic diff --git a/docs/internal/pipeline.md b/docs/internal/pipeline.md index 1062323..573b91e 100644 --- a/docs/internal/pipeline.md +++ b/docs/internal/pipeline.md @@ -53,8 +53,8 @@ empty bound files. Media-type acceptance is checked only when a slot declares `application/octet-stream`. Reference content is omitted from diagnostics and manifests. The CLI writes provenance-only resolved reference diagnostics, and the run manifest records target-stage reference provenance separately from -source digests. Runtime reference content is currently passed only to the -matching lane extractor through `ExtractionRequest`. +source digests. Runtime reference content is passed to the matching chunker, +extractor, or normalizer request. Prompt bundles can declare reference slots and use `reference` and `hasreference` template functions. Bundle loading validates string-literal slot diff --git a/internal/framework/pipeline/runner.go b/internal/framework/pipeline/runner.go index 6a6d5ff..3f169ef 100644 --- a/internal/framework/pipeline/runner.go +++ b/internal/framework/pipeline/runner.go @@ -95,6 +95,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (RunOutput, error) { attachModuleManifestMetadata(&output, "chunker", chunker) chunkResult, err := chunker.Chunk(ctx, contracts.ChunkRequest{ Source: doc, + References: CloneReferenceSet(input.Pipeline.ChunkReferences.ReferenceSet), LLMClient: input.LLMClient, LLMProfile: input.Pipeline.Chunk.LLMProfile, Options: cloneOptions(input.Pipeline.Chunk.Options), @@ -224,6 +225,7 @@ func (r *Runner) runLane(ctx context.Context, input RunInput, doc *source.Source Source: doc, LaneID: lane.ID, Candidates: mergeResult.Candidates, + References: CloneReferenceSet(lane.NormalizeReferences.ReferenceSet), LLMClient: input.LLMClient, LLMProfile: lane.Normalize.LLMProfile, Options: cloneOptions(lane.Normalize.Options), diff --git a/internal/framework/pipeline/runner_test.go b/internal/framework/pipeline/runner_test.go index ba516c9..bc01218 100644 --- a/internal/framework/pipeline/runner_test.go +++ b/internal/framework/pipeline/runner_test.go @@ -566,24 +566,7 @@ func TestRunPassesModuleBindingConfigToStageRequests(t *testing.T) { func TestRunPassesLaneReferencesToExtractorRequests(t *testing.T) { modules := defaultRunnerModules() pipeline := resolvedPipeline() - pipeline.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 text"), - Digest: "sha256:test", - Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/reference.txt"}, - SizeBytes: int64(len("reference text")), - BindingSource: contracts.ReferenceBindingSourceConfig, - }, - }, - }, - }, - } + pipeline.ArtifactLanes[0].ExtractReferences.ReferenceSet = testReferenceSet("roster", "reference text") _, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline}) if err != nil { @@ -601,6 +584,55 @@ func TestRunPassesLaneReferencesToExtractorRequests(t *testing.T) { } } +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 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"} @@ -1235,6 +1267,27 @@ func resolvedPipelineWithValidators(validators ...string) ResolvedPipeline { 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 diff --git a/internal/framework/pipeline/walking_skeleton_test.go b/internal/framework/pipeline/walking_skeleton_test.go index 9c617d0..fa3c9ac 100644 --- a/internal/framework/pipeline/walking_skeleton_test.go +++ b/internal/framework/pipeline/walking_skeleton_test.go @@ -43,8 +43,8 @@ func TestWalkingSkeletonFixture(t *testing.T) { t.Fatalf("ContentType = %q, want application/json", output.OutputFiles[0].ContentType) } assertStructuralJSONEqual(t, output.OutputFiles[0].Bytes, expectedBytes) - if llmClient.calls != 2 { - t.Fatalf("LLM calls = %d, want chunk count 2", llmClient.calls) + if llmClient.calls != 3 { + t.Fatalf("LLM calls = %d, want extractor calls plus normalizer call", llmClient.calls) } } @@ -345,6 +345,15 @@ func (normalizer walkingSkeletonNormalizer) ReferenceSlots() []contracts.Referen } func (normalizer walkingSkeletonNormalizer) Normalize(ctx context.Context, req contracts.NormalizeRequest) (contracts.NormalizeResult, error) { + var response struct { + Call int `json:"call"` + } + if _, err := req.LLMClient.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ + StageName: "fake/normalize", + ResponseSchemaName: "fake_normalize", + }, &response); err != nil { + return contracts.NormalizeResult{}, err + } return contracts.NormalizeResult{Candidates: req.Candidates}, nil }