Add render artifact model, config, and Seriatim adapter contracts
This commit is contained in:
@@ -68,6 +68,26 @@ func (n *NoopRunner) Normalize(ctx context.Context, req NormalizeRequest) (Norma
|
||||
}, 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.
|
||||
type FakeRunner struct {
|
||||
Requests []MergeRequest
|
||||
@@ -79,6 +99,9 @@ type FakeRunner struct {
|
||||
TrimRequests []TrimRequest
|
||||
TrimErr error
|
||||
TrimResult TrimResult
|
||||
RenderRequests []RenderRequest
|
||||
RenderErr error
|
||||
RenderResult RenderResult
|
||||
}
|
||||
|
||||
// Run records request and returns configured response.
|
||||
@@ -195,6 +218,46 @@ func (f *FakeRunner) Normalize(ctx context.Context, req NormalizeRequest) (Norma
|
||||
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 {
|
||||
if req.OutputMergedTranscriptPath != "" {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -148,3 +148,58 @@ func TestFakeRunnerNormalizeError(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
@@ -6,11 +6,12 @@ import (
|
||||
"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 {
|
||||
Run(ctx context.Context, req MergeRequest) (MergeResult, error)
|
||||
Normalize(ctx context.Context, req NormalizeRequest) (NormalizeResult, error)
|
||||
Trim(ctx context.Context, req TrimRequest) (TrimResult, error)
|
||||
Render(ctx context.Context, req RenderRequest) (RenderResult, error)
|
||||
}
|
||||
|
||||
// MergeRequest describes a seriatim merge invocation.
|
||||
@@ -90,3 +91,33 @@ type TrimResult struct {
|
||||
KeepSelector string
|
||||
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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
||||
)
|
||||
@@ -384,6 +385,96 @@ func (r *SubprocessRunner) Normalize(ctx context.Context, req NormalizeRequest)
|
||||
}, 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 {
|
||||
args := []string{"merge"}
|
||||
|
||||
@@ -480,6 +571,22 @@ func buildNormalizeArgs(req NormalizeRequest, outputSchema string) []string {
|
||||
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 {
|
||||
payload := map[string]any{
|
||||
"schema": "seriatim.generated.v1",
|
||||
@@ -509,6 +616,24 @@ func writeNormalizeInvocationConfig(req NormalizeRequest, args []string, binary
|
||||
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 {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -541,3 +666,20 @@ func validateJSONFileWithSegments(path string) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
_, err := NewSubprocessRunnerFromConfigValues("", "10m", "seriatim-intermediate", nil, true, EnvConfig{})
|
||||
if err == nil {
|
||||
@@ -702,6 +852,14 @@ func TestSeriatimSubprocessHelper(t *testing.T) {
|
||||
case "normalize_report_missing":
|
||||
writeSeriatimHelperFile(outputPath, `{"schema":"seriatim.intermediate.v1","segments":[]}`)
|
||||
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:
|
||||
_, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode))
|
||||
os.Exit(2)
|
||||
@@ -777,6 +935,25 @@ func normalizeReqForTest(t *testing.T, withReport bool) NormalizeRequest {
|
||||
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 {
|
||||
t.Helper()
|
||||
coalesce := 3.0
|
||||
|
||||
@@ -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) {
|
||||
lockSet := lockSourceSet(locks.All)
|
||||
fmt.Fprintln(out, "Built-in:")
|
||||
for _, id := range []string{
|
||||
artifacts.ArtifactTranscriptBase,
|
||||
artifacts.ArtifactTranscriptPolished,
|
||||
artifacts.ArtifactTranscriptFinal,
|
||||
artifacts.ArtifactTranscriptFinalTrimmed,
|
||||
artifacts.ArtifactBoundsSession,
|
||||
} {
|
||||
writeArtifactLine(out, id, lockSet)
|
||||
for _, transcript := range artifacts.RuntimeTranscriptArtifacts() {
|
||||
writeArtifactLine(out, transcript.SourceID, lockSet)
|
||||
}
|
||||
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
|
||||
fmt.Fprintln(out, "Configured:")
|
||||
for _, entry := range catalog.ListConfigured() {
|
||||
writeArtifactLine(out, entry.SourceID, lockSet)
|
||||
|
||||
@@ -3,24 +3,30 @@ package artifactmodel
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
SourceTranscriptBase = "narratio.transcript.base"
|
||||
SourceTranscriptPolished = "narratio.transcript.polished"
|
||||
SourceTranscriptFinal = "narratio.transcript.final"
|
||||
SourceTranscriptFinalTrimmed = "narratio.transcript.final_trimmed"
|
||||
SourceTranscriptBase = "narratio.transcript.base"
|
||||
SourceTranscriptPolished = "narratio.transcript.polished"
|
||||
SourceTranscriptFinal = "narratio.transcript.final"
|
||||
SourceTranscriptFinalTrimmed = "narratio.transcript.final_trimmed"
|
||||
SourceTranscriptFinalMarkdown = "narratio.transcript.final_markdown"
|
||||
SourceTranscriptFinalTrimmedMarkdown = "narratio.transcript.final_trimmed_markdown"
|
||||
)
|
||||
|
||||
const (
|
||||
TranscriptPathBase = "transcripts/base.json"
|
||||
TranscriptPathPolished = "transcripts/polished.json"
|
||||
TranscriptPathFinal = "transcripts/final.json"
|
||||
TranscriptPathFinalTrimmed = "transcripts/final.trimmed.json"
|
||||
TranscriptPathBase = "transcripts/base.json"
|
||||
TranscriptPathPolished = "transcripts/polished.json"
|
||||
TranscriptPathFinal = "transcripts/final.json"
|
||||
TranscriptPathFinalTrimmed = "transcripts/final.trimmed.json"
|
||||
TranscriptPathFinalMarkdown = "transcripts/final.md"
|
||||
TranscriptPathFinalTrimmedMarkdown = "transcripts/final.trimmed.md"
|
||||
)
|
||||
|
||||
const (
|
||||
TranscriptOutputKindBase = "transcript_base"
|
||||
TranscriptOutputKindPolished = "transcript_polished"
|
||||
TranscriptOutputKindFinal = "transcript_final"
|
||||
TranscriptOutputKindFinalTrimmed = "transcript_final_trimmed"
|
||||
TranscriptOutputKindBase = "transcript_base"
|
||||
TranscriptOutputKindPolished = "transcript_polished"
|
||||
TranscriptOutputKindFinal = "transcript_final"
|
||||
TranscriptOutputKindFinalTrimmed = "transcript_final_trimmed"
|
||||
TranscriptOutputKindFinalMarkdown = "transcript_final_markdown"
|
||||
TranscriptOutputKindFinalTrimmedMarkdown = "transcript_final_trimmed_markdown"
|
||||
)
|
||||
|
||||
// TranscriptArtifactSpec describes one built-in transcript artifact mapping.
|
||||
@@ -56,6 +62,18 @@ var runtimeTranscriptArtifacts = []TranscriptArtifactSpec{
|
||||
ProducerStage: "trim",
|
||||
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.
|
||||
|
||||
60
internal/artifactmodel/transcripts_test.go
Normal file
60
internal/artifactmodel/transcripts_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,28 +14,34 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ArtifactTranscriptBase = artifactmodel.SourceTranscriptBase
|
||||
ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished
|
||||
ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal
|
||||
ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed
|
||||
ArtifactBoundsSession = "narratio.bounds.session"
|
||||
ArtifactTranscriptBase = artifactmodel.SourceTranscriptBase
|
||||
ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished
|
||||
ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal
|
||||
ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed
|
||||
ArtifactTranscriptFinalMarkdown = artifactmodel.SourceTranscriptFinalMarkdown
|
||||
ArtifactTranscriptFinalTrimmedMarkdown = artifactmodel.SourceTranscriptFinalTrimmedMarkdown
|
||||
ArtifactBoundsSession = "narratio.bounds.session"
|
||||
|
||||
ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache"
|
||||
ArtifactProvenancePreviousCacheFilesystem = "current_session.previous_cache"
|
||||
)
|
||||
|
||||
const (
|
||||
TranscriptPathBase = artifactmodel.TranscriptPathBase
|
||||
TranscriptPathPolished = artifactmodel.TranscriptPathPolished
|
||||
TranscriptPathFinal = artifactmodel.TranscriptPathFinal
|
||||
TranscriptPathFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed
|
||||
TranscriptPathBase = artifactmodel.TranscriptPathBase
|
||||
TranscriptPathPolished = artifactmodel.TranscriptPathPolished
|
||||
TranscriptPathFinal = artifactmodel.TranscriptPathFinal
|
||||
TranscriptPathFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed
|
||||
TranscriptPathFinalMarkdown = artifactmodel.TranscriptPathFinalMarkdown
|
||||
TranscriptPathFinalTrimmedMarkdown = artifactmodel.TranscriptPathFinalTrimmedMarkdown
|
||||
)
|
||||
|
||||
const (
|
||||
TranscriptOutputKindBase = artifactmodel.TranscriptOutputKindBase
|
||||
TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished
|
||||
TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal
|
||||
TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed
|
||||
TranscriptOutputKindBase = artifactmodel.TranscriptOutputKindBase
|
||||
TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished
|
||||
TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal
|
||||
TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed
|
||||
TranscriptOutputKindFinalMarkdown = artifactmodel.TranscriptOutputKindFinalMarkdown
|
||||
TranscriptOutputKindFinalTrimmedMarkdown = artifactmodel.TranscriptOutputKindFinalTrimmedMarkdown
|
||||
)
|
||||
|
||||
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
|
||||
@@ -67,7 +73,7 @@ func buildArtifactRegistry() map[string]artifactSpec {
|
||||
CanonicalRelPath: transcript.CanonicalRelPath,
|
||||
ProducerStage: transcript.ProducerStage,
|
||||
OutputKind: transcript.OutputKind,
|
||||
ContentKind: contentTranscriptJSON,
|
||||
ContentKind: transcriptContentKind(transcript),
|
||||
}
|
||||
}
|
||||
registry[ArtifactBoundsSession] = artifactSpec{
|
||||
@@ -80,6 +86,15 @@ func buildArtifactRegistry() map[string]artifactSpec {
|
||||
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.
|
||||
type ResolvedSessionArtifact struct {
|
||||
ID string
|
||||
|
||||
@@ -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) {
|
||||
workspace := t.TempDir()
|
||||
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) {
|
||||
workspace := t.TempDir()
|
||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||
|
||||
@@ -216,11 +216,10 @@ func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error {
|
||||
}
|
||||
|
||||
func runtimeBuiltInArtifactIDs() []string {
|
||||
return []string{
|
||||
ArtifactTranscriptBase,
|
||||
ArtifactTranscriptPolished,
|
||||
ArtifactTranscriptFinal,
|
||||
ArtifactTranscriptFinalTrimmed,
|
||||
ArtifactBoundsSession,
|
||||
ids := make([]string, 0, len(RuntimeTranscriptArtifacts())+1)
|
||||
for _, transcript := range RuntimeTranscriptArtifacts() {
|
||||
ids = append(ids, transcript.SourceID)
|
||||
}
|
||||
ids = append(ids, ArtifactBoundsSession)
|
||||
return ids
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
catalog := NewArtifactCatalog()
|
||||
if err := catalog.RegisterConfiguredArtifacts(map[string]ConfiguredArtifactDefinition{
|
||||
|
||||
@@ -31,6 +31,18 @@ func TestRuntimeTranscriptArtifacts(t *testing.T) {
|
||||
ProducerStage: "trim",
|
||||
OutputKind: TranscriptOutputKindFinalTrimmed,
|
||||
},
|
||||
{
|
||||
SourceID: ArtifactTranscriptFinalMarkdown,
|
||||
CanonicalRelPath: TranscriptPathFinalMarkdown,
|
||||
ProducerStage: "render",
|
||||
OutputKind: TranscriptOutputKindFinalMarkdown,
|
||||
},
|
||||
{
|
||||
SourceID: ArtifactTranscriptFinalTrimmedMarkdown,
|
||||
CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown,
|
||||
ProducerStage: "render",
|
||||
OutputKind: TranscriptOutputKindFinalTrimmedMarkdown,
|
||||
},
|
||||
}
|
||||
|
||||
got := RuntimeTranscriptArtifacts()
|
||||
@@ -75,6 +87,18 @@ func TestPlannedTranscriptArtifacts(t *testing.T) {
|
||||
ProducerStage: "trim",
|
||||
OutputKind: TranscriptOutputKindFinalTrimmed,
|
||||
},
|
||||
{
|
||||
SourceID: ArtifactTranscriptFinalMarkdown,
|
||||
CanonicalRelPath: TranscriptPathFinalMarkdown,
|
||||
ProducerStage: "render",
|
||||
OutputKind: TranscriptOutputKindFinalMarkdown,
|
||||
},
|
||||
{
|
||||
SourceID: ArtifactTranscriptFinalTrimmedMarkdown,
|
||||
CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown,
|
||||
ProducerStage: "render",
|
||||
OutputKind: TranscriptOutputKindFinalTrimmedMarkdown,
|
||||
},
|
||||
}
|
||||
|
||||
got := PlannedTranscriptArtifacts()
|
||||
@@ -113,10 +137,14 @@ func TestRuntimeArtifactRegistryUsesTranscriptSpecs(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("artifactRegistry missing %q", transcript.SourceID)
|
||||
}
|
||||
wantContentKind := contentTranscriptJSON
|
||||
if transcript.SourceID == ArtifactTranscriptFinalMarkdown || transcript.SourceID == ArtifactTranscriptFinalTrimmedMarkdown {
|
||||
wantContentKind = contentText
|
||||
}
|
||||
if spec.CanonicalRelPath != transcript.CanonicalRelPath ||
|
||||
spec.ProducerStage != transcript.ProducerStage ||
|
||||
spec.OutputKind != transcript.OutputKind ||
|
||||
spec.ContentKind != contentTranscriptJSON {
|
||||
spec.ContentKind != wantContentKind {
|
||||
t.Fatalf("artifactRegistry[%q] = %#v, want transcript spec %#v", transcript.SourceID, spec, transcript)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ type PipelineConfig struct {
|
||||
Audita AuditaConfig `yaml:"audita"`
|
||||
Normalize *NormalizeConfig `yaml:"normalize"`
|
||||
Trim *TrimConfig `yaml:"trim"`
|
||||
Render *RenderConfig `yaml:"render"`
|
||||
Scriptorium *ScriptoriumConfig `yaml:"scriptorium"`
|
||||
Notification NotificationConfig `yaml:"notification"`
|
||||
}
|
||||
@@ -209,6 +210,16 @@ type TrimSeriatimConfig struct {
|
||||
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.
|
||||
type ScriptoriumConfig struct {
|
||||
Binary string `yaml:"binary"`
|
||||
|
||||
@@ -40,6 +40,12 @@ const (
|
||||
|
||||
DefaultTrimBoundsTimeout = "10m"
|
||||
DefaultTrimSeriatimReport = false
|
||||
DefaultRenderEnabled = true
|
||||
DefaultRenderFormat = "markdown"
|
||||
DefaultRenderTitle = ""
|
||||
DefaultRenderTimestamps = true
|
||||
DefaultRenderSegmentIDs = false
|
||||
DefaultRenderMetadata = false
|
||||
|
||||
DefaultNormalizeOutputPath = artifactmodel.TranscriptPathFinal
|
||||
DefaultNormalizeOutputSchema = "seriatim-intermediate"
|
||||
|
||||
@@ -337,6 +337,7 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
|
||||
}
|
||||
applyNormalizeDefaults(cfg.Normalize)
|
||||
applyTrimDefaults(cfg.Trim)
|
||||
applyRenderDefaults(&cfg.Render)
|
||||
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) {
|
||||
if cfg == nil {
|
||||
return
|
||||
|
||||
128
internal/config/render_test.go
Normal file
128
internal/config/render_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,9 @@ func validatePipeline(cfg *PipelineConfig) error {
|
||||
if err := validateTrim(cfg.Trim); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRender(cfg.Render); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateScriptorium(cfg.Scriptorium); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -299,6 +302,26 @@ func validateTrim(cfg *TrimConfig) error {
|
||||
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 {
|
||||
if strings.TrimSpace(cfg.TranscribeURL) == "" {
|
||||
return fmt.Errorf("pipeline.whisperx.transcribe_url is required")
|
||||
|
||||
Reference in New Issue
Block a user