Add render artifact model, config, and Seriatim adapter contracts

This commit is contained in:
2026-05-25 00:28:01 +00:00
parent a51228c803
commit 0658f2f642
18 changed files with 915 additions and 43 deletions

View File

@@ -68,6 +68,26 @@ func (n *NoopRunner) Normalize(ctx context.Context, req NormalizeRequest) (Norma
}, nil }, nil
} }
// Render returns the requested output path with placeholder metadata.
func (n *NoopRunner) Render(ctx context.Context, req RenderRequest) (RenderResult, error) {
if err := ctx.Err(); err != nil {
return RenderResult{}, err
}
if err := materializeRenderPlaceholders(req); err != nil {
return RenderResult{}, err
}
return RenderResult{
OutputRenderedPath: req.OutputRenderedPath,
StdoutLogPath: req.StdoutLogPath,
StderrLogPath: req.StderrLogPath,
GeneratedConfigPath: req.GeneratedConfigPath,
InvokedBinary: "noop",
Format: req.Format,
Title: req.Title,
Metadata: map[string]any{"placeholder": true},
}, nil
}
// FakeRunner captures merge requests and returns deterministic responses. // FakeRunner captures merge requests and returns deterministic responses.
type FakeRunner struct { type FakeRunner struct {
Requests []MergeRequest Requests []MergeRequest
@@ -79,6 +99,9 @@ type FakeRunner struct {
TrimRequests []TrimRequest TrimRequests []TrimRequest
TrimErr error TrimErr error
TrimResult TrimResult TrimResult TrimResult
RenderRequests []RenderRequest
RenderErr error
RenderResult RenderResult
} }
// Run records request and returns configured response. // Run records request and returns configured response.
@@ -195,6 +218,46 @@ func (f *FakeRunner) Normalize(ctx context.Context, req NormalizeRequest) (Norma
return res, nil return res, nil
} }
// Render records request and returns configured response.
func (f *FakeRunner) Render(ctx context.Context, req RenderRequest) (RenderResult, error) {
if err := ctx.Err(); err != nil {
return RenderResult{}, err
}
f.RenderRequests = append(f.RenderRequests, req)
if f.RenderErr != nil {
return RenderResult{}, f.RenderErr
}
if err := materializeRenderPlaceholders(req); err != nil {
return RenderResult{}, err
}
res := f.RenderResult
if res.OutputRenderedPath == "" {
res.OutputRenderedPath = req.OutputRenderedPath
}
if res.StdoutLogPath == "" {
res.StdoutLogPath = req.StdoutLogPath
}
if res.StderrLogPath == "" {
res.StderrLogPath = req.StderrLogPath
}
if res.GeneratedConfigPath == "" {
res.GeneratedConfigPath = req.GeneratedConfigPath
}
if res.InvokedBinary == "" {
res.InvokedBinary = "fake"
}
if res.Format == "" {
res.Format = req.Format
}
if res.Title == "" {
res.Title = req.Title
}
if res.Metadata == nil {
res.Metadata = map[string]any{"fake": true}
}
return res, nil
}
func materializePlaceholders(req MergeRequest) error { func materializePlaceholders(req MergeRequest) error {
if req.OutputMergedTranscriptPath != "" { if req.OutputMergedTranscriptPath != "" {
if err := subprocess.WriteFileAtomic(req.OutputMergedTranscriptPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), 0o644); err != nil { if err := subprocess.WriteFileAtomic(req.OutputMergedTranscriptPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), 0o644); err != nil {
@@ -301,3 +364,39 @@ func materializeNormalizePlaceholders(req NormalizeRequest) error {
} }
return nil return nil
} }
func materializeRenderPlaceholders(req RenderRequest) error {
if req.OutputRenderedPath != "" {
if err := subprocess.WriteFileAtomic(req.OutputRenderedPath, []byte("# Transcript\n\nRendered markdown placeholder.\n"), 0o644); err != nil {
return fmt.Errorf("write rendered transcript %q: %w", req.OutputRenderedPath, err)
}
}
if req.GeneratedConfigPath != "" {
payload := map[string]any{
"schema": "seriatim.generated.v1",
"placeholder": true,
"command": "render",
"input_path": req.InputTranscriptPath,
"output_path": req.OutputRenderedPath,
"format": req.Format,
"title": req.Title,
"include_timestamps": req.IncludeTimestamps,
"include_segment_ids": req.IncludeSegmentIDs,
"include_metadata": req.IncludeMetadata,
}
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644); err != nil {
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
}
}
if req.StdoutLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake render stdout placeholder\n"), 0o644); err != nil {
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
}
}
if req.StderrLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake render stderr placeholder\n"), 0o644); err != nil {
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
}
}
return nil
}

View File

@@ -148,3 +148,58 @@ func TestFakeRunnerNormalizeError(t *testing.T) {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
} }
func TestFakeRunnerRenderCapturesRequestAndReturnsPath(t *testing.T) {
fake := &FakeRunner{}
dir := t.TempDir()
req := RenderRequest{
GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.render.yml"),
InputTranscriptPath: filepath.Join(dir, "transcripts", "final.trimmed.json"),
OutputRenderedPath: filepath.Join(dir, "transcripts", "final.trimmed.md"),
Format: "markdown",
Title: "Session render",
IncludeTimestamps: true,
IncludeSegmentIDs: false,
IncludeMetadata: true,
StdoutLogPath: filepath.Join(dir, "logs", "seriatim.render.stdout.log"),
StderrLogPath: filepath.Join(dir, "logs", "seriatim.render.stderr.log"),
}
res, err := fake.Render(context.Background(), req)
if err != nil {
t.Fatalf("Render() error = %v", err)
}
if len(fake.RenderRequests) != 1 || fake.RenderRequests[0].GeneratedConfigPath == "" {
t.Fatalf("render requests = %#v, want captured request", fake.RenderRequests)
}
if res.OutputRenderedPath != req.OutputRenderedPath {
t.Fatalf("rendered path = %q, want %q", res.OutputRenderedPath, req.OutputRenderedPath)
}
if res.Format != req.Format {
t.Fatalf("format = %q, want %q", res.Format, req.Format)
}
if res.Title != req.Title {
t.Fatalf("title = %q, want %q", res.Title, req.Title)
}
cfgData, err := os.ReadFile(req.GeneratedConfigPath)
if err != nil {
t.Fatalf("read generated config: %v", err)
}
if !strings.Contains(string(cfgData), "command: render") {
t.Fatalf("generated config = %q, want render command marker", string(cfgData))
}
for _, path := range []string{req.StdoutLogPath, req.StderrLogPath, req.OutputRenderedPath} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected file %q to exist: %v", path, err)
}
}
}
func TestFakeRunnerRenderError(t *testing.T) {
fake := &FakeRunner{RenderErr: errors.New("boom")}
_, err := fake.Render(context.Background(), RenderRequest{})
if err == nil {
t.Fatal("expected error, got nil")
}
}

