package stage import ( "context" "errors" "fmt" "path/filepath" "strings" "gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) type renderStage struct{} func (renderStage) Name() string { return "render" } func (renderStage) Declares() IODecl { return IODecl{ Inputs: []artifacts.Ref{ {Kind: "transcript_final", Category: "transcripts", RelativePath: "transcripts/final.json"}, {Kind: "transcript_final_trimmed", Category: "transcripts", RelativePath: "transcripts/final.trimmed.json"}, }, Outputs: []artifacts.Ref{ {Kind: artifacts.TranscriptOutputKindFinalMarkdown, Category: "transcripts", RelativePath: artifacts.TranscriptPathFinalMarkdown}, {Kind: artifacts.TranscriptOutputKindFinalTrimmedMarkdown, Category: "transcripts", RelativePath: artifacts.TranscriptPathFinalTrimmedMarkdown}, }, } } func (renderStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) { if env == nil || env.Config == nil { return nil, fmt.Errorf("render: stage environment config is required") } if env.ArtifactStore == nil { return nil, fmt.Errorf("render: artifact store is required") } if env.Config.Pipeline == nil || env.Config.Session == nil { return nil, fmt.Errorf("render: resolved config must include pipeline and session") } if env.Seriatim == nil { return nil, fmt.Errorf("render: seriatim adapter is required") } var sessionID string if m != nil { sessionID = strings.TrimSpace(m.SessionID) } if sessionID == "" { sessionID = strings.TrimSpace(env.Config.Session.SessionID) } if sessionID == "" { return nil, fmt.Errorf("render: session id is required") } paths := sessionPathsForEnv(env, sessionID) runLayout, err := resolveRunStageLayout(env, m, paths, sessionID, "render") if err != nil { return nil, fmt.Errorf("render: resolve run-stage layout: %w", err) } renderCfg := renderConfigOrDefault(env.Config.Pipeline.Render) enabled := renderCfg.Enabled == nil || *renderCfg.Enabled format := strings.TrimSpace(renderCfg.Format) if format == "" { format = config.DefaultRenderFormat } title := resolveRenderTitle(renderCfg, env.Config.Session) includeTimestamps := renderCfg.IncludeTimestamps == nil || *renderCfg.IncludeTimestamps includeSegmentIDs := config.DefaultRenderSegmentIDs if renderCfg.IncludeSegmentIDs != nil { includeSegmentIDs = *renderCfg.IncludeSegmentIDs } includeMetadata := renderCfg.IncludeMetadata meta := map[string]any{ "stage": "render", "render_enabled": enabled, "format": format, "title": title, "include_timestamps": includeTimestamps, "include_segment_ids": includeSegmentIDs, "include_metadata": includeMetadata, "binary": env.Config.Pipeline.Seriatim.Binary, "timeout": env.Config.Pipeline.Seriatim.Timeout, } if !enabled { meta["skipped"] = true meta["reason"] = "pipeline.render.enabled is false" return &StageResult{Metadata: meta}, nil } finalInput, err := artifacts.ResolveSessionArtifact(paths, m, artifacts.ArtifactTranscriptFinal) if err != nil { return nil, wrapRenderInputResolveError(err, sessionID, artifacts.ArtifactTranscriptFinal, "normalize") } finalTrimmedInput, err := artifacts.ResolveSessionArtifact(paths, m, artifacts.ArtifactTranscriptFinalTrimmed) if err != nil { return nil, wrapRenderInputResolveError(err, sessionID, artifacts.ArtifactTranscriptFinalTrimmed, "trim") } canonicalFinalMarkdownPath, err := resolveScriptoriumOutputPath(paths, artifacts.TranscriptPathFinalMarkdown) if err != nil { return nil, fmt.Errorf("render: resolve canonical final markdown output path: %w", err) } canonicalFinalTrimmedMarkdownPath, err := resolveScriptoriumOutputPath(paths, artifacts.TranscriptPathFinalTrimmedMarkdown) if err != nil { return nil, fmt.Errorf("render: resolve canonical final trimmed markdown output path: %w", err) } runFinalMarkdownPath, err := runLocalPathForCanonical(runLayout, paths, canonicalFinalMarkdownPath) if err != nil { return nil, fmt.Errorf("render: resolve run-local final markdown output path: %w", err) } runFinalTrimmedMarkdownPath, err := runLocalPathForCanonical(runLayout, paths, canonicalFinalTrimmedMarkdownPath) if err != nil { return nil, fmt.Errorf("render: resolve run-local final trimmed markdown output path: %w", err) } finalStdoutLogPath := filepath.Join(paths.LogsDir, "seriatim.render.final.stdout.log") finalStderrLogPath := filepath.Join(paths.LogsDir, "seriatim.render.final.stderr.log") finalGeneratedConfigPath := filepath.Join(paths.ConfigDir, "seriatim.render.final.generated.yml") finalTrimmedStdoutLogPath := filepath.Join(paths.LogsDir, "seriatim.render.final_trimmed.stdout.log") finalTrimmedStderrLogPath := filepath.Join(paths.LogsDir, "seriatim.render.final_trimmed.stderr.log") finalTrimmedGeneratedConfigPath := filepath.Join(paths.ConfigDir, "seriatim.render.final_trimmed.generated.yml") if runLayout.Enabled { finalStdoutLogPath = filepath.Join(runLayout.LogsDir, "seriatim.render.final.stdout.log") finalStderrLogPath = filepath.Join(runLayout.LogsDir, "seriatim.render.final.stderr.log") finalGeneratedConfigPath = filepath.Join(runLayout.ConfigDir, "seriatim.render.final.generated.yml") finalTrimmedStdoutLogPath = filepath.Join(runLayout.LogsDir, "seriatim.render.final_trimmed.stdout.log") finalTrimmedStderrLogPath = filepath.Join(runLayout.LogsDir, "seriatim.render.final_trimmed.stderr.log") finalTrimmedGeneratedConfigPath = filepath.Join(runLayout.ConfigDir, "seriatim.render.final_trimmed.generated.yml") } timeout, err := resolveSeriatimStageTimeout(env.Config.Pipeline.Seriatim.Timeout) if err != nil { return nil, fmt.Errorf("render: resolve seriatim timeout: %w", err) } finalReq := seriatim.RenderRequest{ Binary: env.Config.Pipeline.Seriatim.Binary, InputTranscriptPath: finalInput.Path, OutputRenderedPath: runFinalMarkdownPath, Format: format, Title: title, IncludeTimestamps: includeTimestamps, IncludeSegmentIDs: includeSegmentIDs, IncludeMetadata: includeMetadata, StdoutLogPath: finalStdoutLogPath, StderrLogPath: finalStderrLogPath, GeneratedConfigPath: finalGeneratedConfigPath, Timeout: timeout, } finalRes, err := env.Seriatim.Render(ctx, finalReq) if err != nil { return nil, fmt.Errorf("render: seriatim render failed for %q: %w", artifacts.ArtifactTranscriptFinal, err) } finalRenderedPath := coalesceString(finalRes.OutputRenderedPath, finalReq.OutputRenderedPath) if err := requireNonEmptyFile(finalRenderedPath, "final transcript markdown output"); err != nil { return nil, fmt.Errorf("render: %w", err) } finalTrimmedReq := seriatim.RenderRequest{ Binary: env.Config.Pipeline.Seriatim.Binary, InputTranscriptPath: finalTrimmedInput.Path, OutputRenderedPath: runFinalTrimmedMarkdownPath, Format: format, Title: title, IncludeTimestamps: includeTimestamps, IncludeSegmentIDs: includeSegmentIDs, IncludeMetadata: includeMetadata, StdoutLogPath: finalTrimmedStdoutLogPath, StderrLogPath: finalTrimmedStderrLogPath, GeneratedConfigPath: finalTrimmedGeneratedConfigPath, Timeout: timeout, } finalTrimmedRes, err := env.Seriatim.Render(ctx, finalTrimmedReq) if err != nil { return nil, fmt.Errorf("render: seriatim render failed for %q: %w", artifacts.ArtifactTranscriptFinalTrimmed, err) } finalTrimmedRenderedPath := coalesceString(finalTrimmedRes.OutputRenderedPath, finalTrimmedReq.OutputRenderedPath) if err := requireNonEmptyFile(finalTrimmedRenderedPath, "final trimmed transcript markdown output"); err != nil { return nil, fmt.Errorf("render: %w", err) } materializedFinalMarkdown, err := materializeRunLocalOutput(env.ArtifactStore, finalRenderedPath, canonicalFinalMarkdownPath, artifacts.Ref{ Kind: artifacts.TranscriptOutputKindFinalMarkdown, Category: "transcripts", SessionID: sessionID, }) if err != nil { return nil, fmt.Errorf("render: materialize canonical final markdown output: %w", err) } materializedFinalTrimmedMarkdown, err := materializeRunLocalOutput(env.ArtifactStore, finalTrimmedRenderedPath, canonicalFinalTrimmedMarkdownPath, artifacts.Ref{ Kind: artifacts.TranscriptOutputKindFinalTrimmedMarkdown, Category: "transcripts", SessionID: sessionID, }) if err != nil { return nil, fmt.Errorf("render: materialize canonical final trimmed markdown output: %w", err) } meta["final_input_path"] = finalInput.Path meta["final_input_provenance"] = finalInput.Provenance meta["final_input_run_id"] = finalInput.ProducerRunID meta["final_trimmed_input_path"] = finalTrimmedInput.Path meta["final_trimmed_input_provenance"] = finalTrimmedInput.Provenance meta["final_trimmed_input_run_id"] = finalTrimmedInput.ProducerRunID meta["run_final_markdown_path"] = finalRenderedPath meta["final_markdown_path"] = canonicalFinalMarkdownPath meta["run_final_trimmed_markdown_path"] = finalTrimmedRenderedPath meta["final_trimmed_markdown_path"] = canonicalFinalTrimmedMarkdownPath populateRenderAdapterMetadata(meta, "final_adapter_", finalRes) populateRenderAdapterMetadata(meta, "final_trimmed_adapter_", finalTrimmedRes) return &StageResult{ Outputs: []artifacts.Ref{ materializedFinalMarkdown, materializedFinalTrimmedMarkdown, }, Logs: dedupeAndSortPaths([]string{ finalStdoutLogPath, finalStderrLogPath, finalTrimmedStdoutLogPath, finalTrimmedStderrLogPath, }), GeneratedConfigs: dedupeAndSortPaths([]string{ finalGeneratedConfigPath, finalTrimmedGeneratedConfigPath, }), Metadata: meta, }, nil } func renderConfigOrDefault(cfg *config.RenderConfig) *config.RenderConfig { if cfg != nil { return cfg } enabled := true includeTimestamps := true includeSegmentIDs := config.DefaultRenderSegmentIDs return &config.RenderConfig{ Enabled: &enabled, Format: config.DefaultRenderFormat, IncludeTimestamps: &includeTimestamps, IncludeSegmentIDs: &includeSegmentIDs, IncludeMetadata: config.DefaultRenderMetadata, } } func resolveRenderTitle(renderCfg *config.RenderConfig, sessionCfg *config.SessionConfig) string { if renderCfg != nil && strings.TrimSpace(renderCfg.Title) != "" { return strings.TrimSpace(renderCfg.Title) } if sessionCfg != nil && strings.TrimSpace(sessionCfg.Title) != "" { return strings.TrimSpace(sessionCfg.Title) } return "" } func wrapRenderInputResolveError(err error, sessionID, sourceID, guidanceStage string) error { var notFound *artifacts.SessionArtifactNotFoundError if errors.As(err, ¬Found) { return fmt.Errorf( "render: required input %q is unavailable; run narratio run-stage %s %s --force", sourceID, guidanceStage, sessionID, ) } return fmt.Errorf("render: resolve %q input: %w", sourceID, err) } func populateRenderAdapterMetadata(meta map[string]any, prefix string, result seriatim.RenderResult) { if meta == nil { return } meta[prefix+"duration_ms"] = result.Duration.Milliseconds() meta[prefix+"exit_code"] = result.ExitCode meta[prefix+"invoked_binary"] = result.InvokedBinary meta[prefix+"format"] = result.Format meta[prefix+"title"] = result.Title meta[prefix+"output_path"] = result.OutputRenderedPath meta[prefix+"generated_config"] = result.GeneratedConfigPath meta[prefix+"stdout_log_path"] = result.StdoutLogPath meta[prefix+"stderr_log_path"] = result.StderrLogPath if result.Metadata != nil { meta[prefix+"metadata"] = result.Metadata } }