diff --git a/README.md b/README.md index cf8d54a..dd9d75a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Implemented now: - local workspace/session layout, locking, and manifest persistence - resumable stage control (`run`, `plan`, `resume`, `run-stage`, `status`) - real `prepare`, `transcribe`, `merge`, `polish`, `trim`, and `analyze` stages +- placeholder `normalize` stage - real WhisperX, Seriatim, and Audita adapters - real Scriptorium subprocess adapter - optional Scriptorium render diagnostics (`render_debug`) @@ -36,15 +37,17 @@ YAML decoding is strict (`KnownFields(true)`), so unknown fields fail fast. 2. `transcribe` 3. `merge` 4. `polish` -5. `trim` -6. `analyze` -7. `archive` -8. `notify` +5. `normalize` +6. `trim` +7. `analyze` +8. `archive` +9. `notify` ## Transcript Tiers - `transcripts/merged.json`: canonical deterministic merged transcript from Seriatim merge - `transcripts/processed.json`: full polished transcript from Audita polish +- `transcripts/normalized.json`: placeholder normalized transcript (Seriatim schema pass-through for now) - `transcripts/trimmed.json`: gameplay-only polished transcript from trim stage ## Trim Configuration @@ -83,9 +86,9 @@ trim: Trim behavior summary: -- trim discovers and validates `transcripts/processed.json` +- trim discovers and validates `transcripts/normalized.json` - trim uses Scriptorium bounds (`dnd_session.bounds` by example config) to produce `artifacts/session_bounds.json` -- bounds IDs are validated against the same processed transcript ID space that Seriatim trim will consume +- bounds IDs are validated against the same normalized transcript ID space that Seriatim trim will consume - trim converts bounds to Seriatim keep selector (for example `10-868`) and runs Seriatim trim - if trim is disabled, Narratio copies processed transcript to trimmed transcript and records `trim_action=copy_disabled` @@ -233,8 +236,8 @@ If prepared inputs or prompt/runtime config change, rerun the appropriate upstre Examples: -- glossary/autocorrect/speaker-context changes: rerun at least `merge`, `polish`, `trim`, and `analyze` -- trim bounds prompt/profile/config changes: rerun at least `trim` and `analyze` +- glossary/autocorrect/speaker-context changes: rerun at least `merge`, `polish`, `normalize`, `trim`, and `analyze` +- trim bounds prompt/profile/config changes: rerun at least `normalize`, `trim`, and `analyze` - session recap prompt/profile/input-source changes: rerun `analyze` ## Roadmap diff --git a/architecture.md b/architecture.md index 0495cbb..ed2594e 100644 --- a/architecture.md +++ b/architecture.md @@ -18,6 +18,7 @@ Implemented: - real Seriatim subprocess adapter - real Audita subprocess adapter - real Scriptorium subprocess adapter +- placeholder `normalize` stage producing `transcripts/normalized.json` - real `trim` stage producing `transcripts/trimmed.json` - real `analyze` stage for initial `session_recap` generation - optional Scriptorium render diagnostics (`render_debug`) before production run @@ -38,10 +39,11 @@ Canonical stage order: 2. `transcribe` 3. `merge` 4. `polish` -5. `trim` -6. `analyze` -7. `archive` -8. `notify` +5. `normalize` +6. `trim` +7. `analyze` +8. `archive` +9. `notify` Boundary rules: @@ -155,29 +157,30 @@ trim: ## 6. Transcript Tiers -Narratio currently produces and uses three transcript tiers: +Narratio currently produces and uses four transcript tiers: - `transcripts/merged.json`: canonical deterministic merged transcript from Seriatim merge - `transcripts/processed.json`: full polished transcript from Audita polish (includes pre/post-game content) +- `transcripts/normalized.json`: placeholder normalized transcript (Seriatim-compatible schema pass-through for now) - `transcripts/trimmed.json`: gameplay-only polished transcript from trim stage -Trim reads `transcripts/processed.json`, validates bounds IDs against that same transcript ID space, and writes `transcripts/trimmed.json`. +Trim reads `transcripts/normalized.json`, validates bounds IDs against that same transcript ID space, and writes `transcripts/trimmed.json`. ## 7. Trim Stage (Current Implementation) Trim stage behavior: -- stage order position: after `polish` and before `analyze` -- discovers processed transcript from manifest polish outputs (`transcript_processed`) when present, else `work//transcripts/processed.json` -- validates processed transcript JSON shape (`segments` array required) -- when `trim.enabled: false` (or trim config omitted), deterministically copies processed transcript to `transcripts/trimmed.json` and records `trim_action=copy_disabled` +- stage order position: after `normalize` and before `analyze` +- discovers normalized transcript from manifest normalize outputs (`transcript_normalized`) when present, else `work//transcripts/normalized.json` +- validates normalized transcript JSON shape (`segments` array required) +- when `trim.enabled: false` (or trim config omitted), deterministically copies normalized transcript to `transcripts/trimmed.json` and records `trim_action=copy_disabled` - when `trim.enabled: true`: - runs Scriptorium bounds prompt using configured `trim.bounds.prompt_id` - writes bounds output to configured path (typically `artifacts/session_bounds.json`) - - parses and validates bounds output against the same processed transcript being trimmed + - parses and validates bounds output against the same normalized transcript being trimmed - converts bounds range to Seriatim keep selector (for example `10-868`) - runs Seriatim `trim` to produce `transcripts/trimmed.json` - - supports no-trim bounds actions (`none`/`copy`) by copying processed transcript unchanged + - supports no-trim bounds actions (`none`/`copy`) by copying normalized transcript unchanged - validates trimmed transcript JSON shape (`segments` array required) Expected trim outputs and diagnostics: @@ -252,8 +255,8 @@ If prepared inputs or prompt/runtime configuration change (for example glossary Examples: -- glossary or autocorrect changes usually require rerunning at least `merge`, `polish`, `trim`, and `analyze` -- trim prompt/profile changes require rerunning at least `trim` and `analyze` +- glossary or autocorrect changes usually require rerunning at least `merge`, `polish`, `normalize`, `trim`, and `analyze` +- trim prompt/profile changes require rerunning at least `normalize`, `trim`, and `analyze` - session recap prompt/profile/input-source changes require rerunning `analyze` ## 12. Roadmap diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index f90c186..b839237 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -31,8 +31,8 @@ func TestExecuteValidCommands(t *testing.T) { args []string wantOut string }{ - {name: "run", args: []string{"run", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=8 skipped=0; manifest="}, - {name: "plan", args: []string{"plan", "--config", pipelinePath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\ntrim: skip\nanalyze: skip\narchive: skip\nnotify: skip"}, + {name: "run", args: []string{"run", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="}, + {name: "plan", args: []string{"plan", "--config", pipelinePath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\narchive: skip\nnotify: skip"}, {name: "status", args: []string{"status", "--manifest", manifestPath}, wantOut: "session_id: 2026-05-03"}, {name: "resume", args: []string{"resume", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"}, {name: "run-stage", args: []string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "polish"}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="}, @@ -106,19 +106,21 @@ func TestExecuteRunStageUnknownFails(t *testing.T) { } } -func TestExecuteRunStageNormalizeIsRejected(t *testing.T) { +func TestExecuteRunStageNormalizeIsAccepted(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe") + workRoot := filepath.Join(workspaceRoot, "work", "2026-05-03") + mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "processed.json"), `{"segments":[{"id":1}]}`) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "normalize"}, &stdout, &stderr) - if code == 0 { - t.Fatal("exit code = 0, want non-zero") + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } - if !strings.Contains(stderr.String(), "unknown stage") { - t.Fatalf("stderr = %q, want unknown stage error", stderr.String()) + if !strings.Contains(stdout.String(), "stage=normalize executed=1 skipped=0") { + t.Fatalf("stdout = %q, want normalize stage execution", stdout.String()) } } diff --git a/internal/app/plan_test.go b/internal/app/plan_test.go index c8d3c73..b204b2c 100644 --- a/internal/app/plan_test.go +++ b/internal/app/plan_test.go @@ -27,12 +27,12 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) { if !strings.Contains(got, "narratio plan: workdir prepared at") { t.Fatalf("first output = %q, want workdir prepared", got) } - for _, name := range []string{"prepare", "transcribe", "merge", "polish", "trim", "analyze", "archive", "notify"} { + for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "archive", "notify"} { if !strings.Contains(got, name+": run") { t.Fatalf("first output = %q, missing stage %q", got, name) } } - if !strings.Contains(got, "totals: run=8 skip=0") { + if !strings.Contains(got, "totals: run=9 skip=0") { t.Fatalf("first output = %q, want totals", got) } @@ -84,8 +84,8 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) { if !strings.Contains(got, "trim: run") { t.Fatalf("output = %q, want trim run", got) } - if !strings.Contains(got, "totals: run=6 skip=2") { - t.Fatalf("output = %q, want totals run=6 skip=2", got) + if !strings.Contains(got, "totals: run=7 skip=2") { + t.Fatalf("output = %q, want totals run=7 skip=2", got) } } diff --git a/internal/app/planner_test.go b/internal/app/planner_test.go index 4940f8e..d4b9955 100644 --- a/internal/app/planner_test.go +++ b/internal/app/planner_test.go @@ -4,7 +4,7 @@ import "testing" func TestBuildFullPlanOrder(t *testing.T) { got := BuildFullPlan() - want := []string{"prepare", "transcribe", "merge", "polish", "trim", "analyze", "archive", "notify"} + want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "archive", "notify"} if len(got) != len(want) { t.Fatalf("len(plan) = %d, want %d", len(got), len(want)) } diff --git a/internal/app/resume_run_stage_test.go b/internal/app/resume_run_stage_test.go index 94c737e..13afb58 100644 --- a/internal/app/resume_run_stage_test.go +++ b/internal/app/resume_run_stage_test.go @@ -36,8 +36,8 @@ func TestResumeStartsAfterCompletedStages(t *testing.T) { if err != nil { t.Fatalf("Resume() error = %v", err) } - if !strings.Contains(out.String(), "executed=6 skipped=0") { - t.Fatalf("output = %q, want executed=6 skipped=0", out.String()) + if !strings.Contains(out.String(), "executed=7 skipped=0") { + t.Fatalf("output = %q, want executed=7 skipped=0", out.String()) } loaded, err := store.Load(context.Background(), manifestPath) @@ -56,7 +56,7 @@ func TestResumeNoRemainingStages(t *testing.T) { store := &manifest.LocalStore{} 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", "trim", "analyze", "archive", "notify"} { + for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "archive", "notify"} { 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 { @@ -85,7 +85,7 @@ func TestResumeForceRerunsSucceeded(t *testing.T) { store := &manifest.LocalStore{} 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", "trim", "analyze", "archive", "notify"} { + for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "archive", "notify"} { 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 { @@ -97,7 +97,7 @@ func TestResumeForceRerunsSucceeded(t *testing.T) { if err != nil { t.Fatalf("Resume() error = %v", err) } - if !strings.Contains(out.String(), "executed=8 skipped=0") { + if !strings.Contains(out.String(), "executed=9 skipped=0") { t.Fatalf("output = %q, want forced full rerun", out.String()) } } @@ -171,7 +171,7 @@ func TestRunStageTrimExecutes(t *testing.T) { pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "2026-05-03", "manifest.json") workRoot := filepath.Join(workspaceRoot, "work", "2026-05-03") - mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "processed.json"), `{"segments":[{"id":1},{"id":2}]}`) + mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "normalized.json"), `{"segments":[{"id":1},{"id":2}]}`) var out bytes.Buffer err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "trim"}, &out) @@ -194,3 +194,29 @@ func TestRunStageTrimExecutes(t *testing.T) { t.Fatalf("trim stage metadata = %#v, want trim_action=copy_disabled", m.Stages["trim"].Metadata) } } + +func TestRunStageNormalizeExecutes(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + manifestPath := filepath.Join(workspaceRoot, "work", "2026-05-03", "manifest.json") + workRoot := filepath.Join(workspaceRoot, "work", "2026-05-03") + mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "processed.json"), `{"segments":[{"id":1},{"id":2}]}`) + + var out bytes.Buffer + err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "normalize"}, &out) + if err != nil { + t.Fatalf("RunStage(normalize) error = %v", err) + } + if !strings.Contains(out.String(), "stage=normalize executed=1 skipped=0") { + t.Fatalf("output = %q, want stage=normalize 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["normalize"] == nil || m.Stages["normalize"].Status != manifest.StatusSucceeded { + t.Fatalf("normalize stage = %#v, want succeeded", m.Stages["normalize"]) + } +} diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index 06fabad..3471f69 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -51,8 +51,8 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) { if err != nil { t.Fatalf("executeStages() error = %v", err) } - if len(summary.StageNames) != 8 || len(summary.Executed) != 8 || len(summary.Skipped) != 0 { - t.Fatalf("summary = %#v, want all 8 executed", summary) + if len(summary.StageNames) != 9 || len(summary.Executed) != 9 || len(summary.Skipped) != 0 { + t.Fatalf("summary = %#v, want all 9 executed", summary) } store := &manifest.LocalStore{} @@ -61,7 +61,7 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) { t.Fatalf("Load manifest error = %v", err) } - for _, name := range []string{"prepare", "transcribe", "merge", "polish", "trim", "analyze", "archive", "notify"} { + for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "archive", "notify"} { sr := m.Stages[name] if sr == nil { t.Fatalf("missing stage record %q", name) @@ -114,6 +114,15 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) { } continue } + if name == "normalize" { + if sr.Metadata == nil || sr.Metadata["stage"] != "normalize" { + t.Fatalf("normalize metadata missing stage=normalize: %#v", sr.Metadata) + } + if len(sr.Outputs) == 0 { + t.Fatalf("normalize outputs missing") + } + continue + } if name == "analyze" { if sr.Metadata == nil || sr.Metadata["stage"] != "analyze" { t.Fatalf("analyze metadata missing stage=analyze: %#v", sr.Metadata) diff --git a/internal/stage/normalize.go b/internal/stage/normalize.go new file mode 100644 index 0000000..5a7c4ec --- /dev/null +++ b/internal/stage/normalize.go @@ -0,0 +1,122 @@ +package stage + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +type normalizeStage struct{} + +func (normalizeStage) Name() string { return "normalize" } + +func (normalizeStage) Declares() IODecl { + return IODecl{ + Inputs: []artifacts.Ref{ + {Kind: "transcript_processed", Category: "transcripts", RelativePath: "transcripts/processed.json"}, + }, + Outputs: []artifacts.Ref{ + {Kind: "transcript_normalized", Category: "transcripts", RelativePath: "transcripts/normalized.json"}, + }, + } +} + +func (normalizeStage) Run(_ context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) { + if env == nil || env.Config == nil { + return nil, fmt.Errorf("normalize: stage environment config is required") + } + if env.ArtifactStore == nil { + return nil, fmt.Errorf("normalize: artifact store is required") + } + if env.Config.Pipeline == nil || env.Config.Session == nil { + return nil, fmt.Errorf("normalize: resolved config must include pipeline and session") + } + + 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("normalize: session id is required") + } + + paths := env.ArtifactStore.SessionPaths(sessionID) + processedPath, processedSource, err := discoverProcessedTranscript(m, paths) + if err != nil { + return nil, fmt.Errorf("normalize: resolve processed transcript: %w", err) + } + if processedPath == "" { + return nil, fmt.Errorf("normalize: processed transcript input is required") + } + if err := validateProcessedTranscriptOutput(processedPath); err != nil { + return nil, fmt.Errorf("normalize: processed transcript %q invalid: %w", processedPath, err) + } + + normalizedPath := filepath.Join(paths.TranscriptsDir, "normalized.json") + if err := copyTranscript(env.ArtifactStore, processedPath, normalizedPath); err != nil { + return nil, fmt.Errorf("normalize: copy processed transcript to normalized output: %w", err) + } + if err := validateProcessedTranscriptOutput(normalizedPath); err != nil { + return nil, fmt.Errorf("normalize: normalized transcript %q invalid: %w", normalizedPath, err) + } + + return &StageResult{ + Outputs: []artifacts.Ref{{ + Kind: "transcript_normalized", + Category: "transcripts", + SessionID: sessionID, + AbsolutePath: normalizedPath, + }}, + Metadata: map[string]any{ + "stage": "normalize", + "placeholder": true, + "normalize_action": "copy_placeholder", + "processed_transcript_path": processedPath, + "processed_transcript_source": processedSource, + "normalized_transcript_path": normalizedPath, + "normalized_transcript_source": "stage.normalize.output", + }, + }, nil +} + +func discoverNormalizedTranscript(m *manifest.Manifest, paths artifacts.SessionPaths) (string, string, error) { + candidates := []string{} + if m != nil && m.Stages != nil { + if sr := m.Stages["normalize"]; sr != nil { + for _, out := range sr.Outputs { + if out.Kind != "transcript_normalized" { + continue + } + p := strings.TrimSpace(out.LocalPath) + if p == "" { + continue + } + resolved := artifacts.ResolveSessionLocalPathForRead(paths, p) + candidates = append(candidates, filepath.Clean(resolved)) + } + } + } + deduped := dedupeAndSortPaths(candidates) + for _, p := range deduped { + if info, err := os.Stat(p); err == nil && !info.IsDir() { + return p, "manifest.normalize.outputs", nil + } + } + + fallback := filepath.Join(paths.TranscriptsDir, "normalized.json") + if info, err := os.Stat(fallback); err == nil && !info.IsDir() { + return filepath.Clean(fallback), "fallback.transcripts_dir", nil + } + if len(deduped) > 0 { + return deduped[0], "manifest.normalize.outputs", nil + } + return "", "", nil +} diff --git a/internal/stage/placeholders.go b/internal/stage/placeholders.go index 9f01ae7..c7f6b85 100644 --- a/internal/stage/placeholders.go +++ b/internal/stage/placeholders.go @@ -92,6 +92,7 @@ func All() []Stage { transcribeStage{}, mergeStage{}, polishStage{}, + normalizeStage{}, trimStage{}, analyzeStage{}, placeholderStage{name: "archive"}, diff --git a/internal/stage/placeholders_test.go b/internal/stage/placeholders_test.go index d618a48..7c7d8c1 100644 --- a/internal/stage/placeholders_test.go +++ b/internal/stage/placeholders_test.go @@ -111,6 +111,18 @@ func TestStagesReturnExpectedMetadata(t *testing.T) { } continue } + if s.Name() == "normalize" { + if result.Metadata["stage"] != "normalize" { + t.Fatalf("normalize metadata = %#v, want stage=normalize", result.Metadata) + } + if result.Metadata["normalize_action"] != "copy_placeholder" { + t.Fatalf("normalize metadata = %#v, want normalize_action=copy_placeholder", result.Metadata) + } + if len(result.Outputs) == 0 || result.Outputs[0].Kind != "transcript_normalized" { + t.Fatalf("normalize outputs = %#v, want transcript_normalized output", result.Outputs) + } + continue + } if s.Name() == "analyze" { if result.Metadata["stage"] != "analyze" { t.Fatalf("analyze metadata = %#v, want stage=analyze", result.Metadata) diff --git a/internal/stage/trim.go b/internal/stage/trim.go index 517eaf0..23496b2 100644 --- a/internal/stage/trim.go +++ b/internal/stage/trim.go @@ -23,7 +23,7 @@ func (trimStage) Name() string { return "trim" } func (trimStage) Declares() IODecl { return IODecl{ Inputs: []artifacts.Ref{ - {Kind: "transcript_processed", Category: "transcripts", RelativePath: "transcripts/processed.json"}, + {Kind: "transcript_normalized", Category: "transcripts", RelativePath: "transcripts/normalized.json"}, }, Outputs: []artifacts.Ref{ {Kind: "transcript_trimmed", Category: "transcripts", RelativePath: "transcripts/trimmed.json"}, @@ -55,15 +55,15 @@ func (trimStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Stag } paths := env.ArtifactStore.SessionPaths(sessionID) - processedPath, processedSource, err := discoverProcessedTranscript(m, paths) + normalizedPath, normalizedSource, err := discoverNormalizedTranscript(m, paths) if err != nil { - return nil, fmt.Errorf("trim: resolve processed transcript: %w", err) + return nil, fmt.Errorf("trim: resolve normalized transcript: %w", err) } - if processedPath == "" { - return nil, fmt.Errorf("trim: processed transcript input is required") + if normalizedPath == "" { + return nil, fmt.Errorf("trim: normalized transcript input is required") } - if err := validateProcessedTranscriptOutput(processedPath); err != nil { - return nil, fmt.Errorf("trim: processed transcript %q invalid: %w", processedPath, err) + if err := validateProcessedTranscriptOutput(normalizedPath); err != nil { + return nil, fmt.Errorf("trim: normalized transcript %q invalid: %w", normalizedPath, err) } trimCfg := env.Config.Pipeline.Trim @@ -77,16 +77,16 @@ func (trimStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Stag logPaths := []string{} generatedConfigs := []string{} metadata := map[string]any{ - "stage": "trim", - "trim_enabled": enabled, - "processed_transcript_path": processedPath, - "processed_transcript_source": processedSource, - "trimmed_output_path": trimmedPath, + "stage": "trim", + "trim_enabled": enabled, + "normalized_transcript_path": normalizedPath, + "normalized_transcript_source": normalizedSource, + "trimmed_output_path": trimmedPath, } if !enabled { - if err := copyTranscript(env.ArtifactStore, processedPath, trimmedPath); err != nil { - return nil, fmt.Errorf("trim: copy processed transcript to trimmed output: %w", err) + if err := copyTranscript(env.ArtifactStore, normalizedPath, trimmedPath); err != nil { + return nil, fmt.Errorf("trim: copy normalized transcript to trimmed output: %w", err) } if err := validateProcessedTranscriptOutput(trimmedPath); err != nil { return nil, fmt.Errorf("trim: copied trimmed transcript %q invalid: %w", trimmedPath, err) @@ -127,7 +127,7 @@ func (trimStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Stag } inputPaths := map[string]string{ - boundsCfg.TranscriptInputName: processedPath, + boundsCfg.TranscriptInputName: normalizedPath, } vars := map[string]string{} @@ -136,7 +136,7 @@ func (trimStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Stag metadata["bounds_output_path"] = boundsOutputPath metadata["bounds_timeout"] = boundsTimeout.String() metadata["bounds_input_name"] = boundsCfg.TranscriptInputName - metadata["bounds_input_path"] = processedPath + metadata["bounds_input_path"] = normalizedPath metadata["bounds_render_debug_enabled"] = boundsCfg.RenderDebug renderOutputPath := "" @@ -233,8 +233,8 @@ func (trimStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Stag if err != nil { return nil, fmt.Errorf("trim: parse bounds output %q: %w", finalBoundsOutputPath, err) } - if err := contracts.ValidateSessionBoundsAgainstTranscript(boundsPayload, processedPath); err != nil { - return nil, fmt.Errorf("trim: validate bounds output %q against transcript %q: %w", finalBoundsOutputPath, processedPath, err) + if err := contracts.ValidateSessionBoundsAgainstTranscript(boundsPayload, normalizedPath); err != nil { + return nil, fmt.Errorf("trim: validate bounds output %q against transcript %q: %w", finalBoundsOutputPath, normalizedPath, err) } keepSelector, copyUnchanged, err := contracts.BuildSeriatimKeepSelector(boundsPayload) @@ -272,8 +272,8 @@ func (trimStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Stag } if copyUnchanged { - if err := copyTranscript(env.ArtifactStore, processedPath, trimmedPath); err != nil { - return nil, fmt.Errorf("trim: copy processed transcript to trimmed output: %w", err) + if err := copyTranscript(env.ArtifactStore, normalizedPath, trimmedPath); err != nil { + return nil, fmt.Errorf("trim: copy normalized transcript to trimmed output: %w", err) } } else { trimStdoutLogPath := filepath.Join(paths.LogsDir, "seriatim.trim.stdout.log") @@ -285,7 +285,7 @@ func (trimStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Stag } trimReq := seriatim.TrimRequest{ Binary: env.Config.Pipeline.Seriatim.Binary, - InputTranscriptPath: processedPath, + InputTranscriptPath: normalizedPath, OutputTrimmedPath: trimmedPath, KeepSelector: keepSelector, StdoutLogPath: trimStdoutLogPath, diff --git a/internal/stage/trim_test.go b/internal/stage/trim_test.go index dd1e220..bebcc12 100644 --- a/internal/stage/trim_test.go +++ b/internal/stage/trim_test.go @@ -16,13 +16,13 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) -func TestTrimStageConsumesProcessedAndProducesTrimmedTranscript(t *testing.T) { +func TestTrimStageConsumesNormalizedAndProducesTrimmedTranscript(t *testing.T) { env, m, scr, ser := setupTrimEnv(t) paths := env.ArtifactStore.SessionPaths(m.SessionID) - processed := filepath.Join(paths.TranscriptsDir, "processed.json") - writeFile(t, processed, `{"segments":[{"id":10},{"id":868}]}`) - m.MarkStageSucceeded("polish", time.Now().UTC(), []manifest.ArtifactRecord{ - {Kind: "transcript_processed", LocalPath: processed}, + normalized := filepath.Join(paths.TranscriptsDir, "normalized.json") + writeFile(t, normalized, `{"segments":[{"id":10},{"id":868}]}`) + m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{ + {Kind: "transcript_normalized", LocalPath: normalized}, }) scr.BoundsBody = `{"confidence":"high","trim_action":"trim","start_segment_id":10,"end_segment_id":868,"warnings":[]}` @@ -38,8 +38,8 @@ func TestTrimStageConsumesProcessedAndProducesTrimmedTranscript(t *testing.T) { t.Fatalf("seriatim trim requests = %d, want 1", len(ser.TrimRequests)) } trimReq := ser.TrimRequests[0] - if trimReq.InputTranscriptPath != processed { - t.Fatalf("trim input = %q, want %q", trimReq.InputTranscriptPath, processed) + if trimReq.InputTranscriptPath != normalized { + t.Fatalf("trim input = %q, want %q", trimReq.InputTranscriptPath, normalized) } if trimReq.KeepSelector != "10-868" { t.Fatalf("keep selector = %q, want %q", trimReq.KeepSelector, "10-868") @@ -71,8 +71,8 @@ func TestTrimStageConsumesProcessedAndProducesTrimmedTranscript(t *testing.T) { func TestTrimStageUsesConfiguredScriptoriumInputName(t *testing.T) { env, m, scr, _ := setupTrimEnv(t) paths := env.ArtifactStore.SessionPaths(m.SessionID) - processed := filepath.Join(paths.TranscriptsDir, "processed.json") - writeFile(t, processed, `{"segments":[{"id":10},{"id":11}]}`) + normalized := filepath.Join(paths.TranscriptsDir, "normalized.json") + writeFile(t, normalized, `{"segments":[{"id":10},{"id":11}]}`) scr.BoundsBody = `{"trim_action":"trim","start_segment_id":10,"end_segment_id":11}` cfg := env.Config.Pipeline.Trim @@ -87,15 +87,15 @@ func TestTrimStageUsesConfiguredScriptoriumInputName(t *testing.T) { t.Fatalf("scriptorium run requests = %d, want 1", len(scr.RunRequests)) } req := scr.RunRequests[0] - if req.InputPaths["transcript_body"] != processed { - t.Fatalf("configured input path = %q, want %q", req.InputPaths["transcript_body"], processed) + if req.InputPaths["transcript_body"] != normalized { + t.Fatalf("configured input path = %q, want %q", req.InputPaths["transcript_body"], normalized) } } func TestTrimStageRecordsLogAndGeneratedConfigRefs(t *testing.T) { env, m, scr, _ := setupTrimEnv(t) paths := env.ArtifactStore.SessionPaths(m.SessionID) - writeFile(t, filepath.Join(paths.TranscriptsDir, "processed.json"), `{"segments":[{"id":1},{"id":2}]}`) + writeFile(t, filepath.Join(paths.TranscriptsDir, "normalized.json"), `{"segments":[{"id":1},{"id":2}]}`) scr.BoundsBody = `{"trim_action":"trim","start_segment_id":1,"end_segment_id":2}` result, err := (trimStage{}).Run(context.Background(), env, m) @@ -115,7 +115,7 @@ func TestTrimStageRecordsLogAndGeneratedConfigRefs(t *testing.T) { func TestTrimStageRenderDebugDiagnosticsAreNotStageOutputs(t *testing.T) { env, m, scr, _ := setupTrimEnv(t) paths := env.ArtifactStore.SessionPaths(m.SessionID) - writeFile(t, filepath.Join(paths.TranscriptsDir, "processed.json"), `{"segments":[{"id":1},{"id":2}]}`) + writeFile(t, filepath.Join(paths.TranscriptsDir, "normalized.json"), `{"segments":[{"id":1},{"id":2}]}`) scr.BoundsBody = `{"trim_action":"trim","start_segment_id":1,"end_segment_id":2}` cfg := *env.Config.Pipeline.Trim @@ -139,21 +139,21 @@ func TestTrimStageRenderDebugDiagnosticsAreNotStageOutputs(t *testing.T) { } } -func TestTrimStageFailsWhenProcessedTranscriptMissing(t *testing.T) { +func TestTrimStageFailsWhenNormalizedTranscriptMissing(t *testing.T) { env, m, _, _ := setupTrimEnv(t) _, err := (trimStage{}).Run(context.Background(), env, m) if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "processed transcript input is required") { + if !strings.Contains(err.Error(), "normalized transcript input is required") { t.Fatalf("error = %q", err.Error()) } } -func TestTrimStageFailsWhenProcessedTranscriptInvalidJSON(t *testing.T) { +func TestTrimStageFailsWhenNormalizedTranscriptInvalidJSON(t *testing.T) { env, m, _, _ := setupTrimEnv(t) paths := env.ArtifactStore.SessionPaths(m.SessionID) - writeFile(t, filepath.Join(paths.TranscriptsDir, "processed.json"), "not-json") + writeFile(t, filepath.Join(paths.TranscriptsDir, "normalized.json"), "not-json") _, err := (trimStage{}).Run(context.Background(), env, m) if err == nil { t.Fatal("expected error, got nil") @@ -163,10 +163,10 @@ func TestTrimStageFailsWhenProcessedTranscriptInvalidJSON(t *testing.T) { } } -func TestTrimStageFailsWhenProcessedTranscriptMissingSegmentsArray(t *testing.T) { +func TestTrimStageFailsWhenNormalizedTranscriptMissingSegmentsArray(t *testing.T) { env, m, _, _ := setupTrimEnv(t) paths := env.ArtifactStore.SessionPaths(m.SessionID) - writeFile(t, filepath.Join(paths.TranscriptsDir, "processed.json"), `{"schema":"audita.processed.v1"}`) + writeFile(t, filepath.Join(paths.TranscriptsDir, "normalized.json"), `{"schema":"audita.processed.v1"}`) _, err := (trimStage{}).Run(context.Background(), env, m) if err == nil { t.Fatal("expected error, got nil") @@ -179,7 +179,7 @@ func TestTrimStageFailsWhenProcessedTranscriptMissingSegmentsArray(t *testing.T) func TestTrimStageFailsWhenBoundsOutputInvalidJSON(t *testing.T) { env, m, scr, _ := setupTrimEnv(t) paths := env.ArtifactStore.SessionPaths(m.SessionID) - writeFile(t, filepath.Join(paths.TranscriptsDir, "processed.json"), `{"segments":[{"id":1},{"id":2}]}`) + writeFile(t, filepath.Join(paths.TranscriptsDir, "normalized.json"), `{"segments":[{"id":1},{"id":2}]}`) scr.BoundsBody = "not-json" _, err := (trimStage{}).Run(context.Background(), env, m) if err == nil { @@ -193,7 +193,7 @@ func TestTrimStageFailsWhenBoundsOutputInvalidJSON(t *testing.T) { func TestTrimStageFailsWhenBoundsRangeIsDescending(t *testing.T) { env, m, scr, _ := setupTrimEnv(t) paths := env.ArtifactStore.SessionPaths(m.SessionID) - writeFile(t, filepath.Join(paths.TranscriptsDir, "processed.json"), `{"segments":[{"id":1},{"id":2}]}`) + writeFile(t, filepath.Join(paths.TranscriptsDir, "normalized.json"), `{"segments":[{"id":1},{"id":2}]}`) scr.BoundsBody = `{"trim_action":"trim","start_segment_id":2,"end_segment_id":1}` _, err := (trimStage{}).Run(context.Background(), env, m) if err == nil { @@ -207,7 +207,7 @@ func TestTrimStageFailsWhenBoundsRangeIsDescending(t *testing.T) { func TestTrimStageFailsWhenBoundsIDsMissingFromTranscript(t *testing.T) { env, m, scr, _ := setupTrimEnv(t) paths := env.ArtifactStore.SessionPaths(m.SessionID) - writeFile(t, filepath.Join(paths.TranscriptsDir, "processed.json"), `{"segments":[{"id":20},{"id":21}]}`) + writeFile(t, filepath.Join(paths.TranscriptsDir, "normalized.json"), `{"segments":[{"id":20},{"id":21}]}`) scr.BoundsBody = `{"trim_action":"trim","start_segment_id":10,"end_segment_id":21}` _, err := (trimStage{}).Run(context.Background(), env, m) if err == nil { @@ -221,7 +221,7 @@ func TestTrimStageFailsWhenBoundsIDsMissingFromTranscript(t *testing.T) { func TestTrimStageFailsWhenScriptoriumAdapterFails(t *testing.T) { env, m, scr, _ := setupTrimEnv(t) paths := env.ArtifactStore.SessionPaths(m.SessionID) - writeFile(t, filepath.Join(paths.TranscriptsDir, "processed.json"), `{"segments":[{"id":1},{"id":2}]}`) + writeFile(t, filepath.Join(paths.TranscriptsDir, "normalized.json"), `{"segments":[{"id":1},{"id":2}]}`) scr.RunErr = errors.New("bounds failed") _, err := (trimStage{}).Run(context.Background(), env, m) if err == nil { @@ -235,7 +235,7 @@ func TestTrimStageFailsWhenScriptoriumAdapterFails(t *testing.T) { func TestTrimStageFailsWhenSeriatimTrimAdapterFails(t *testing.T) { env, m, scr, ser := setupTrimEnv(t) paths := env.ArtifactStore.SessionPaths(m.SessionID) - writeFile(t, filepath.Join(paths.TranscriptsDir, "processed.json"), `{"segments":[{"id":1},{"id":2}]}`) + writeFile(t, filepath.Join(paths.TranscriptsDir, "normalized.json"), `{"segments":[{"id":1},{"id":2}]}`) scr.BoundsBody = `{"trim_action":"trim","start_segment_id":1,"end_segment_id":2}` ser.TrimErr = errors.New("trim failed") _, err := (trimStage{}).Run(context.Background(), env, m) @@ -247,12 +247,12 @@ func TestTrimStageFailsWhenSeriatimTrimAdapterFails(t *testing.T) { } } -func TestTrimStageDisabledCopiesProcessedTranscript(t *testing.T) { +func TestTrimStageDisabledCopiesNormalizedTranscript(t *testing.T) { env, m, scr, ser := setupTrimEnv(t) paths := env.ArtifactStore.SessionPaths(m.SessionID) - processed := filepath.Join(paths.TranscriptsDir, "processed.json") - processedBody := `{"segments":[{"id":1,"text":"alpha"},{"id":2,"text":"beta"}]}` - writeFile(t, processed, processedBody) + normalized := filepath.Join(paths.TranscriptsDir, "normalized.json") + normalizedBody := `{"segments":[{"id":1,"text":"alpha"},{"id":2,"text":"beta"}]}` + writeFile(t, normalized, normalizedBody) disabled := *env.Config.Pipeline.Trim disabled.Enabled = false @@ -273,8 +273,8 @@ func TestTrimStageDisabledCopiesProcessedTranscript(t *testing.T) { if err != nil { t.Fatalf("read trimmed transcript: %v", err) } - if string(data) != processedBody { - t.Fatalf("trimmed body = %q, want copied processed body", string(data)) + if string(data) != normalizedBody { + t.Fatalf("trimmed body = %q, want copied normalized body", string(data)) } }