View File

@@ -1,4 +1,4 @@
// Package seriatim declares the adapter contract for transcript merge/normalize/trim execution. // Package seriatim declares the adapter contract for transcript merge/normalize/trim/render execution.
package seriatim package seriatim
import ( import (
@@ -6,11 +6,12 @@ import (
"time" "time"
) )
// Runner is the adapter boundary for seriatim merge/normalize/trim invocations. // Runner is the adapter boundary for seriatim merge/normalize/trim/render invocations.
type Runner interface { type Runner interface {
Run(ctx context.Context, req MergeRequest) (MergeResult, error) Run(ctx context.Context, req MergeRequest) (MergeResult, error)
Normalize(ctx context.Context, req NormalizeRequest) (NormalizeResult, error) Normalize(ctx context.Context, req NormalizeRequest) (NormalizeResult, error)
Trim(ctx context.Context, req TrimRequest) (TrimResult, error) Trim(ctx context.Context, req TrimRequest) (TrimResult, error)
Render(ctx context.Context, req RenderRequest) (RenderResult, error)
} }
// MergeRequest describes a seriatim merge invocation. // MergeRequest describes a seriatim merge invocation.
@@ -90,3 +91,33 @@ type TrimResult struct {
KeepSelector string KeepSelector string
Metadata map[string]any Metadata map[string]any
} }
// RenderRequest describes a seriatim render invocation.
type RenderRequest struct {
Binary string
InputTranscriptPath string
OutputRenderedPath string
Format string
Title string
IncludeTimestamps bool
IncludeSegmentIDs bool
IncludeMetadata bool
StdoutLogPath string
StderrLogPath string
GeneratedConfigPath string
Timeout time.Duration
}
// RenderResult describes a render output.
type RenderResult struct {
OutputRenderedPath string
StdoutLogPath string
StderrLogPath string
GeneratedConfigPath string
ExitCode int
Duration time.Duration
InvokedBinary string
Format string
Title string
Metadata map[string]any
}

View File

