package seriatim import ( "context" "encoding/json" "fmt" "strconv" "strings" "time" "unicode/utf8" "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" "gitea.maximumdirect.net/eric/narratio/internal/fileops" ) // MaxOutputFileBytes bounds each Seriatim JSON or rendered-text result. const MaxOutputFileBytes int64 = 64 * 1024 * 1024 // EnvConfig defines optional Seriatim environment tuning values. type EnvConfig struct { OverlapWordRunGap *float64 OverlapWordRunReorderWindow *float64 BackchannelMaxDuration *float64 FillerMaxDuration *float64 } // SubprocessRunnerConfig defines deterministic settings for Seriatim CLI execution. type SubprocessRunnerConfig struct { Binary string Timeout time.Duration OutputSchema string CoalesceGap *float64 Report bool Env EnvConfig } // SubprocessRunner invokes Seriatim via subprocess. type SubprocessRunner struct { binary string timeout time.Duration outputSchema string coalesceGap *float64 report bool env EnvConfig } // NewSubprocessRunnerFromConfigValues parses config-derived values once. func NewSubprocessRunnerFromConfigValues( binary string, timeout string, outputSchema string, coalesceGap *float64, report bool, env EnvConfig, ) (*SubprocessRunner, error) { if strings.TrimSpace(timeout) == "" { return nil, fmt.Errorf("seriatim timeout is required") } parsedTimeout, err := time.ParseDuration(timeout) if err != nil { return nil, fmt.Errorf("parse seriatim timeout %q: %w", timeout, err) } return NewSubprocessRunner(SubprocessRunnerConfig{ Binary: binary, Timeout: parsedTimeout, OutputSchema: outputSchema, CoalesceGap: coalesceGap, Report: report, Env: env, }) } // NewSubprocessRunner constructs a validated Seriatim subprocess runner. func NewSubprocessRunner(cfg SubprocessRunnerConfig) (*SubprocessRunner, error) { if strings.TrimSpace(cfg.Binary) == "" { return nil, fmt.Errorf("seriatim binary is required") } if cfg.Timeout <= 0 { return nil, fmt.Errorf("seriatim timeout must be > 0") } if strings.TrimSpace(cfg.OutputSchema) == "" { return nil, fmt.Errorf("seriatim output schema is required") } if err := validateOutputSchema(cfg.OutputSchema); err != nil { return nil, err } if cfg.CoalesceGap != nil && *cfg.CoalesceGap < 0 { return nil, fmt.Errorf("seriatim coalesce gap must be >= 0") } return &SubprocessRunner{ binary: cfg.Binary, timeout: cfg.Timeout, outputSchema: cfg.OutputSchema, coalesceGap: cfg.CoalesceGap, report: cfg.Report, env: cfg.Env, }, nil } func validateOutputSchema(schema string) error { switch strings.TrimSpace(schema) { case "seriatim-minimal", "seriatim-intermediate", "seriatim-full": return nil default: return fmt.Errorf("seriatim output schema %q is unsupported", schema) } } // Run executes Seriatim merge with deterministic flags and validates output artifacts. func (r *SubprocessRunner) Run(ctx context.Context, req MergeRequest) (MergeResult, error) { if r == nil { return MergeResult{}, fmt.Errorf("seriatim subprocess runner is nil") } if strings.TrimSpace(req.OutputMergedTranscriptPath) == "" { return MergeResult{}, fmt.Errorf("seriatim merge output path is required") } if len(req.InputTranscriptPaths) == 0 { return MergeResult{}, fmt.Errorf("seriatim merge requires at least one input transcript") } if r.report && strings.TrimSpace(req.ReportPath) == "" { return MergeResult{}, fmt.Errorf("seriatim report is enabled but report path is missing") } args := r.buildMergeArgs(req) env := r.buildEnvOverrides() if req.GeneratedConfigPath != "" { if err := r.writeMergeInvocationConfig(req, args); err != nil { return MergeResult{}, fmt.Errorf("write seriatim invocation config %q: %w", req.GeneratedConfigPath, err) } } runRes, err := subprocess.Run(ctx, subprocess.RunRequest{ Executable: r.binary, Args: args, Timeout: r.timeout, EnvOverrides: env, DiagnosticOwner: "seriatim", StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, }) if err != nil { return MergeResult{ MergedTranscriptPath: req.OutputMergedTranscriptPath, ReportPath: req.ReportPath, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, GeneratedConfigPath: req.GeneratedConfigPath, ExitCode: runRes.ExitCode, Duration: runRes.Duration, InvokedBinary: r.binary, OutputSchema: r.outputSchema, }, fmt.Errorf("run seriatim merge (binary=%q): %w", r.binary, err) } if err := validateJSONFile(req.OutputMergedTranscriptPath); err != nil { return MergeResult{ MergedTranscriptPath: req.OutputMergedTranscriptPath, ReportPath: req.ReportPath, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, GeneratedConfigPath: req.GeneratedConfigPath, ExitCode: runRes.ExitCode, Duration: runRes.Duration, InvokedBinary: r.binary, OutputSchema: r.outputSchema, }, fmt.Errorf("validate seriatim merged output %q: %w", req.OutputMergedTranscriptPath, err) } if r.report { if err := validateJSONFile(req.ReportPath); err != nil { return MergeResult{ MergedTranscriptPath: req.OutputMergedTranscriptPath, ReportPath: req.ReportPath, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, GeneratedConfigPath: req.GeneratedConfigPath, ExitCode: runRes.ExitCode, Duration: runRes.Duration, InvokedBinary: r.binary, OutputSchema: r.outputSchema, }, fmt.Errorf("validate seriatim report output %q: %w", req.ReportPath, err) } } return MergeResult{ MergedTranscriptPath: req.OutputMergedTranscriptPath, ReportPath: req.ReportPath, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, GeneratedConfigPath: req.GeneratedConfigPath, ExitCode: runRes.ExitCode, Duration: runRes.Duration, InvokedBinary: r.binary, OutputSchema: r.outputSchema, Metadata: map[string]any{ "adapter": "seriatim_subprocess", }, }, nil } // Trim executes Seriatim trim with deterministic flags and validates output artifacts. func (r *SubprocessRunner) Trim(ctx context.Context, req TrimRequest) (TrimResult, error) { if r == nil { return TrimResult{}, fmt.Errorf("seriatim subprocess runner is nil") } if strings.TrimSpace(req.InputTranscriptPath) == "" { return TrimResult{}, fmt.Errorf("seriatim trim input path is required") } if strings.TrimSpace(req.OutputTrimmedPath) == "" { return TrimResult{}, fmt.Errorf("seriatim trim output path is required") } if strings.TrimSpace(req.KeepSelector) == "" { return TrimResult{}, fmt.Errorf("seriatim trim keep selector is required") } binary := r.binary if strings.TrimSpace(req.Binary) != "" { binary = strings.TrimSpace(req.Binary) } timeout := r.timeout if req.Timeout < 0 { return TrimResult{}, fmt.Errorf("seriatim trim timeout must be >= 0") } if req.Timeout > 0 { timeout = req.Timeout } args := buildTrimArgs(req) if req.GeneratedConfigPath != "" { if err := writeTrimInvocationConfig(req, args, binary, timeout); err != nil { return TrimResult{}, fmt.Errorf("write seriatim trim invocation config %q: %w", req.GeneratedConfigPath, err) } } runRes, err := subprocess.Run(ctx, subprocess.RunRequest{ Executable: binary, Args: args, Timeout: timeout, DiagnosticOwner: "seriatim", StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, }) if err != nil { return TrimResult{ OutputTrimmedPath: req.OutputTrimmedPath, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, GeneratedConfigPath: req.GeneratedConfigPath, ExitCode: runRes.ExitCode, Duration: runRes.Duration, InvokedBinary: binary, KeepSelector: req.KeepSelector, }, fmt.Errorf("run seriatim trim (binary=%q): %w", binary, err) } if err := validateJSONFileWithSegments(req.OutputTrimmedPath); err != nil { return TrimResult{ OutputTrimmedPath: req.OutputTrimmedPath, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, GeneratedConfigPath: req.GeneratedConfigPath, ExitCode: runRes.ExitCode, Duration: runRes.Duration, InvokedBinary: binary, KeepSelector: req.KeepSelector, }, fmt.Errorf("validate seriatim trimmed output %q: %w", req.OutputTrimmedPath, err) } return TrimResult{ OutputTrimmedPath: req.OutputTrimmedPath, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, GeneratedConfigPath: req.GeneratedConfigPath, ExitCode: runRes.ExitCode, Duration: runRes.Duration, InvokedBinary: binary, KeepSelector: req.KeepSelector, Metadata: map[string]any{ "adapter": "seriatim_subprocess", }, }, nil } // Normalize executes Seriatim normalize with deterministic flags and validates output artifacts. func (r *SubprocessRunner) Normalize(ctx context.Context, req NormalizeRequest) (NormalizeResult, error) { if r == nil { return NormalizeResult{}, fmt.Errorf("seriatim subprocess runner is nil") } if strings.TrimSpace(req.InputTranscriptPath) == "" { return NormalizeResult{}, fmt.Errorf("seriatim normalize input path is required") } if strings.TrimSpace(req.OutputNormalizedPath) == "" { return NormalizeResult{}, fmt.Errorf("seriatim normalize output path is required") } binary := r.binary if strings.TrimSpace(req.Binary) != "" { binary = strings.TrimSpace(req.Binary) } timeout := r.timeout if req.Timeout < 0 { return NormalizeResult{}, fmt.Errorf("seriatim normalize timeout must be >= 0") } if req.Timeout > 0 { timeout = req.Timeout } outputSchema := strings.TrimSpace(req.OutputSchema) if outputSchema == "" { outputSchema = r.outputSchema } if err := validateOutputSchema(outputSchema); err != nil { return NormalizeResult{}, err } args := buildNormalizeArgs(req, outputSchema) if req.GeneratedConfigPath != "" { if err := writeNormalizeInvocationConfig(req, args, binary, timeout, outputSchema); err != nil { return NormalizeResult{}, fmt.Errorf("write seriatim normalize invocation config %q: %w", req.GeneratedConfigPath, err) } } runRes, err := subprocess.Run(ctx, subprocess.RunRequest{ Executable: binary, Args: args, Timeout: timeout, DiagnosticOwner: "seriatim", StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, }) if err != nil { return NormalizeResult{ OutputNormalizedPath: req.OutputNormalizedPath, ReportPath: req.ReportPath, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, GeneratedConfigPath: req.GeneratedConfigPath, ExitCode: runRes.ExitCode, Duration: runRes.Duration, InvokedBinary: binary, OutputSchema: outputSchema, }, fmt.Errorf("run seriatim normalize (binary=%q): %w", binary, err) } if err := validateJSONFileWithSegments(req.OutputNormalizedPath); err != nil { return NormalizeResult{ OutputNormalizedPath: req.OutputNormalizedPath, ReportPath: req.ReportPath, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, GeneratedConfigPath: req.GeneratedConfigPath, ExitCode: runRes.ExitCode, Duration: runRes.Duration, InvokedBinary: binary, OutputSchema: outputSchema, }, fmt.Errorf("validate seriatim normalized output %q: %w", req.OutputNormalizedPath, err) } if strings.TrimSpace(req.ReportPath) != "" { if err := validateJSONFile(req.ReportPath); err != nil { return NormalizeResult{ OutputNormalizedPath: req.OutputNormalizedPath, ReportPath: req.ReportPath, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, GeneratedConfigPath: req.GeneratedConfigPath, ExitCode: runRes.ExitCode, Duration: runRes.Duration, InvokedBinary: binary, OutputSchema: outputSchema, }, fmt.Errorf("validate seriatim normalize report output %q: %w", req.ReportPath, err) } } return NormalizeResult{ OutputNormalizedPath: req.OutputNormalizedPath, ReportPath: req.ReportPath, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, GeneratedConfigPath: req.GeneratedConfigPath, ExitCode: runRes.ExitCode, Duration: runRes.Duration, InvokedBinary: binary, OutputSchema: outputSchema, Metadata: map[string]any{ "adapter": "seriatim_subprocess", }, }, 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, DiagnosticOwner: "seriatim", 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"} for _, path := range req.InputTranscriptPaths { args = append(args, "--input-file", path) } args = append(args, "--output-file", req.OutputMergedTranscriptPath) if r.report { args = append(args, "--report-file", req.ReportPath) } if strings.TrimSpace(req.SpeakersPath) != "" { args = append(args, "--speakers", req.SpeakersPath) } if strings.TrimSpace(req.AutocorrectPath) != "" { args = append(args, "--autocorrect", req.AutocorrectPath) } args = append(args, "--output-schema", r.outputSchema) if r.coalesceGap != nil { args = append(args, "--coalesce-gap", strconv.FormatFloat(*r.coalesceGap, 'f', -1, 64)) } return args } func (r *SubprocessRunner) buildEnvOverrides() map[string]string { out := map[string]string{} if r.env.OverlapWordRunGap != nil { out["SERIATIM_OVERLAP_WORD_RUN_GAP"] = strconv.FormatFloat(*r.env.OverlapWordRunGap, 'f', -1, 64) } if r.env.OverlapWordRunReorderWindow != nil { out["SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW"] = strconv.FormatFloat(*r.env.OverlapWordRunReorderWindow, 'f', -1, 64) } if r.env.BackchannelMaxDuration != nil { out["SERIATIM_BACKCHANNEL_MAX_DURATION"] = strconv.FormatFloat(*r.env.BackchannelMaxDuration, 'f', -1, 64) } if r.env.FillerMaxDuration != nil { out["SERIATIM_FILLER_MAX_DURATION"] = strconv.FormatFloat(*r.env.FillerMaxDuration, 'f', -1, 64) } if len(out) == 0 { return nil } return out } func (r *SubprocessRunner) writeMergeInvocationConfig(req MergeRequest, args []string) error { payload := map[string]any{ "schema": "seriatim.generated.v1", "binary": r.binary, "command": "merge", "args": args, "timeout": r.timeout.String(), "output_schema": r.outputSchema, "report_enabled": r.report, "input_transcript_paths": req.InputTranscriptPaths, "output_path": req.OutputMergedTranscriptPath, } if req.ReportPath != "" { payload["report_path"] = req.ReportPath } if req.SpeakersPath != "" { payload["speakers_path"] = req.SpeakersPath } if req.AutocorrectPath != "" { payload["autocorrect_path"] = req.AutocorrectPath } if r.coalesceGap != nil { payload["coalesce_gap"] = *r.coalesceGap } return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode) } func buildTrimArgs(req TrimRequest) []string { return []string{ "trim", "--input-file", req.InputTranscriptPath, "--output-file", req.OutputTrimmedPath, "--keep", req.KeepSelector, } } func buildNormalizeArgs(req NormalizeRequest, outputSchema string) []string { args := []string{ "normalize", "--input-file", req.InputTranscriptPath, "--output-file", req.OutputNormalizedPath, "--output-schema", outputSchema, } if strings.TrimSpace(req.ReportPath) != "" { args = append(args, "--report-file", req.ReportPath) } 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", "command": "trim", "binary": binary, "args": args, "timeout": timeout.String(), "input_path": req.InputTranscriptPath, "output_path": req.OutputTrimmedPath, "keep_selector": req.KeepSelector, } return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode) } func writeNormalizeInvocationConfig(req NormalizeRequest, args []string, binary string, timeout time.Duration, outputSchema string) error { payload := map[string]any{ "schema": "seriatim.generated.v1", "command": "normalize", "binary": binary, "args": args, "timeout": timeout.String(), "input_path": req.InputTranscriptPath, "output_path": req.OutputNormalizedPath, "output_schema": outputSchema, "report_path": req.ReportPath, } return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode) } 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, fileops.WorkspaceFileMode) } func validateJSONFile(path string) error { data, err := readSeriatimResult(path, "JSON output") if err != nil { return err } var v any if err := json.Unmarshal(data, &v); err != nil { return fmt.Errorf("parse json: %w", err) } return nil } func validateJSONFileWithSegments(path string) error { data, err := readSeriatimResult(path, "transcript JSON output") if err != nil { return err } var payload map[string]any if err := json.Unmarshal(data, &payload); err != nil { return fmt.Errorf("parse json: %w", err) } segments, ok := payload["segments"] if !ok { return fmt.Errorf("top-level segments is required") } if _, ok := segments.([]any); !ok { return fmt.Errorf("top-level segments must be an array") } return nil } func validateNonEmptyTextFile(path string) error { data, err := readSeriatimResult(path, "rendered text output") if err != nil { return 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 } func readSeriatimResult(path, category string) ([]byte, error) { data, err := fileops.ReadRegularFile(path, MaxOutputFileBytes) if err != nil { return nil, fmt.Errorf("seriatim %s exceeds or cannot be read within %d-byte limit: %w", category, MaxOutputFileBytes, err) } return data, nil }