Update raw output files and manifests

This commit is contained in:
2026-07-07 19:27:28 +00:00
parent aa14faa3cb
commit 7c95791e94
10 changed files with 253 additions and 51 deletions

View File

@@ -21,7 +21,7 @@ merge output through unchanged.
## Output Shape ## Output Shape
For a single chunk, `outputs/spells.json` has this shape: For a single chunk, `lanes/spells.json` has this shape:
```json ```json
{ {

View File

@@ -20,7 +20,7 @@ The `json` output module writes:
- `index.json` - `index.json`
- `manifest.json` - `manifest.json`
- `outputs/<lane-id>.json`, one file per normalized raw lane output - `lanes/<lane-id>.json`, one file per normalized raw lane output
- `rejected.json` - `rejected.json`
- `warnings.json` - `warnings.json`
@@ -37,7 +37,7 @@ Shape:
{ {
"lane_id": "spells", "lane_id": "spells",
"media_type": "application/json", "media_type": "application/json",
"file": "outputs/spells.json", "file": "lanes/spells.json",
"module_key": "noop", "module_key": "noop",
"schema_id": "notarius.dnd.spells", "schema_id": "notarius.dnd.spells",
"schema_name": "notarius_dnd_spells_v1", "schema_name": "notarius_dnd_spells_v1",
@@ -49,7 +49,7 @@ Shape:
} }
``` ```
`output_files` is in normalized output order. Output file names are produced by `output_files` is sorted by lane ID. Output file names are produced by
sanitizing the lane ID: sanitizing the lane ID:
- characters outside `A-Z`, `a-z`, `0-9`, `.`, `_`, and `-` become `_`; - characters outside `A-Z`, `a-z`, `0-9`, `.`, `_`, and `-` become `_`;
@@ -103,14 +103,21 @@ references.
`validation_status` is `approved` when no raw outputs were rejected and `validation_status` is `approved` when no raw outputs were rejected and
`rejected` when one or more raw outputs were rejected. `rejected` when one or more raw outputs were rejected.
`normalized_outputs` summarizes each normalized lane output without embedding
payload bytes. Entries include lane ID, normalizer module key, source ID, media
type, and response schema provenance where available.
`rejected_outputs` summarizes rejected module outputs without embedding raw
payload bytes. Entries include stage, lane, module, chunk, validator or reason,
message, attempt count, and optional diagnostic artifact path.
## Output Payload Files ## Output Payload Files
Each normalized raw output is written to `outputs/<sanitized-lane-id>.json`. Each normalized raw output is written to `lanes/<sanitized-lane-id>.json`.
For `application/json` payloads, the file contains the raw JSON payload The JSON output encoder accepts only `application/json` normalized outputs. The
pretty-printed. Other media types are written as raw bytes with the media type file contains the raw JSON payload pretty-printed.
reported in `index.json`.
For the current D&D spell extractor, `outputs/spells.json` has this shape: For the current D&D spell extractor, `lanes/spells.json` has this shape:
```json ```json
{ {

View File

@@ -221,7 +221,8 @@ Package: `internal/modules/output/json`
The `json` output encoder converts normalized raw outputs, rejected raw outputs, The `json` output encoder converts normalized raw outputs, rejected raw outputs,
warnings, and the run manifest into logical JSON output files. It writes one warnings, and the run manifest into logical JSON output files. It writes one
payload file per lane under `outputs/` and sanitizes lane IDs for file names. payload file per lane under `lanes/` and sanitizes lane IDs for file names.
Normalized output payloads must be valid `application/json`.
Requires: Requires:

View File

@@ -37,8 +37,8 @@ The `json` output module writes these files:
- `manifest.json`: run manifest with resolved pipeline provenance, top-level - `manifest.json`: run manifest with resolved pipeline provenance, top-level
module metadata, module keys, reference provenance, validation status, and module metadata, module keys, reference provenance, validation status, and
timing. timing.
- `outputs/<lane-id>.json`: normalized raw output payloads, one file per lane. - `lanes/<lane-id>.json`: normalized raw JSON output payloads, one file per
For the current D&D spell extractor, this includes `outputs/spells.json`. lane. For the current D&D spell extractor, this includes `lanes/spells.json`.
- `rejected.json`: rejected raw output records. - `rejected.json`: rejected raw output records.
- `warnings.json`: warnings reported by pipeline modules or the output encoder. - `warnings.json`: warnings reported by pipeline modules or the output encoder.

View File

@@ -1732,8 +1732,8 @@ func TestRunPipelineWritesDurableOutputFiles(t *testing.T) {
runOutputDir := onlyChildDir(t, outputDir) runOutputDir := onlyChildDir(t, outputDir)
for _, name := range []string{ for _, name := range []string{
"index.json", "index.json",
"lanes/spells.json",
"manifest.json", "manifest.json",
"outputs/spells.json",
"rejected.json", "rejected.json",
"warnings.json", "warnings.json",
} { } {
@@ -2086,7 +2086,7 @@ func TestExampleFixtureRunWritesExpectedJSON(t *testing.T) {
SourceRefs []source.SourceRef `json:"source_refs"` SourceRefs []source.SourceRef `json:"source_refs"`
} `json:"spell_casts"` } `json:"spell_casts"`
} }
readJSONFile(t, filepath.Join(runOutputDir, "outputs", "spells.json"), &spellOutput) readJSONFile(t, filepath.Join(runOutputDir, "lanes", "spells.json"), &spellOutput)
if len(spellOutput.SpellCasts) != 1 { if len(spellOutput.SpellCasts) != 1 {
t.Fatalf("spell output = %#v, want one spell cast", spellOutput) t.Fatalf("spell output = %#v, want one spell cast", spellOutput)
} }

View File

@@ -60,26 +60,55 @@ type ReferenceProvenance struct {
BindingSource string `json:"binding_source,omitempty"` BindingSource string `json:"binding_source,omitempty"`
} }
type OutputSchemaProvenance struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Version string `json:"version,omitempty"`
}
type NormalizedOutputManifest struct {
LaneID string `json:"lane_id"`
ModuleKey string `json:"module_key,omitempty"`
SourceID string `json:"source_id,omitempty"`
MediaType string `json:"media_type,omitempty"`
Schema OutputSchemaProvenance `json:"schema,omitempty"`
}
type RejectedOutputManifest struct {
Stage string `json:"stage"`
LaneID string `json:"lane_id,omitempty"`
ModuleKey string `json:"module_key,omitempty"`
ChunkID string `json:"chunk_id,omitempty"`
ChunkIndex int `json:"chunk_index,omitempty"`
ValidatorName string `json:"validator_name,omitempty"`
ReasonCode string `json:"reason_code,omitempty"`
Message string `json:"message,omitempty"`
AttemptCount int `json:"attempt_count,omitempty"`
DiagnosticArtifactPath string `json:"diagnostic_artifact_path,omitempty"`
}
type RunManifest struct { type RunManifest struct {
RunID string `json:"run_id,omitempty"` RunID string `json:"run_id,omitempty"`
PipelineID string `json:"pipeline_id,omitempty"` PipelineID string `json:"pipeline_id,omitempty"`
PipelineDigest string `json:"pipeline_digest,omitempty"` PipelineDigest string `json:"pipeline_digest,omitempty"`
InputModule string `json:"input_module,omitempty"` InputModule string `json:"input_module,omitempty"`
Chunker string `json:"chunker,omitempty"` Chunker string `json:"chunker,omitempty"`
SourceDigests []string `json:"source_digests,omitempty"` SourceDigests []string `json:"source_digests,omitempty"`
Extractors []string `json:"extractors,omitempty"` Extractors []string `json:"extractors,omitempty"`
Merger string `json:"merger,omitempty"` Merger string `json:"merger,omitempty"`
Normalizer string `json:"normalizer,omitempty"` Normalizer string `json:"normalizer,omitempty"`
OutputEncoder string `json:"output_encoder,omitempty"` OutputEncoder string `json:"output_encoder,omitempty"`
ModuleMetadata map[string]map[string]any `json:"module_metadata,omitempty"` ModuleMetadata map[string]map[string]any `json:"module_metadata,omitempty"`
ArtifactLanes []ArtifactLaneManifest `json:"artifact_lanes,omitempty"` ArtifactLanes []ArtifactLaneManifest `json:"artifact_lanes,omitempty"`
References []ReferenceProvenance `json:"references,omitempty"` References []ReferenceProvenance `json:"references,omitempty"`
LLMProfiles []LLMProfileManifest `json:"llm_profiles,omitempty"` NormalizedOutputs []NormalizedOutputManifest `json:"normalized_outputs,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"` RejectedOutputs []RejectedOutputManifest `json:"rejected_outputs,omitempty"`
SchemaVersion string `json:"schema_version,omitempty"` LLMProfiles []LLMProfileManifest `json:"llm_profiles,omitempty"`
ValidationStatus string `json:"validation_status,omitempty"` Metadata map[string]any `json:"metadata,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"` SchemaVersion string `json:"schema_version,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"` ValidationStatus string `json:"validation_status,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
} }
func ArtifactFromCandidate(candidate ArtifactCandidate) Artifact { func ArtifactFromCandidate(candidate ArtifactCandidate) Artifact {

View File

@@ -157,6 +157,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
} else { } else {
output.Manifest.ValidationStatus = "approved" output.Manifest.ValidationStatus = "approved"
} }
populateRawOutputManifest(&output)
output.Manifest.CompletedAt = timePtr(time.Now().UTC()) output.Manifest.CompletedAt = timePtr(time.Now().UTC())
encoder, err := r.registries.Outputs.Build(input.Pipeline.Output.Module) encoder, err := r.registries.Outputs.Build(input.Pipeline.Output.Module)
@@ -636,12 +637,64 @@ func manifestFromPipeline(input RunInput) artifacts.RunManifest {
func failOutput(output RunOutput) RunOutput { func failOutput(output RunOutput) RunOutput {
if output.Manifest.PipelineID != "" { if output.Manifest.PipelineID != "" {
populateRawOutputManifest(&output)
output.Manifest.ValidationStatus = "failed" output.Manifest.ValidationStatus = "failed"
output.Manifest.CompletedAt = timePtr(time.Now().UTC()) output.Manifest.CompletedAt = timePtr(time.Now().UTC())
} }
return output return output
} }
func populateRawOutputManifest(output *RunOutput) {
if output == nil {
return
}
output.Manifest.NormalizedOutputs = normalizedOutputManifests(output.NormalizeOutputs)
output.Manifest.RejectedOutputs = rejectedOutputManifests(output.Rejected)
}
func normalizedOutputManifests(outputs []contracts.NormalizeOutput) []artifacts.NormalizedOutputManifest {
if len(outputs) == 0 {
return nil
}
manifests := make([]artifacts.NormalizedOutputManifest, 0, len(outputs))
for _, output := range outputs {
manifests = append(manifests, artifacts.NormalizedOutputManifest{
LaneID: output.LaneID,
ModuleKey: output.NormalizerKey,
SourceID: output.SourceID,
MediaType: output.Payload.MediaType,
Schema: artifacts.OutputSchemaProvenance{
ID: output.Schema.ID,
Name: output.Schema.Name,
Version: output.Schema.Version,
},
})
}
return manifests
}
func rejectedOutputManifests(rejected []contracts.RejectedOutput) []artifacts.RejectedOutputManifest {
if len(rejected) == 0 {
return nil
}
manifests := make([]artifacts.RejectedOutputManifest, 0, len(rejected))
for _, output := range rejected {
manifests = append(manifests, artifacts.RejectedOutputManifest{
Stage: output.Stage,
LaneID: output.LaneID,
ModuleKey: output.ModuleKey,
ChunkID: output.ChunkID,
ChunkIndex: output.ChunkIndex,
ValidatorName: output.ValidatorName,
ReasonCode: output.ReasonCode,
Message: output.Message,
AttemptCount: output.AttemptCount,
DiagnosticArtifactPath: output.DiagnosticArtifactPath,
})
}
return manifests
}
func setLaneManifestMetadata(output *RunOutput, laneID string, modules ...any) { func setLaneManifestMetadata(output *RunOutput, laneID string, modules ...any) {
if output == nil { if output == nil {
return return

View File

@@ -1034,6 +1034,12 @@ func TestRunStopsRetryAfterConfiguredAttemptsAndRecordsAttemptCount(t *testing.T
if len(modules.extractors["extract-alpha"].requests) != 4 { if len(modules.extractors["extract-alpha"].requests) != 4 {
t.Fatalf("extract requests = %d, want two attempts per chunk", len(modules.extractors["extract-alpha"].requests)) 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) { func TestRunContextCancellationStopsRetries(t *testing.T) {
@@ -1292,6 +1298,13 @@ func TestRunManifestIncludesPipelineAndLaneDetails(t *testing.T) {
if manifest.ValidationStatus != "approved" { if manifest.ValidationStatus != "approved" {
t.Fatalf("ValidationStatus = %q, want approved", manifest.ValidationStatus) 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 { if len(manifest.ArtifactLanes) != 1 {
t.Fatalf("len(ArtifactLanes) = %d, want 1", len(manifest.ArtifactLanes)) t.Fatalf("len(ArtifactLanes) = %d, want 1", len(manifest.ArtifactLanes))
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
stdjson "encoding/json" stdjson "encoding/json"
"fmt" "fmt"
"mime"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
@@ -158,27 +159,36 @@ func rawOutputFile(name string, payload contracts.RawPayload) (contracts.OutputF
if len(content) == 0 { if len(content) == 0 {
content = []byte("null") content = []byte("null")
} }
if payload.MediaType == contentTypeJSON && stdjson.Valid(content) {
var decoded any
if err := stdjson.Unmarshal(content, &decoded); err == nil {
pretty, err := marshalPretty(decoded)
if err != nil {
return contracts.OutputFile{}, err
}
content = pretty
}
}
mediaType := strings.TrimSpace(payload.MediaType) mediaType := strings.TrimSpace(payload.MediaType)
if mediaType == "" { if mediaType == "" {
mediaType = "application/octet-stream" mediaType = "application/octet-stream"
} }
if !isJSONMediaType(mediaType) {
return contracts.OutputFile{}, encoderErrorf("normalized output %q has unsupported media type %q", name, mediaType)
}
var decoded any
if err := stdjson.Unmarshal(content, &decoded); err != nil {
return contracts.OutputFile{}, encoderErrorf("normalized output %q contains invalid JSON: %w", name, err)
}
pretty, err := marshalPretty(decoded)
if err != nil {
return contracts.OutputFile{}, err
}
return contracts.OutputFile{ return contracts.OutputFile{
Name: name, Name: name,
ContentType: mediaType, ContentType: mediaType,
Bytes: append([]byte(nil), content...), Bytes: pretty,
}, nil }, nil
} }
func isJSONMediaType(mediaType string) bool {
base, _, err := mime.ParseMediaType(strings.TrimSpace(mediaType))
if err != nil {
base = strings.TrimSpace(mediaType)
}
return strings.EqualFold(base, contentTypeJSON)
}
func jsonFile(name string, value any) (contracts.OutputFile, error) { func jsonFile(name string, value any) (contracts.OutputFile, error) {
data, err := marshalPretty(value) data, err := marshalPretty(value)
if err != nil { if err != nil {
@@ -208,7 +218,7 @@ func outputFileName(laneID string) (string, error) {
if sanitized == "" { if sanitized == "" {
return "", encoderErrorf("lane id %q cannot produce a safe file name", laneID) return "", encoderErrorf("lane id %q cannot produce a safe file name", laneID)
} }
return "outputs/" + sanitized + ".json", nil return "lanes/" + sanitized + ".json", nil
} }
func cloneNormalizeOutputs(outputs []contracts.NormalizeOutput) []contracts.NormalizeOutput { func cloneNormalizeOutputs(outputs []contracts.NormalizeOutput) []contracts.NormalizeOutput {

View File

@@ -66,9 +66,9 @@ func TestEncodeReturnsLogicalFilesForNormalizedOutputs(t *testing.T) {
wantNames := []string{ wantNames := []string{
"index.json", "index.json",
"lanes/notes_items.json",
"lanes/spells.json",
"manifest.json", "manifest.json",
"outputs/notes_items.json",
"outputs/spells.json",
"rejected.json", "rejected.json",
"warnings.json", "warnings.json",
} }
@@ -85,7 +85,7 @@ func TestEncodeReturnsLogicalFilesForNormalizedOutputs(t *testing.T) {
} }
} }
spells := decodeObject(t, fileBytes(t, result.Files, "outputs/spells.json")) spells := decodeObject(t, fileBytes(t, result.Files, "lanes/spells.json"))
spellCasts := spells["spell_casts"].([]any) spellCasts := spells["spell_casts"].([]any)
if spellCasts[0].(map[string]any)["spell"] != "Cure Wounds" { if spellCasts[0].(map[string]any)["spell"] != "Cure Wounds" {
t.Fatalf("spells output = %#v, want raw normalized content", spells) t.Fatalf("spells output = %#v, want raw normalized content", spells)
@@ -177,6 +177,58 @@ func TestEncodeIncludesManifestReferences(t *testing.T) {
} }
} }
func TestEncodeIncludesManifestRawOutputProvenance(t *testing.T) {
result, err := New().Encode(context.Background(), contracts.OutputRequest{
Manifest: artifacts.RunManifest{
RunID: "run-1",
NormalizedOutputs: []artifacts.NormalizedOutputManifest{
{
LaneID: "spells",
ModuleKey: "noop",
SourceID: "source-1",
MediaType: contentTypeJSON,
Schema: artifacts.OutputSchemaProvenance{
ID: "schema-id",
Name: "schema-name",
Version: "v1",
},
},
},
RejectedOutputs: []artifacts.RejectedOutputManifest{
{
Stage: "extract",
LaneID: "spells",
ModuleKey: "dnd/spells",
ChunkID: "chunk-0",
ReasonCode: "raw_output_rejected",
AttemptCount: 2,
},
},
},
})
if err != nil {
t.Fatalf("Encode() error = %v, want nil", err)
}
manifest := decodeObject(t, fileBytes(t, result.Files, "manifest.json"))
normalized := manifest["normalized_outputs"].([]any)
if len(normalized) != 1 {
t.Fatalf("normalized_outputs = %#v, want one entry", normalized)
}
normalizedEntry := normalized[0].(map[string]any)
if normalizedEntry["lane_id"] != "spells" || normalizedEntry["media_type"] != contentTypeJSON {
t.Fatalf("normalized output manifest = %#v, want lane and media type", normalizedEntry)
}
rejected := manifest["rejected_outputs"].([]any)
if len(rejected) != 1 {
t.Fatalf("rejected_outputs = %#v, want one entry", rejected)
}
rejectedEntry := rejected[0].(map[string]any)
if rejectedEntry["attempt_count"] != float64(2) || rejectedEntry["chunk_id"] != "chunk-0" {
t.Fatalf("rejected output manifest = %#v, want attempt count and chunk", rejectedEntry)
}
}
func TestEncodeRejectsLaneIDWithoutSafeFileName(t *testing.T) { func TestEncodeRejectsLaneIDWithoutSafeFileName(t *testing.T) {
_, err := New().Encode(context.Background(), contracts.OutputRequest{ _, err := New().Encode(context.Background(), contracts.OutputRequest{
NormalizeOutputs: []contracts.NormalizeOutput{normalizeOutput("///", `{"value":true}`)}, NormalizeOutputs: []contracts.NormalizeOutput{normalizeOutput("///", `{"value":true}`)},
@@ -197,11 +249,48 @@ func TestEncodeSanitizesParentPathSequences(t *testing.T) {
t.Fatalf("Encode() error = %v, want nil", err) t.Fatalf("Encode() error = %v, want nil", err)
} }
if got := outputFileNames(result.Files); !containsString(got, "outputs/dnd__spell.json") { if got := outputFileNames(result.Files); !containsString(got, "lanes/dnd__spell.json") {
t.Fatalf("file names = %#v, want sanitized output filename", got) t.Fatalf("file names = %#v, want sanitized output filename", got)
} }
} }
func TestEncodeRejectsInvalidJSONAndUnsupportedMediaTypes(t *testing.T) {
tests := []struct {
name string
output contracts.NormalizeOutput
want string
}{
{
name: "invalid JSON",
output: normalizeOutput("spells", `{"spell_casts":[`),
want: "invalid JSON",
},
{
name: "unsupported media type",
output: func() contracts.NormalizeOutput {
output := normalizeOutput("spells", `{"spell_casts":[]}`)
output.Payload.MediaType = "text/plain"
return output
}(),
want: "unsupported media type",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := New().Encode(context.Background(), contracts.OutputRequest{
NormalizeOutputs: []contracts.NormalizeOutput{test.output},
})
if err == nil {
t.Fatal("Encode() error = nil, want error")
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("Encode() error = %q, want %q", err.Error(), test.want)
}
})
}
}
func TestEncodeRejectsSanitizedFilenameCollisions(t *testing.T) { func TestEncodeRejectsSanitizedFilenameCollisions(t *testing.T) {
_, err := New().Encode(context.Background(), contracts.OutputRequest{ _, err := New().Encode(context.Background(), contracts.OutputRequest{
NormalizeOutputs: []contracts.NormalizeOutput{ NormalizeOutputs: []contracts.NormalizeOutput{
@@ -244,7 +333,7 @@ func TestEncodeDoesNotMutateInputs(t *testing.T) {
req.Rejected[0].Message = "changed" req.Rejected[0].Message = "changed"
req.Warnings[0].Message = "changed" req.Warnings[0].Message = "changed"
if !stdjson.Valid(fileBytes(t, result.Files, "outputs/spells.json")) { if !stdjson.Valid(fileBytes(t, result.Files, "lanes/spells.json")) {
t.Fatal("output changed after request mutation") t.Fatal("output changed after request mutation")
} }
warnings := decodeObject(t, fileBytes(t, result.Files, "warnings.json")) warnings := decodeObject(t, fileBytes(t, result.Files, "warnings.json"))
@@ -265,7 +354,7 @@ func TestOutputFilesDoNotContainWarnings(t *testing.T) {
t.Fatalf("Encode() error = %v, want nil", err) t.Fatalf("Encode() error = %v, want nil", err)
} }
outputFile := decodeObject(t, fileBytes(t, result.Files, "outputs/spells.json")) outputFile := decodeObject(t, fileBytes(t, result.Files, "lanes/spells.json"))
if _, ok := outputFile["warnings"]; ok { if _, ok := outputFile["warnings"]; ok {
t.Fatalf("output file contains warnings: %#v", outputFile) t.Fatalf("output file contains warnings: %#v", outputFile)
} }