@@ -8,6 +8,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode/utf8"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
) )
@@ -384,6 +385,96 @@ func (r *SubprocessRunner) Normalize(ctx context.Context, req NormalizeRequest)
}, nil }, nil
} }
// Render executes Seriatim render with deterministic flags and validates non-empty text output.
func (r *SubprocessRunner) Render(ctx context.Context, req RenderRequest) (RenderResult, error) {
if r == nil {
return RenderResult{}, fmt.Errorf("seriatim subprocess runner is nil")
}
if strings.TrimSpace(req.InputTranscriptPath) == "" {
return RenderResult{}, fmt.Errorf("seriatim render input path is required")
}
if strings.TrimSpace(req.OutputRenderedPath) == "" {
return RenderResult{}, fmt.Errorf("seriatim render output path is required")
}
format := strings.TrimSpace(req.Format)
if format == "" {
format = "markdown"
}
if format != "markdown" {
return RenderResult{}, fmt.Errorf("seriatim render format %q is unsupported", req.Format)
}
binary := r.binary
if strings.TrimSpace(req.Binary) != "" {
binary = strings.TrimSpace(req.Binary)
}
timeout := r.timeout
if req.Timeout < 0 {
return RenderResult{}, fmt.Errorf("seriatim render timeout must be >= 0")
}
if req.Timeout > 0 {
timeout = req.Timeout
}
args := buildRenderArgs(req, format)
if req.GeneratedConfigPath != "" {
if err := writeRenderInvocationConfig(req, args, binary, timeout, format); err != nil {
return RenderResult{}, fmt.Errorf("write seriatim render invocation config %q: %w", req.GeneratedConfigPath, err)
}
}
runRes, err := subprocess.Run(ctx, subprocess.RunRequest{
Executable: binary,
Args: args,
Timeout: timeout,
StdoutLogPath: req.StdoutLogPath,
StderrLogPath: req.StderrLogPath,
})
if err != nil {
return RenderResult{
OutputRenderedPath: req.OutputRenderedPath,
StdoutLogPath: req.StdoutLogPath,
StderrLogPath: req.StderrLogPath,
GeneratedConfigPath: req.GeneratedConfigPath,
ExitCode: runRes.ExitCode,
Duration: runRes.Duration,
InvokedBinary: binary,
Format: format,
Title: req.Title,
}, fmt.Errorf("run seriatim render (binary=%q): %w", binary, err)
}
if err := validateNonEmptyTextFile(req.OutputRenderedPath); err != nil {
return RenderResult{
OutputRenderedPath: req.OutputRenderedPath,
StdoutLogPath: req.StdoutLogPath,
StderrLogPath: req.StderrLogPath,
GeneratedConfigPath: req.GeneratedConfigPath,
ExitCode: runRes.ExitCode,
Duration: runRes.Duration,
InvokedBinary: binary,
Format: format,
Title: req.Title,
}, fmt.Errorf("validate seriatim rendered output %q: %w", req.OutputRenderedPath, err)
}
return RenderResult{
OutputRenderedPath: req.OutputRenderedPath,
StdoutLogPath: req.StdoutLogPath,
StderrLogPath: req.StderrLogPath,
GeneratedConfigPath: req.GeneratedConfigPath,
ExitCode: runRes.ExitCode,
Duration: runRes.Duration,
InvokedBinary: binary,
Format: format,
Title: req.Title,
Metadata: map[string]any{
"adapter": "seriatim_subprocess",
},
}, nil
}
func (r *SubprocessRunner) buildMergeArgs(req MergeRequest) []string { func (r *SubprocessRunner) buildMergeArgs(req MergeRequest) []string {
args := []string{"merge"} args := []string{"merge"}
@@ -480,6 +571,22 @@ func buildNormalizeArgs(req NormalizeRequest, outputSchema string) []string {
return args return args
} }
func buildRenderArgs(req RenderRequest, format string) []string {
args := []string{
"render",
"--input-file", req.InputTranscriptPath,
"--output-file", req.OutputRenderedPath,
"--format", format,
"--include-timestamps", strconv.FormatBool(req.IncludeTimestamps),
"--include-segment-ids", strconv.FormatBool(req.IncludeSegmentIDs),
"--include-metadata", strconv.FormatBool(req.IncludeMetadata),
}
if strings.TrimSpace(req.Title) != "" {
args = append(args, "--title", req.Title)
}
return args
}
func writeTrimInvocationConfig(req TrimRequest, args []string, binary string, timeout time.Duration) error { func writeTrimInvocationConfig(req TrimRequest, args []string, binary string, timeout time.Duration) error {
payload := map[string]any{ payload := map[string]any{
"schema": "seriatim.generated.v1", "schema": "seriatim.generated.v1",
@@ -509,6 +616,24 @@ func writeNormalizeInvocationConfig(req NormalizeRequest, args []string, binary
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644) return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644)
} }
func writeRenderInvocationConfig(req RenderRequest, args []string, binary string, timeout time.Duration, format string) error {
payload := map[string]any{
"schema": "seriatim.generated.v1",
"command": "render",
"binary": binary,
"args": args,
"timeout": timeout.String(),
"input_path": req.InputTranscriptPath,
"output_path": req.OutputRenderedPath,
"format": format,
"title": req.Title,
"include_timestamps": req.IncludeTimestamps,
"include_segment_ids": req.IncludeSegmentIDs,
"include_metadata": req.IncludeMetadata,
}
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644)
}
func validateJSONFile(path string) error { func validateJSONFile(path string) error {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -541,3 +666,20 @@ func validateJSONFileWithSegments(path string) error {
} }
return nil return nil
} }
func validateNonEmptyTextFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read file: %w", err)
}
if len(data) == 0 {
return fmt.Errorf("file is empty")
}
if !utf8.Valid(data) {
return fmt.Errorf("file is not valid utf-8 text")
}
if strings.TrimSpace(string(data)) == "" {
return fmt.Errorf("file has no non-whitespace content")
}
return nil
}

View File

