Implement render stage runtime and integrate it into pipeline execution
This commit is contained in:
@@ -127,7 +127,7 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
|
|||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||||
seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
||||||
@@ -143,7 +143,7 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "executed=0 skipped=9") {
|
if !strings.Contains(out.String(), "executed=0 skipped=10") {
|
||||||
t.Fatalf("output = %q, want all stages skipped", out.String())
|
t.Fatalf("output = %q, want all stages skipped", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ func TestExecuteValidCommands(t *testing.T) {
|
|||||||
args []string
|
args []string
|
||||||
wantOut string
|
wantOut string
|
||||||
}{
|
}{
|
||||||
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="},
|
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=10 skipped=0; manifest="},
|
||||||
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\npublish: skip\nnotify: skip"},
|
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nrender: skip\nanalyze: skip\npublish: skip\nnotify: skip"},
|
||||||
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
|
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
|
||||||
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
|
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
|
||||||
}
|
}
|
||||||
@@ -331,7 +331,7 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T)
|
|||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03; executed=9 skipped=0; manifest=") {
|
if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03; executed=10 skipped=0; manifest=") {
|
||||||
t.Fatalf("stdout = %q, want successful run output", stdout.String())
|
t.Fatalf("stdout = %q, want successful run output", stdout.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -997,7 +997,7 @@ func TestExecutePublishLoadsRemoteLocks(t *testing.T) {
|
|||||||
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
|
||||||
|
|
||||||
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
||||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
|
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
|
||||||
// The publish stage only checks the manifest statuses and source files.
|
// The publish stage only checks the manifest statuses and source files.
|
||||||
_ = stageName
|
_ = stageName
|
||||||
}
|
}
|
||||||
@@ -1065,7 +1065,7 @@ func writeValidPublishRunConfigFiles(t *testing.T, workspaceRoot string) (string
|
|||||||
m := manifest.New("2026-05-03", nowUTC())
|
m := manifest.New("2026-05-03", nowUTC())
|
||||||
m.Campaign = "sample-campaign"
|
m.Campaign = "sample-campaign"
|
||||||
m.RunID = "20260521T160000Z-test"
|
m.RunID = "20260521T160000Z-test"
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
|
||||||
m.MarkStageSucceeded(name, nowUTC(), nil)
|
m.MarkStageSucceeded(name, nowUTC(), nil)
|
||||||
}
|
}
|
||||||
path := artifacts.SessionManifestPathForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
path := artifacts.SessionManifestPathForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
|||||||
if !strings.Contains(got, "narratio session plan: workdir prepared at") {
|
if !strings.Contains(got, "narratio session plan: workdir prepared at") {
|
||||||
t.Fatalf("first output = %q, want workdir prepared", got)
|
t.Fatalf("first output = %q, want workdir prepared", got)
|
||||||
}
|
}
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||||
if !strings.Contains(got, name+": run") {
|
if !strings.Contains(got, name+": run") {
|
||||||
t.Fatalf("first output = %q, missing stage %q", got, name)
|
t.Fatalf("first output = %q, missing stage %q", got, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "totals: run=9 skip=0") {
|
if !strings.Contains(got, "totals: run=10 skip=0") {
|
||||||
t.Fatalf("first output = %q, want totals", got)
|
t.Fatalf("first output = %q, want totals", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +84,8 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
|
|||||||
if !strings.Contains(got, "trim: run") {
|
if !strings.Contains(got, "trim: run") {
|
||||||
t.Fatalf("output = %q, want trim run", got)
|
t.Fatalf("output = %q, want trim run", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "totals: run=7 skip=2") {
|
if !strings.Contains(got, "totals: run=8 skip=2") {
|
||||||
t.Fatalf("output = %q, want totals run=7 skip=2", got)
|
t.Fatalf("output = %q, want totals run=8 skip=2", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import "testing"
|
|||||||
|
|
||||||
func TestBuildFullPlanOrder(t *testing.T) {
|
func TestBuildFullPlanOrder(t *testing.T) {
|
||||||
got := BuildFullPlan()
|
got := BuildFullPlan()
|
||||||
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"}
|
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"}
|
||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
t.Fatalf("len(plan) = %d, want %d", len(got), len(want))
|
t.Fatalf("len(plan) = %d, want %d", len(got), len(want))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ func publishStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load() error = %v", err)
|
t.Fatalf("Load() error = %v", err)
|
||||||
}
|
}
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
|
||||||
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
}
|
}
|
||||||
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func TestDecideStageActions(t *testing.T) {
|
|||||||
|
|
||||||
func TestDownstreamStageNames(t *testing.T) {
|
func TestDownstreamStageNames(t *testing.T) {
|
||||||
got := downstreamStageNames("polish")
|
got := downstreamStageNames("polish")
|
||||||
want := []string{"normalize", "trim", "analyze", "publish", "notify"}
|
want := []string{"normalize", "trim", "render", "analyze", "publish", "notify"}
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("downstreamStageNames(polish) = %#v, want %#v", got, want)
|
t.Fatalf("downstreamStageNames(polish) = %#v, want %#v", got, want)
|
||||||
}
|
}
|
||||||
@@ -52,12 +52,13 @@ func TestInvalidateDownstreamSucceededStages(t *testing.T) {
|
|||||||
m.MarkStageSucceeded("polish", now, nil)
|
m.MarkStageSucceeded("polish", now, nil)
|
||||||
m.MarkStageSucceeded("normalize", now, nil)
|
m.MarkStageSucceeded("normalize", now, nil)
|
||||||
m.MarkStageSucceeded("trim", now, nil)
|
m.MarkStageSucceeded("trim", now, nil)
|
||||||
|
m.MarkStageSucceeded("render", now, nil)
|
||||||
m.MarkStageFailed("analyze", now, "analysis failed")
|
m.MarkStageFailed("analyze", now, "analysis failed")
|
||||||
m.MarkStageSucceeded("publish", now, nil)
|
m.MarkStageSucceeded("publish", now, nil)
|
||||||
m.MarkStageSucceeded("notify", now, nil)
|
m.MarkStageSucceeded("notify", now, nil)
|
||||||
|
|
||||||
got := invalidateDownstreamSucceededStages(m, "polish", now.Add(1*time.Second))
|
got := invalidateDownstreamSucceededStages(m, "polish", now.Add(1*time.Second))
|
||||||
want := []string{"normalize", "trim", "publish", "notify"}
|
want := []string{"normalize", "trim", "render", "publish", "notify"}
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("invalidateDownstreamSucceededStages() = %#v, want %#v", got, want)
|
t.Fatalf("invalidateDownstreamSucceededStages() = %#v, want %#v", got, want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ func TestRunContinuesAfterCompletedStages(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "executed=7 skipped=2") {
|
if !strings.Contains(out.String(), "executed=8 skipped=2") {
|
||||||
t.Fatalf("output = %q, want executed=7 skipped=2", out.String())
|
t.Fatalf("output = %q, want executed=8 skipped=2", out.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
loaded, err := store.Load(context.Background(), manifestPath)
|
loaded, err := store.Load(context.Background(), manifestPath)
|
||||||
@@ -56,7 +56,7 @@ func TestRunNoRemainingStagesRecordsSkippedStages(t *testing.T) {
|
|||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||||
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||||
@@ -68,8 +68,8 @@ func TestRunNoRemainingStagesRecordsSkippedStages(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "executed=0 skipped=9") {
|
if !strings.Contains(out.String(), "executed=0 skipped=10") {
|
||||||
t.Fatalf("output = %q, want executed=0 skipped=9", out.String())
|
t.Fatalf("output = %q, want executed=0 skipped=10", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ func TestRunForceRerunsSucceeded(t *testing.T) {
|
|||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||||
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||||
@@ -97,7 +97,7 @@ func TestRunForceRerunsSucceeded(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "executed=9 skipped=0") {
|
if !strings.Contains(out.String(), "executed=10 skipped=0") {
|
||||||
t.Fatalf("output = %q, want forced full rerun", out.String())
|
t.Fatalf("output = %q, want forced full rerun", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,7 +176,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
|
|||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||||
seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
||||||
@@ -196,7 +196,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load manifest after force: %v", err)
|
t.Fatalf("load manifest after force: %v", err)
|
||||||
}
|
}
|
||||||
for _, name := range []string{"normalize", "trim", "analyze", "publish", "notify"} {
|
for _, name := range []string{"normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||||
if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale {
|
if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale {
|
||||||
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
|
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "executed=5 skipped=4") {
|
if !strings.Contains(out.String(), "executed=6 skipped=4") {
|
||||||
t.Fatalf("output = %q, want run to execute stale downstream stages", out.String())
|
t.Fatalf("output = %q, want run to execute stale downstream stages", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,3 +266,30 @@ func TestRunStageNormalizeExecutes(t *testing.T) {
|
|||||||
t.Fatalf("normalize stage = %#v, want succeeded", m.Stages["normalize"])
|
t.Fatalf("normalize stage = %#v, want succeeded", m.Stages["normalize"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunStageRenderExecutes(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||||
|
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
||||||
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.json"), `{"segments":[{"id":1}]}`)
|
||||||
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[{"id":2}]}`)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
err := RunStage(context.Background(), []string{"render", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunStage(render) error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "stage=render executed=1 skipped=0") {
|
||||||
|
t.Fatalf("output = %q, want stage=render executed", out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
m, err := store.Load(context.Background(), manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load manifest: %v", err)
|
||||||
|
}
|
||||||
|
if m.Stages["render"] == nil || m.Stages["render"].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("render stage = %#v, want succeeded", m.Stages["render"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/logging"
|
"gitea.maximumdirect.net/eric/narratio/internal/logging"
|
||||||
@@ -410,6 +411,8 @@ func mapResultOutputs(stageName string, result *stage.StageResult, runID string)
|
|||||||
if stageName == "analyze" {
|
if stageName == "analyze" {
|
||||||
sourceID = artifacts.ConfiguredArtifactSourceID(ref.Kind)
|
sourceID = artifacts.ConfiguredArtifactSourceID(ref.Kind)
|
||||||
kind = "scriptorium_artifact"
|
kind = "scriptorium_artifact"
|
||||||
|
} else {
|
||||||
|
sourceID = sourceIDForOutputKind(kind)
|
||||||
}
|
}
|
||||||
out = append(out, manifest.ArtifactRecord{
|
out = append(out, manifest.ArtifactRecord{
|
||||||
Kind: kind,
|
Kind: kind,
|
||||||
@@ -424,6 +427,22 @@ func mapResultOutputs(stageName string, result *stage.StageResult, runID string)
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sourceIDForOutputKind(kind string) string {
|
||||||
|
trimmed := strings.TrimSpace(kind)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if trimmed == "session_bounds" {
|
||||||
|
return artifacts.ArtifactBoundsSession
|
||||||
|
}
|
||||||
|
for _, spec := range artifactmodel.RuntimeTranscriptArtifacts() {
|
||||||
|
if spec.OutputKind == trimmed {
|
||||||
|
return spec.SourceID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func applyStageResultToManifest(m *manifest.Manifest, stageName string, result *stage.StageResult) {
|
func applyStageResultToManifest(m *manifest.Manifest, stageName string, result *stage.StageResult) {
|
||||||
if m == nil || result == nil {
|
if m == nil || result == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -332,8 +332,8 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(summary.StageNames) != 9 || len(summary.Executed) != 9 || len(summary.Skipped) != 0 {
|
if len(summary.StageNames) != 10 || len(summary.Executed) != 10 || len(summary.Skipped) != 0 {
|
||||||
t.Fatalf("summary = %#v, want all 9 executed", summary)
|
t.Fatalf("summary = %#v, want all 10 executed", summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
@@ -342,7 +342,7 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
|||||||
t.Fatalf("Load manifest error = %v", err)
|
t.Fatalf("Load manifest error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||||
sr := m.Stages[name]
|
sr := m.Stages[name]
|
||||||
if sr == nil {
|
if sr == nil {
|
||||||
t.Fatalf("missing stage record %q", name)
|
t.Fatalf("missing stage record %q", name)
|
||||||
@@ -425,6 +425,15 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if name == "render" {
|
||||||
|
if sr.Metadata == nil || sr.Metadata["stage"] != "render" {
|
||||||
|
t.Fatalf("render metadata missing stage=render: %#v", sr.Metadata)
|
||||||
|
}
|
||||||
|
if len(sr.Outputs) == 0 {
|
||||||
|
t.Fatalf("render outputs missing")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if name == "publish" {
|
if name == "publish" {
|
||||||
if sr.Metadata == nil || sr.Metadata["stage"] != "publish" {
|
if sr.Metadata == nil || sr.Metadata["stage"] != "publish" {
|
||||||
t.Fatalf("publish metadata missing stage=publish: %#v", sr.Metadata)
|
t.Fatalf("publish metadata missing stage=publish: %#v", sr.Metadata)
|
||||||
@@ -522,7 +531,7 @@ func TestExecuteStagesForceSuccessInvalidatesDownstreamSucceededStages(t *testin
|
|||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
|
|
||||||
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
||||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "publish", "notify"} {
|
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "publish", "notify"} {
|
||||||
existing.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
|
existing.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
existing.MarkStageFailed("analyze", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), "previous analyze failure")
|
existing.MarkStageFailed("analyze", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), "previous analyze failure")
|
||||||
@@ -553,7 +562,7 @@ func TestExecuteStagesForceSuccessInvalidatesDownstreamSucceededStages(t *testin
|
|||||||
if loaded.Stages["polish"] == nil || loaded.Stages["polish"].Status != manifest.StatusSucceeded {
|
if loaded.Stages["polish"] == nil || loaded.Stages["polish"].Status != manifest.StatusSucceeded {
|
||||||
t.Fatalf("polish status = %#v, want succeeded", loaded.Stages["polish"])
|
t.Fatalf("polish status = %#v, want succeeded", loaded.Stages["polish"])
|
||||||
}
|
}
|
||||||
for _, stageName := range []string{"normalize", "trim", "publish", "notify"} {
|
for _, stageName := range []string{"normalize", "trim", "render", "publish", "notify"} {
|
||||||
if loaded.Stages[stageName] == nil || loaded.Stages[stageName].Status != manifest.StatusStale {
|
if loaded.Stages[stageName] == nil || loaded.Stages[stageName].Status != manifest.StatusStale {
|
||||||
t.Fatalf("%s status = %#v, want stale", stageName, loaded.Stages[stageName])
|
t.Fatalf("%s status = %#v, want stale", stageName, loaded.Stages[stageName])
|
||||||
}
|
}
|
||||||
@@ -977,7 +986,7 @@ func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) {
|
|||||||
seed.S3Bucket = "my-dnd-archive"
|
seed.S3Bucket = "my-dnd-archive"
|
||||||
seed.S3SessionPrefix = "dnd/campaigns/" + cfg.Session.Campaign + "/sessions/" + cfg.Session.SessionID + "/"
|
seed.S3SessionPrefix = "dnd/campaigns/" + cfg.Session.Campaign + "/sessions/" + cfg.Session.SessionID + "/"
|
||||||
seed.S3RunPrefix = seed.S3SessionPrefix + "runs/" + runID + "/"
|
seed.S3RunPrefix = seed.S3SessionPrefix + "runs/" + runID + "/"
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
|
||||||
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
}
|
}
|
||||||
if err := tc.env.ManifestStore.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
if err := tc.env.ManifestStore.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ func All() []Stage {
|
|||||||
polishStage{},
|
polishStage{},
|
||||||
normalizeStage{},
|
normalizeStage{},
|
||||||
trimStage{},
|
trimStage{},
|
||||||
|
renderStage{},
|
||||||
analyzeStage{},
|
analyzeStage{},
|
||||||
publishStage{},
|
publishStage{},
|
||||||
placeholderStage{name: "notify"},
|
placeholderStage{name: "notify"},
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m := manifest.New("2026-05-03", time.Now().UTC())
|
m := manifest.New("2026-05-03", time.Now().UTC())
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
|
||||||
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
}
|
}
|
||||||
m.RunID = "20260516T000000Z-abcdef12"
|
m.RunID = "20260516T000000Z-abcdef12"
|
||||||
@@ -194,6 +194,15 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if s.Name() == "render" {
|
||||||
|
if result.Metadata["stage"] != "render" {
|
||||||
|
t.Fatalf("render metadata = %#v, want stage=render", result.Metadata)
|
||||||
|
}
|
||||||
|
if len(result.Outputs) != 2 {
|
||||||
|
t.Fatalf("render outputs = %#v, want 2 markdown outputs", result.Outputs)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if s.Name() == "publish" {
|
if s.Name() == "publish" {
|
||||||
if result.Metadata["stage"] != "publish" {
|
if result.Metadata["stage"] != "publish" {
|
||||||
t.Fatalf("publish metadata = %#v, want stage=publish", result.Metadata)
|
t.Fatalf("publish metadata = %#v, want stage=publish", result.Metadata)
|
||||||
|
|||||||
288
internal/stage/render.go
Normal file
288
internal/stage/render.go
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
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 := 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 := resolveTrimSeriatimTimeout(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
|
||||||
|
return &config.RenderConfig{
|
||||||
|
Enabled: &enabled,
|
||||||
|
Format: config.DefaultRenderFormat,
|
||||||
|
IncludeTimestamps: &includeTimestamps,
|
||||||
|
IncludeSegmentIDs: config.DefaultRenderSegmentIDs,
|
||||||
|
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 --force %s %s",
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
207
internal/stage/render_test.go
Normal file
207
internal/stage/render_test.go
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
package stage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderStageDisabledSucceedsWithoutOutputs(t *testing.T) {
|
||||||
|
env, m, ser := setupRenderEnv(t)
|
||||||
|
enabled := false
|
||||||
|
env.Config.Pipeline.Render.Enabled = &enabled
|
||||||
|
|
||||||
|
result, err := (renderStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Outputs) != 0 {
|
||||||
|
t.Fatalf("outputs = %#v, want empty when disabled", result.Outputs)
|
||||||
|
}
|
||||||
|
if len(ser.RenderRequests) != 0 {
|
||||||
|
t.Fatalf("render requests = %d, want 0 when disabled", len(ser.RenderRequests))
|
||||||
|
}
|
||||||
|
if result.Metadata["render_enabled"] != false {
|
||||||
|
t.Fatalf("metadata render_enabled = %#v, want false", result.Metadata["render_enabled"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderStageResolvesManifestInputsFirst(t *testing.T) {
|
||||||
|
env, m, ser := setupRenderEnv(t)
|
||||||
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
|
|
||||||
|
manifestFinal := filepath.Join(paths.ArtifactsDir, "final.from-manifest.json")
|
||||||
|
manifestTrimmed := filepath.Join(paths.ArtifactsDir, "final.trimmed.from-manifest.json")
|
||||||
|
writeFile(t, manifestFinal, `{"segments":[{"id":101}]}`)
|
||||||
|
writeFile(t, manifestTrimmed, `{"segments":[{"id":202}]}`)
|
||||||
|
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`)
|
||||||
|
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`)
|
||||||
|
|
||||||
|
m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{{Kind: "transcript_final", LocalPath: manifestFinal}})
|
||||||
|
m.MarkStageSucceeded("trim", time.Now().UTC(), []manifest.ArtifactRecord{{Kind: "transcript_final_trimmed", LocalPath: manifestTrimmed}})
|
||||||
|
|
||||||
|
result, err := (renderStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(ser.RenderRequests) != 2 {
|
||||||
|
t.Fatalf("render requests = %d, want 2", len(ser.RenderRequests))
|
||||||
|
}
|
||||||
|
if ser.RenderRequests[0].InputTranscriptPath != manifestFinal {
|
||||||
|
t.Fatalf("first input path = %q, want %q", ser.RenderRequests[0].InputTranscriptPath, manifestFinal)
|
||||||
|
}
|
||||||
|
if ser.RenderRequests[1].InputTranscriptPath != manifestTrimmed {
|
||||||
|
t.Fatalf("second input path = %q, want %q", ser.RenderRequests[1].InputTranscriptPath, manifestTrimmed)
|
||||||
|
}
|
||||||
|
if result.Metadata["final_input_provenance"] != "manifest.normalize.outputs" {
|
||||||
|
t.Fatalf("final_input_provenance = %#v, want manifest.normalize.outputs", result.Metadata["final_input_provenance"])
|
||||||
|
}
|
||||||
|
if result.Metadata["final_trimmed_input_provenance"] != "manifest.trim.outputs" {
|
||||||
|
t.Fatalf("final_trimmed_input_provenance = %#v, want manifest.trim.outputs", result.Metadata["final_trimmed_input_provenance"])
|
||||||
|
}
|
||||||
|
if len(result.Outputs) != 2 {
|
||||||
|
t.Fatalf("outputs = %#v, want 2", result.Outputs)
|
||||||
|
}
|
||||||
|
if result.Outputs[0].Kind != artifacts.TranscriptOutputKindFinalMarkdown {
|
||||||
|
t.Fatalf("outputs[0].kind = %q, want %q", result.Outputs[0].Kind, artifacts.TranscriptOutputKindFinalMarkdown)
|
||||||
|
}
|
||||||
|
if result.Outputs[1].Kind != artifacts.TranscriptOutputKindFinalTrimmedMarkdown {
|
||||||
|
t.Fatalf("outputs[1].kind = %q, want %q", result.Outputs[1].Kind, artifacts.TranscriptOutputKindFinalTrimmedMarkdown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderStageTitleFallsBackToSessionTitle(t *testing.T) {
|
||||||
|
env, m, ser := setupRenderEnv(t)
|
||||||
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
|
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`)
|
||||||
|
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`)
|
||||||
|
env.Config.Pipeline.Render.Title = ""
|
||||||
|
env.Config.Session.Title = "Session Title"
|
||||||
|
|
||||||
|
_, err := (renderStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(ser.RenderRequests) != 2 {
|
||||||
|
t.Fatalf("render requests = %d, want 2", len(ser.RenderRequests))
|
||||||
|
}
|
||||||
|
if ser.RenderRequests[0].Title != "Session Title" || ser.RenderRequests[1].Title != "Session Title" {
|
||||||
|
t.Fatalf("render titles = %q/%q, want Session Title", ser.RenderRequests[0].Title, ser.RenderRequests[1].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderStageFailsWhenFinalInputMissing(t *testing.T) {
|
||||||
|
env, m, _ := setupRenderEnv(t)
|
||||||
|
_, err := (renderStage{}).Run(context.Background(), env, m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "run narratio run-stage --force normalize") {
|
||||||
|
t.Fatalf("error = %q, want normalize guidance", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderStageFailsWhenFinalTrimmedInputMissing(t *testing.T) {
|
||||||
|
env, m, _ := setupRenderEnv(t)
|
||||||
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
|
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`)
|
||||||
|
|
||||||
|
_, err := (renderStage{}).Run(context.Background(), env, m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "run narratio run-stage --force trim") {
|
||||||
|
t.Fatalf("error = %q, want trim guidance", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderStageUsesRunLocalOutputsAndMaterializesCanonical(t *testing.T) {
|
||||||
|
env, m, ser := setupRenderEnv(t)
|
||||||
|
env.Config.Session.Campaign = "sample-campaign"
|
||||||
|
m.Campaign = "sample-campaign"
|
||||||
|
m.RunID = "20260518T010203Z-abcdef12"
|
||||||
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
|
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`)
|
||||||
|
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`)
|
||||||
|
|
||||||
|
result, err := (renderStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(ser.RenderRequests) != 2 {
|
||||||
|
t.Fatalf("render requests = %d, want 2", len(ser.RenderRequests))
|
||||||
|
}
|
||||||
|
if !strings.Contains(ser.RenderRequests[0].OutputRenderedPath, filepath.Join("runs", m.RunID, "render", "outputs")) {
|
||||||
|
t.Fatalf("run output path = %q, want run-local output path", ser.RenderRequests[0].OutputRenderedPath)
|
||||||
|
}
|
||||||
|
for _, out := range result.Outputs {
|
||||||
|
if strings.Contains(out.AbsolutePath, string(filepath.Separator)+"runs"+string(filepath.Separator)) {
|
||||||
|
t.Fatalf("materialized output path = %q, want canonical session path", out.AbsolutePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(result.GeneratedConfigs) != 2 {
|
||||||
|
t.Fatalf("generated configs = %#v, want two generated config paths", result.GeneratedConfigs)
|
||||||
|
}
|
||||||
|
if len(result.Logs) != 4 {
|
||||||
|
t.Fatalf("logs = %#v, want four log paths", result.Logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupRenderEnv(t *testing.T) (*Env, *manifest.Manifest, *seriatim.FakeRunner) {
|
||||||
|
t.Helper()
|
||||||
|
workspace := t.TempDir()
|
||||||
|
cfgDir := t.TempDir()
|
||||||
|
sessionPath := filepath.Join(cfgDir, "session.yml")
|
||||||
|
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
|
||||||
|
writeFile(t, sessionPath, "session_id: 2026-05-03\n")
|
||||||
|
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
|
||||||
|
|
||||||
|
enabled := true
|
||||||
|
includeTimestamps := true
|
||||||
|
seriatimReport := false
|
||||||
|
cfg := &config.Config{
|
||||||
|
PipelinePath: pipelinePath,
|
||||||
|
SessionPath: sessionPath,
|
||||||
|
Pipeline: &config.PipelineConfig{
|
||||||
|
Workspace: config.WorkspaceConfig{Root: workspace},
|
||||||
|
Seriatim: config.SeriatimConfig{
|
||||||
|
Binary: "seriatim",
|
||||||
|
Timeout: "10m",
|
||||||
|
OutputSchema: "seriatim-intermediate",
|
||||||
|
CoalesceGap: func() *float64 { v := 3.0; return &v }(),
|
||||||
|
Report: &seriatimReport,
|
||||||
|
},
|
||||||
|
Render: &config.RenderConfig{
|
||||||
|
Enabled: &enabled,
|
||||||
|
Format: "markdown",
|
||||||
|
Title: "Pipeline Title",
|
||||||
|
IncludeTimestamps: &includeTimestamps,
|
||||||
|
IncludeSegmentIDs: false,
|
||||||
|
IncludeMetadata: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Session: &config.SessionConfig{
|
||||||
|
SessionID: "2026-05-03",
|
||||||
|
Campaign: "sample-campaign",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
store := artifacts.NewLocalStore(workspace)
|
||||||
|
if _, err := store.EnsureLayoutFor("sample-campaign", "2026-05-03"); err != nil {
|
||||||
|
t.Fatalf("EnsureLayout() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ser := &seriatim.FakeRunner{}
|
||||||
|
return &Env{
|
||||||
|
Config: cfg,
|
||||||
|
ArtifactStore: store,
|
||||||
|
Seriatim: ser,
|
||||||
|
}, manifest.New("2026-05-03", time.Now().UTC()), ser
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user