@@ -569,6 +569,156 @@ func TestSubprocessRunnerNormalizeInvalidReportJSONFails(t *testing.T) {
} }
} }
func TestSubprocessRunnerRenderSuccessInvocationAndProvenance(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "render_success")
recordPath := filepath.Join(t.TempDir(), "record.json")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", recordPath)
wrapper := writeHelperWrapper(t)
runner := mustRunner(t, wrapper, false)
req := renderReqForTest(t)
res, err := runner.Render(context.Background(), req)
if err != nil {
t.Fatalf("Render() error = %v", err)
}
if res.OutputRenderedPath != req.OutputRenderedPath {
t.Fatalf("OutputRenderedPath = %q, want %q", res.OutputRenderedPath, req.OutputRenderedPath)
}
if res.Format != req.Format {
t.Fatalf("Format = %q, want %q", res.Format, req.Format)
}
if res.Title != req.Title {
t.Fatalf("Title = %q, want %q", res.Title, req.Title)
}
if res.InvokedBinary != wrapper {
t.Fatalf("InvokedBinary = %q, want %q", res.InvokedBinary, wrapper)
}
if res.ExitCode != 0 {
t.Fatalf("ExitCode = %d, want 0", res.ExitCode)
}
if res.Duration <= 0 {
t.Fatalf("Duration = %s, want >0", res.Duration)
}
if res.Metadata == nil || res.Metadata["adapter"] != "seriatim_subprocess" {
t.Fatalf("Metadata = %#v, want adapter marker", res.Metadata)
}
if _, err := os.Stat(req.OutputRenderedPath); err != nil {
t.Fatalf("rendered output missing: %v", err)
}
if _, err := os.Stat(req.StdoutLogPath); err != nil {
t.Fatalf("stdout log missing: %v", err)
}
if _, err := os.Stat(req.StderrLogPath); err != nil {
t.Fatalf("stderr log missing: %v", err)
}
if _, err := os.Stat(req.GeneratedConfigPath); err != nil {
t.Fatalf("generated config missing: %v", err)
}
rec := readHelperRecord(t, recordPath)
wantArgs := []string{
"render",
"--input-file", req.InputTranscriptPath,
"--output-file", req.OutputRenderedPath,
"--format", req.Format,
"--include-timestamps", "true",
"--include-segment-ids", "false",
"--include-metadata", "true",
"--title", req.Title,
}
if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") {
t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs)
}
}
func TestSubprocessRunnerRenderWithoutTitleOmitsTitleArg(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "render_success")
recordPath := filepath.Join(t.TempDir(), "record.json")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", recordPath)
runner := mustRunner(t, writeHelperWrapper(t), false)
req := renderReqForTest(t)
req.Title = ""
if _, err := runner.Render(context.Background(), req); err != nil {
t.Fatalf("Render() error = %v", err)
}
rec := readHelperRecord(t, recordPath)
for i := 0; i < len(rec.Args); i++ {
if rec.Args[i] == "--title" {
t.Fatalf("args = %#v, did not expect --title", rec.Args)
}
}
}
func TestSubprocessRunnerRenderSubprocessFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "fail")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustRunner(t, writeHelperWrapper(t), false)
req := renderReqForTest(t)
_, err := runner.Render(context.Background(), req)
if err == nil {
t.Fatal("Render() error = nil, want non-nil")
}
if !strings.Contains(err.Error(), "run seriatim render") {
t.Fatalf("error = %q, want subprocess context", err.Error())
}
}
func TestSubprocessRunnerRenderMissingOutputFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "missing_output")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustRunner(t, writeHelperWrapper(t), false)
req := renderReqForTest(t)
_, err := runner.Render(context.Background(), req)
if err == nil {
t.Fatal("Render() error = nil, want non-nil")
}
if !strings.Contains(err.Error(), "validate seriatim rendered output") {
t.Fatalf("error = %q, want output validation context", err.Error())
}
}
func TestSubprocessRunnerRenderEmptyOutputFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "render_empty_output")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustRunner(t, writeHelperWrapper(t), false)
req := renderReqForTest(t)
_, err := runner.Render(context.Background(), req)
if err == nil {
t.Fatal("Render() error = nil, want non-nil")
}
if !strings.Contains(err.Error(), "file is empty") {
t.Fatalf("error = %q, want empty-file validation", err.Error())
}
}
func TestSubprocessRunnerConstructorValidation(t *testing.T) { func TestSubprocessRunnerConstructorValidation(t *testing.T) {
_, err := NewSubprocessRunnerFromConfigValues("", "10m", "seriatim-intermediate", nil, true, EnvConfig{}) _, err := NewSubprocessRunnerFromConfigValues("", "10m", "seriatim-intermediate", nil, true, EnvConfig{})
if err == nil { if err == nil {
@@ -702,6 +852,14 @@ func TestSeriatimSubprocessHelper(t *testing.T) {
case "normalize_report_missing": case "normalize_report_missing":
writeSeriatimHelperFile(outputPath, `{"schema":"seriatim.intermediate.v1","segments":[]}`) writeSeriatimHelperFile(outputPath, `{"schema":"seriatim.intermediate.v1","segments":[]}`)
os.Exit(0) os.Exit(0)
case "render_success":
writeSeriatimHelperFile(outputPath, "# Rendered transcript\n\nHello.\n")
_, _ = os.Stdout.WriteString("seriatim helper render stdout\n")
_, _ = os.Stderr.WriteString("seriatim helper render stderr\n")
os.Exit(0)
case "render_empty_output":
writeSeriatimHelperFile(outputPath, "")
os.Exit(0)
default: default:
_, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode)) _, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode))
os.Exit(2) os.Exit(2)
@@ -777,6 +935,25 @@ func normalizeReqForTest(t *testing.T, withReport bool) NormalizeRequest {
return req return req
} }
func renderReqForTest(t *testing.T) RenderRequest {
t.Helper()
dir := t.TempDir()
input := filepath.Join(dir, "final.trimmed.json")
writeSeriatimFile(t, input, `{"schema":"seriatim.intermediate.v1","segments":[]}`)
return RenderRequest{
InputTranscriptPath: input,
OutputRenderedPath: filepath.Join(dir, "final.trimmed.md"),
Format: "markdown",
Title: "Session 42",
IncludeTimestamps: true,
IncludeSegmentIDs: false,
IncludeMetadata: true,
GeneratedConfigPath: filepath.Join(dir, "seriatim.render.generated.yml"),
StdoutLogPath: filepath.Join(dir, "seriatim.render.stdout.log"),
StderrLogPath: filepath.Join(dir, "seriatim.render.stderr.log"),
}
}
func mustRunner(t *testing.T, binary string, report bool) *SubprocessRunner { func mustRunner(t *testing.T, binary string, report bool) *SubprocessRunner {
t.Helper() t.Helper()
coalesce := 3.0 coalesce := 3.0

View File

@@ -32,15 +32,10 @@ func buildHelperArtifactCatalog(cfg *config.Config) (*artifacts.ArtifactCatalog,
func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.ArtifactCatalog, locks *effectiveLocks, publishedRemoteState map[string]string) { func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.ArtifactCatalog, locks *effectiveLocks, publishedRemoteState map[string]string) {
lockSet := lockSourceSet(locks.All) lockSet := lockSourceSet(locks.All)
fmt.Fprintln(out, "Built-in:") fmt.Fprintln(out, "Built-in:")
for _, id := range []string{ for _, transcript := range artifacts.RuntimeTranscriptArtifacts() {
artifacts.ArtifactTranscriptBase, writeArtifactLine(out, transcript.SourceID, lockSet)
artifacts.ArtifactTranscriptPolished,
artifacts.ArtifactTranscriptFinal,
artifacts.ArtifactTranscriptFinalTrimmed,
artifacts.ArtifactBoundsSession,
} {
writeArtifactLine(out, id, lockSet)
} }
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
fmt.Fprintln(out, "Configured:") fmt.Fprintln(out, "Configured:")
for _, entry := range catalog.ListConfigured() { for _, entry := range catalog.ListConfigured() {
writeArtifactLine(out, entry.SourceID, lockSet) writeArtifactLine(out, entry.SourceID, lockSet)

View File

@@ -3,24 +3,30 @@ package artifactmodel
import "strings" import "strings"
const ( const (
SourceTranscriptBase = "narratio.transcript.base" SourceTranscriptBase = "narratio.transcript.base"
SourceTranscriptPolished = "narratio.transcript.polished" SourceTranscriptPolished = "narratio.transcript.polished"
SourceTranscriptFinal = "narratio.transcript.final" SourceTranscriptFinal = "narratio.transcript.final"
SourceTranscriptFinalTrimmed = "narratio.transcript.final_trimmed" SourceTranscriptFinalTrimmed = "narratio.transcript.final_trimmed"
SourceTranscriptFinalMarkdown = "narratio.transcript.final_markdown"
SourceTranscriptFinalTrimmedMarkdown = "narratio.transcript.final_trimmed_markdown"
) )
const ( const (
TranscriptPathBase = "transcripts/base.json" TranscriptPathBase = "transcripts/base.json"
TranscriptPathPolished = "transcripts/polished.json" TranscriptPathPolished = "transcripts/polished.json"
TranscriptPathFinal = "transcripts/final.json" TranscriptPathFinal = "transcripts/final.json"
TranscriptPathFinalTrimmed = "transcripts/final.trimmed.json" TranscriptPathFinalTrimmed = "transcripts/final.trimmed.json"
TranscriptPathFinalMarkdown = "transcripts/final.md"
TranscriptPathFinalTrimmedMarkdown = "transcripts/final.trimmed.md"
) )
const ( const (
TranscriptOutputKindBase = "transcript_base" TranscriptOutputKindBase = "transcript_base"
TranscriptOutputKindPolished = "transcript_polished" TranscriptOutputKindPolished = "transcript_polished"
TranscriptOutputKindFinal = "transcript_final" TranscriptOutputKindFinal = "transcript_final"
TranscriptOutputKindFinalTrimmed = "transcript_final_trimmed" TranscriptOutputKindFinalTrimmed = "transcript_final_trimmed"
TranscriptOutputKindFinalMarkdown = "transcript_final_markdown"
TranscriptOutputKindFinalTrimmedMarkdown = "transcript_final_trimmed_markdown"
) )
// TranscriptArtifactSpec describes one built-in transcript artifact mapping. // TranscriptArtifactSpec describes one built-in transcript artifact mapping.
@@ -56,6 +62,18 @@ var runtimeTranscriptArtifacts = []TranscriptArtifactSpec{
ProducerStage: "trim", ProducerStage: "trim",
OutputKind: TranscriptOutputKindFinalTrimmed, OutputKind: TranscriptOutputKindFinalTrimmed,
}, },
{
SourceID: SourceTranscriptFinalMarkdown,
CanonicalRelPath: TranscriptPathFinalMarkdown,
ProducerStage: "render",
OutputKind: TranscriptOutputKindFinalMarkdown,
},
{
SourceID: SourceTranscriptFinalTrimmedMarkdown,
CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown,
ProducerStage: "render",
OutputKind: TranscriptOutputKindFinalTrimmedMarkdown,
},
} }
// RuntimeTranscriptArtifacts returns transcript mappings in pipeline order. // RuntimeTranscriptArtifacts returns transcript mappings in pipeline order.

View File

@@ -0,0 +1,60 @@
package artifactmodel
import (
"reflect"
"testing"
)
func TestRuntimeTranscriptArtifactsIncludesMarkdownOutputs(t *testing.T) {
want := []TranscriptArtifactSpec{
{
SourceID: SourceTranscriptBase,
CanonicalRelPath: TranscriptPathBase,
ProducerStage: "merge",
OutputKind: TranscriptOutputKindBase,
},
{
SourceID: SourceTranscriptPolished,
CanonicalRelPath: TranscriptPathPolished,
ProducerStage: "polish",
OutputKind: TranscriptOutputKindPolished,
},
{
SourceID: SourceTranscriptFinal,
CanonicalRelPath: TranscriptPathFinal,
ProducerStage: "normalize",
OutputKind: TranscriptOutputKindFinal,
},
{
SourceID: SourceTranscriptFinalTrimmed,
CanonicalRelPath: TranscriptPathFinalTrimmed,
ProducerStage: "trim",
OutputKind: TranscriptOutputKindFinalTrimmed,
},
{
SourceID: SourceTranscriptFinalMarkdown,
CanonicalRelPath: TranscriptPathFinalMarkdown,
ProducerStage: "render",
OutputKind: TranscriptOutputKindFinalMarkdown,
},
{
SourceID: SourceTranscriptFinalTrimmedMarkdown,
CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown,
ProducerStage: "render",
OutputKind: TranscriptOutputKindFinalTrimmedMarkdown,
},
}
got := RuntimeTranscriptArtifacts()
if !reflect.DeepEqual(got, want) {
t.Fatalf("RuntimeTranscriptArtifacts() = %#v, want %#v", got, want)
}
}
func TestLookupRuntimeTranscriptArtifactFindsMarkdownOutputs(t *testing.T) {
for _, source := range []string{SourceTranscriptFinalMarkdown, SourceTranscriptFinalTrimmedMarkdown} {
if _, ok := LookupRuntimeTranscriptArtifact(source); !ok {
t.Fatalf("LookupRuntimeTranscriptArtifact(%q) ok=false, want true", source)
}
}
}

View File

@@ -14,28 +14,34 @@ import (
) )
const ( const (
ArtifactTranscriptBase = artifactmodel.SourceTranscriptBase ArtifactTranscriptBase = artifactmodel.SourceTranscriptBase
ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished
ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal
ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed
ArtifactBoundsSession = "narratio.bounds.session" ArtifactTranscriptFinalMarkdown = artifactmodel.SourceTranscriptFinalMarkdown
ArtifactTranscriptFinalTrimmedMarkdown = artifactmodel.SourceTranscriptFinalTrimmedMarkdown
ArtifactBoundsSession = "narratio.bounds.session"
ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache" ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache"
ArtifactProvenancePreviousCacheFilesystem = "current_session.previous_cache" ArtifactProvenancePreviousCacheFilesystem = "current_session.previous_cache"
) )
const ( const (
TranscriptPathBase = artifactmodel.TranscriptPathBase TranscriptPathBase = artifactmodel.TranscriptPathBase
TranscriptPathPolished = artifactmodel.TranscriptPathPolished TranscriptPathPolished = artifactmodel.TranscriptPathPolished
TranscriptPathFinal = artifactmodel.TranscriptPathFinal TranscriptPathFinal = artifactmodel.TranscriptPathFinal
TranscriptPathFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed TranscriptPathFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed
TranscriptPathFinalMarkdown = artifactmodel.TranscriptPathFinalMarkdown
TranscriptPathFinalTrimmedMarkdown = artifactmodel.TranscriptPathFinalTrimmedMarkdown
) )
const ( const (
TranscriptOutputKindBase = artifactmodel.TranscriptOutputKindBase TranscriptOutputKindBase = artifactmodel.TranscriptOutputKindBase
TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished
TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal
TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed
TranscriptOutputKindFinalMarkdown = artifactmodel.TranscriptOutputKindFinalMarkdown
TranscriptOutputKindFinalTrimmedMarkdown = artifactmodel.TranscriptOutputKindFinalTrimmedMarkdown
) )
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID. // ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
@@ -67,7 +73,7 @@ func buildArtifactRegistry() map[string]artifactSpec {
CanonicalRelPath: transcript.CanonicalRelPath, CanonicalRelPath: transcript.CanonicalRelPath,
ProducerStage: transcript.ProducerStage, ProducerStage: transcript.ProducerStage,
OutputKind: transcript.OutputKind, OutputKind: transcript.OutputKind,
ContentKind: contentTranscriptJSON, ContentKind: transcriptContentKind(transcript),
} }
} }
registry[ArtifactBoundsSession] = artifactSpec{ registry[ArtifactBoundsSession] = artifactSpec{
@@ -80,6 +86,15 @@ func buildArtifactRegistry() map[string]artifactSpec {
return registry return registry
} }
func transcriptContentKind(transcript TranscriptArtifactSpec) artifactContentKind {
switch transcript.SourceID {
case ArtifactTranscriptFinalMarkdown, ArtifactTranscriptFinalTrimmedMarkdown:
return contentText
default:
return contentTranscriptJSON
}
}
// ResolvedSessionArtifact describes one session-level artifact lookup result. // ResolvedSessionArtifact describes one session-level artifact lookup result.
type ResolvedSessionArtifact struct { type ResolvedSessionArtifact struct {
ID string ID string

View File

@@ -211,6 +211,29 @@ func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) {
} }
} }
func TestResolveSessionArtifactFallsBackToCanonicalMarkdownPath(t *testing.T) {
workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session")
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.md")
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(canonicalPath, []byte("# Final transcript\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalMarkdown)
if err != nil {
t.Fatalf("ResolveSessionArtifact() error = %v", err)
}
if resolved.Path != canonicalPath {
t.Fatalf("resolved path = %q, want %q", resolved.Path, canonicalPath)
}
if resolved.Provenance != "fallback.canonical_path" {
t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance)
}
}
func TestResolveSessionArtifactMissingReturnsTypedError(t *testing.T) { func TestResolveSessionArtifactMissingReturnsTypedError(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session") paths := buildSessionPaths(workspace, "campaign", "session")
@@ -244,6 +267,26 @@ func TestResolveSessionArtifactValidatesTranscriptShape(t *testing.T) {
} }
} }
func TestResolveSessionArtifactRejectsEmptyMarkdownContent(t *testing.T) {
workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session")
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.md")
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(canonicalPath, []byte{}, 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
_, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmedMarkdown)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "file is empty") {
t.Fatalf("error = %q, want empty file validation", err.Error())
}
}
func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) { func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session") paths := buildSessionPaths(workspace, "campaign", "session")

View File

@@ -216,11 +216,10 @@ func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error {
} }
func runtimeBuiltInArtifactIDs() []string { func runtimeBuiltInArtifactIDs() []string {
return []string{ ids := make([]string, 0, len(RuntimeTranscriptArtifacts())+1)
ArtifactTranscriptBase, for _, transcript := range RuntimeTranscriptArtifacts() {
ArtifactTranscriptPolished, ids = append(ids, transcript.SourceID)
ArtifactTranscriptFinal,
ArtifactTranscriptFinalTrimmed,
ArtifactBoundsSession,
} }
ids = append(ids, ArtifactBoundsSession)
return ids
} }

View File

@@ -23,6 +23,26 @@ func TestArtifactCatalogRegisterBuiltInsAndLookup(t *testing.T) {
} }
} }
func TestArtifactCatalogRegisterBuiltInsIncludesMarkdownSources(t *testing.T) {
catalog := NewArtifactCatalog()
if err := catalog.RegisterBuiltIns(); err != nil {
t.Fatalf("RegisterBuiltIns() error = %v", err)
}
for _, sourceID := range []string{
ArtifactTranscriptFinalMarkdown,
ArtifactTranscriptFinalTrimmedMarkdown,
} {
entry, ok := catalog.Lookup(sourceID)
if !ok {
t.Fatalf("Lookup(%q) ok=false, want true", sourceID)
}
if !entry.Planned {
t.Fatalf("%s planned=false, want true", sourceID)
}
}
}
func TestArtifactCatalogRegisterConfiguredArtifactsDefaultsToEnabled(t *testing.T) { func TestArtifactCatalogRegisterConfiguredArtifactsDefaultsToEnabled(t *testing.T) {
catalog := NewArtifactCatalog() catalog := NewArtifactCatalog()
if err := catalog.RegisterConfiguredArtifacts(map[string]ConfiguredArtifactDefinition{ if err := catalog.RegisterConfiguredArtifacts(map[string]ConfiguredArtifactDefinition{

View File

@@ -31,6 +31,18 @@ func TestRuntimeTranscriptArtifacts(t *testing.T) {
ProducerStage: "trim", ProducerStage: "trim",
OutputKind: TranscriptOutputKindFinalTrimmed, OutputKind: TranscriptOutputKindFinalTrimmed,
}, },
{
SourceID: ArtifactTranscriptFinalMarkdown,
CanonicalRelPath: TranscriptPathFinalMarkdown,
ProducerStage: "render",
OutputKind: TranscriptOutputKindFinalMarkdown,
},
{
SourceID: ArtifactTranscriptFinalTrimmedMarkdown,
CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown,
ProducerStage: "render",
OutputKind: TranscriptOutputKindFinalTrimmedMarkdown,
},
} }
got := RuntimeTranscriptArtifacts() got := RuntimeTranscriptArtifacts()
@@ -75,6 +87,18 @@ func TestPlannedTranscriptArtifacts(t *testing.T) {
ProducerStage: "trim", ProducerStage: "trim",
OutputKind: TranscriptOutputKindFinalTrimmed, OutputKind: TranscriptOutputKindFinalTrimmed,
}, },
{
SourceID: ArtifactTranscriptFinalMarkdown,
CanonicalRelPath: TranscriptPathFinalMarkdown,
ProducerStage: "render",
OutputKind: TranscriptOutputKindFinalMarkdown,
},
{
SourceID: ArtifactTranscriptFinalTrimmedMarkdown,
CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown,
ProducerStage: "render",
OutputKind: TranscriptOutputKindFinalTrimmedMarkdown,
},
} }
got := PlannedTranscriptArtifacts() got := PlannedTranscriptArtifacts()
@@ -113,10 +137,14 @@ func TestRuntimeArtifactRegistryUsesTranscriptSpecs(t *testing.T) {
if !ok { if !ok {
t.Fatalf("artifactRegistry missing %q", transcript.SourceID) t.Fatalf("artifactRegistry missing %q", transcript.SourceID)
} }
wantContentKind := contentTranscriptJSON
if transcript.SourceID == ArtifactTranscriptFinalMarkdown || transcript.SourceID == ArtifactTranscriptFinalTrimmedMarkdown {
wantContentKind = contentText
}
if spec.CanonicalRelPath != transcript.CanonicalRelPath || if spec.CanonicalRelPath != transcript.CanonicalRelPath ||
spec.ProducerStage != transcript.ProducerStage || spec.ProducerStage != transcript.ProducerStage ||
spec.OutputKind != transcript.OutputKind || spec.OutputKind != transcript.OutputKind ||
spec.ContentKind != contentTranscriptJSON { spec.ContentKind != wantContentKind {
t.Fatalf("artifactRegistry[%q] = %#v, want transcript spec %#v", transcript.SourceID, spec, transcript) t.Fatalf("artifactRegistry[%q] = %#v, want transcript spec %#v", transcript.SourceID, spec, transcript)
} }
} }

View File

@@ -28,6 +28,7 @@ type PipelineConfig struct {
Audita AuditaConfig `yaml:"audita"` Audita AuditaConfig `yaml:"audita"`
Normalize *NormalizeConfig `yaml:"normalize"` Normalize *NormalizeConfig `yaml:"normalize"`
Trim *TrimConfig `yaml:"trim"` Trim *TrimConfig `yaml:"trim"`
Render *RenderConfig `yaml:"render"`
Scriptorium *ScriptoriumConfig `yaml:"scriptorium"` Scriptorium *ScriptoriumConfig `yaml:"scriptorium"`
Notification NotificationConfig `yaml:"notification"` Notification NotificationConfig `yaml:"notification"`
} }
@@ -209,6 +210,16 @@ type TrimSeriatimConfig struct {
Report *bool `yaml:"report"` Report *bool `yaml:"report"`
} }
// RenderConfig configures render-stage output formatting behavior.
type RenderConfig struct {
Enabled *bool `yaml:"enabled"`
Format string `yaml:"format"`
Title string `yaml:"title"`
IncludeTimestamps *bool `yaml:"include_timestamps"`
IncludeSegmentIDs bool `yaml:"include_segment_ids"`
IncludeMetadata bool `yaml:"include_metadata"`
}
// ScriptoriumConfig configures Scriptorium-backed artifact generation. // ScriptoriumConfig configures Scriptorium-backed artifact generation.
type ScriptoriumConfig struct { type ScriptoriumConfig struct {
Binary string `yaml:"binary"` Binary string `yaml:"binary"`

View File

@@ -40,6 +40,12 @@ const (
DefaultTrimBoundsTimeout = "10m" DefaultTrimBoundsTimeout = "10m"
DefaultTrimSeriatimReport = false DefaultTrimSeriatimReport = false
DefaultRenderEnabled = true
DefaultRenderFormat = "markdown"
DefaultRenderTitle = ""
DefaultRenderTimestamps = true
DefaultRenderSegmentIDs = false
DefaultRenderMetadata = false
DefaultNormalizeOutputPath = artifactmodel.TranscriptPathFinal DefaultNormalizeOutputPath = artifactmodel.TranscriptPathFinal
DefaultNormalizeOutputSchema = "seriatim-intermediate" DefaultNormalizeOutputSchema = "seriatim-intermediate"

View File

@@ -337,6 +337,7 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
} }
applyNormalizeDefaults(cfg.Normalize) applyNormalizeDefaults(cfg.Normalize)
applyTrimDefaults(cfg.Trim) applyTrimDefaults(cfg.Trim)
applyRenderDefaults(&cfg.Render)
applyScriptoriumDefaults(cfg.Scriptorium) applyScriptoriumDefaults(cfg.Scriptorium)
} }
@@ -507,6 +508,27 @@ func applyTrimDefaults(cfg *TrimConfig) {
} }
} }
func applyRenderDefaults(cfg **RenderConfig) {
if cfg == nil {
return
}
if *cfg == nil {
*cfg = &RenderConfig{}
}
if (*cfg).Enabled == nil {
(*cfg).Enabled = boolPtr(DefaultRenderEnabled)
}
if strings.TrimSpace((*cfg).Format) == "" {
(*cfg).Format = DefaultRenderFormat
}
if strings.TrimSpace((*cfg).Title) == "" {
(*cfg).Title = DefaultRenderTitle
}
if (*cfg).IncludeTimestamps == nil {
(*cfg).IncludeTimestamps = boolPtr(DefaultRenderTimestamps)
}
}
func applyNormalizeDefaults(cfg *NormalizeConfig) { func applyNormalizeDefaults(cfg *NormalizeConfig) {
if cfg == nil { if cfg == nil {
return return

View File

@@ -0,0 +1,128 @@
package config
import (
"strings"
"testing"
)
func TestRenderLoadAndValidate(t *testing.T) {
tests := []struct {
name string
renderYAML string
wantLoadErr string
wantValidateErr string
assert func(t *testing.T, cfg *Config)
}{
{
name: "render defaults when omitted",
renderYAML: "",
assert: func(t *testing.T, cfg *Config) {
t.Helper()
if cfg.Pipeline.Render == nil {
t.Fatal("render config should be present via defaults")
}
if cfg.Pipeline.Render.Enabled == nil || !*cfg.Pipeline.Render.Enabled {
t.Fatalf("render.enabled = %#v, want true", cfg.Pipeline.Render.Enabled)
}
if cfg.Pipeline.Render.Format != "markdown" {
t.Fatalf("render.format = %q, want markdown", cfg.Pipeline.Render.Format)
}
if cfg.Pipeline.Render.IncludeTimestamps == nil || !*cfg.Pipeline.Render.IncludeTimestamps {
t.Fatalf("render.include_timestamps = %#v, want true", cfg.Pipeline.Render.IncludeTimestamps)
}
if cfg.Pipeline.Render.IncludeSegmentIDs {
t.Fatalf("render.include_segment_ids = true, want false")
}
if cfg.Pipeline.Render.IncludeMetadata {
t.Fatalf("render.include_metadata = true, want false")
}
},
},
{
name: "valid explicit render config",
renderYAML: `render:
enabled: false
format: markdown
title: Session Render
include_timestamps: false
include_segment_ids: true
include_metadata: true
`,
assert: func(t *testing.T, cfg *Config) {
t.Helper()
if cfg.Pipeline.Render == nil {
t.Fatal("render config should be present")
}
if cfg.Pipeline.Render.Enabled == nil || *cfg.Pipeline.Render.Enabled {
t.Fatalf("render.enabled = %#v, want false", cfg.Pipeline.Render.Enabled)
}
if cfg.Pipeline.Render.IncludeTimestamps == nil || *cfg.Pipeline.Render.IncludeTimestamps {
t.Fatalf("render.include_timestamps = %#v, want false", cfg.Pipeline.Render.IncludeTimestamps)
}
if !cfg.Pipeline.Render.IncludeSegmentIDs {
t.Fatalf("render.include_segment_ids = false, want true")
}
if !cfg.Pipeline.Render.IncludeMetadata {
t.Fatalf("render.include_metadata = false, want true")
}
},
},
{
name: "invalid render format fails",
renderYAML: `render:
format: html
`,
wantValidateErr: "pipeline.render.format must be markdown",
},
{
name: "unknown render field fails strict decoding",
renderYAML: `render:
format: markdown
unknown: true
`,
wantLoadErr: "strict decode failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pipelineYAML := testPipelineBaseYAML
if tt.renderYAML != "" {
pipelineYAML += "\n" + tt.renderYAML
}
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
cfg, err := Load(pipelinePath, sessionPath)
if tt.wantLoadErr != "" {
if err == nil {
t.Fatalf("expected load error containing %q, got nil", tt.wantLoadErr)
}
if !strings.Contains(err.Error(), tt.wantLoadErr) {
t.Fatalf("load error = %q, want to contain %q", err.Error(), tt.wantLoadErr)
}
return
}
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if tt.assert != nil {
tt.assert(t, cfg)
}
err = Validate(cfg)
if tt.wantValidateErr != "" {
if err == nil {
t.Fatalf("expected validation error containing %q, got nil", tt.wantValidateErr)
}
if !strings.Contains(err.Error(), tt.wantValidateErr) {
t.Fatalf("validation error = %q, want to contain %q", err.Error(), tt.wantValidateErr)
}
return
}
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
})
}
}

View File

@@ -88,6 +88,9 @@ func validatePipeline(cfg *PipelineConfig) error {
if err := validateTrim(cfg.Trim); err != nil { if err := validateTrim(cfg.Trim); err != nil {
return err return err
} }
if err := validateRender(cfg.Render); err != nil {
return err
}
if err := validateScriptorium(cfg.Scriptorium); err != nil { if err := validateScriptorium(cfg.Scriptorium); err != nil {
return err return err
} }
@@ -299,6 +302,26 @@ func validateTrim(cfg *TrimConfig) error {
return nil return nil
} }
func validateRender(cfg *RenderConfig) error {
if cfg == nil {
return nil
}
if cfg.Enabled == nil {
return fmt.Errorf("pipeline.render.enabled must be set (defaults should populate this)")
}
if cfg.IncludeTimestamps == nil {
return fmt.Errorf("pipeline.render.include_timestamps must be set (defaults should populate this)")
}
format := strings.TrimSpace(cfg.Format)
if format != "markdown" {
return fmt.Errorf("pipeline.render.format must be markdown")
}
if cfg.Title != "" && strings.TrimSpace(cfg.Title) == "" {
return fmt.Errorf("pipeline.render.title must be non-empty when provided")
}
return nil
}
func validateWhisperX(cfg WhisperXConfig) error { func validateWhisperX(cfg WhisperXConfig) error {
if strings.TrimSpace(cfg.TranscribeURL) == "" { if strings.TrimSpace(cfg.TranscribeURL) == "" {
return fmt.Errorf("pipeline.whisperx.transcribe_url is required") return fmt.Errorf("pipeline.whisperx.transcribe_url is required")