diff --git a/docs/internal/manifest.md b/docs/internal/manifest.md index c7ded58..2759a9d 100644 --- a/docs/internal/manifest.md +++ b/docs/internal/manifest.md @@ -64,6 +64,8 @@ canonical `transcript_raw` output with its path, SHA-256 and size, and the producer run ID. Paths must be canonical and session-relative, checksums must be lowercase SHA-256 values, and the record and output producer identities must agree. Other stages cannot carry this transcribe-owned state. +Audio size is nonnegative; WhisperX determines whether that audio can be +transcribed. Output size remains positive and the transcript must be valid JSON. Transcribe reconciles this collection against the current prepared audio set and observable WhisperX semantics. Missing or corrupt regular outputs become diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index e5f6b55..c7f9859 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -1,6 +1,6 @@ # Transcription Checkpoint Review Remediation -Status: Open. +Status: Complete. Reviewed commits `7b0bf99` through `96b4e73` against the speaker-checkpoint and upload-replay roadmap and the original user request. No production changes were @@ -56,7 +56,13 @@ correct rebuilt outputs, and no removed speaker reaching merge. ## 2. Remove the Unrequested Positive Audio-Size Requirement -Priority: P2. Status: Pending. +Priority: P2. Status: Complete. + +Audio evidence now permits zero size while still rejecting negative values. +Regression tests verify zero-size manifest round trips, successful service +results being checkpointed and reused, and service rejection being propagated +without creating a completion record. Nonempty valid-JSON output validation is +unchanged. Both new success regressions failed before the fix. Location: `internal/manifest/transcribe_state.go:95`. @@ -90,7 +96,9 @@ ordinary downstream-failure tests. ## Verification After Remediation -Run focused stage/manifest/application tests, changed-package race tests, and -the standard repository validation gates from `docs/development.md`. Preserve -the existing replay and cancellation regression tests; these findings do not -require redesigning the HTTP adapter. +Completed on 2026-09-22 with Go 1.26.8: focused stage/application tests, +changed-package race tests, `go test ./...`, `go test -race ./...`, +`go vet ./...`, `go build ./...`, `go test ./internal/doccheck`, +`go test ./internal/config -run '^TestExamplesLoadAndValidate$'`, and +`git diff --check` all passed. Existing replay and cancellation regression +tests remain intact; the HTTP adapter required no changes. diff --git a/internal/app/transcribe_checkpoint_recovery_test.go b/internal/app/transcribe_checkpoint_recovery_test.go index bd3060b..9c1b0e8 100644 --- a/internal/app/transcribe_checkpoint_recovery_test.go +++ b/internal/app/transcribe_checkpoint_recovery_test.go @@ -18,6 +18,57 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/stage" ) +func TestExecuteStagesDelegatesEmptyAudioAcceptanceToWhisperX(t *testing.T) { + for _, rejected := range []bool{false, true} { + name := "accepted" + if rejected { + name = "rejected" + } + t.Run(name, func(t *testing.T) { + cfg, store := setupTranscribeRecoveryFixture(t, []string{"alice"}) + paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID) + if err := os.WriteFile(filepath.Join(paths.AudioDir, "alice.flac"), nil, 0o644); err != nil { + t.Fatal(err) + } + fake := &whisperx.FakeClient{} + if rejected { + fake.Err = errors.New("service rejected audio") + } + transcribe := mustSelectStage(t, "transcribe") + _, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{ + Env: &Env{ManifestStore: store, WhisperX: fake}, + }) + if !errors.Is(err, fake.Err) { + t.Fatalf("transcribe error = %v, want %v", err, fake.Err) + } + if len(fake.RequestsSnapshot()) != 1 { + t.Fatal("empty audio was not sent to service") + } + m := loadTranscribeRecoveryManifest(t, store, cfg) + if rejected { + if len(m.Stages["transcribe"].TranscribeSpeakers) != 0 { + t.Fatal("rejected audio was checkpointed") + } + return + } + record, ok := m.Stages["transcribe"].TranscribeSpeakers["alice"] + if !ok || record.AudioSize != 0 { + t.Fatalf("missing zero-size checkpoint: %#v", record) + } + retry := &whisperx.FakeClient{} + summary, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{ + Env: &Env{ManifestStore: store, WhisperX: retry}, + }) + if err != nil { + t.Fatal(err) + } + if len(summary.Skipped) != 1 || len(retry.RequestsSnapshot()) != 0 { + t.Fatal("successful empty-audio result was not reused") + } + }) + } +} + func TestExecuteStagesRetriesOnlyUncommittedTranscribeSpeakers(t *testing.T) { cfg, baseStore := setupTranscribeRecoveryFixture(t, []string{"a", "b", "c", "d"}) concurrency := 1 diff --git a/internal/manifest/transcribe_state.go b/internal/manifest/transcribe_state.go index 816e124..a449b49 100644 --- a/internal/manifest/transcribe_state.go +++ b/internal/manifest/transcribe_state.go @@ -92,8 +92,8 @@ func validateTranscribeSpeakerRecord(speakerID string, record TranscribeSpeakerR if err := validateSHA256("audio_sha256", record.AudioSHA256); err != nil { return err } - if record.AudioSize <= 0 { - return fmt.Errorf("audio_size must be positive") + if record.AudioSize < 0 { + return fmt.Errorf("audio_size must not be negative") } if record.Output.Kind != "transcript_raw" { return fmt.Errorf("output kind %q must be transcript_raw", record.Output.Kind) diff --git a/internal/manifest/transcribe_state_test.go b/internal/manifest/transcribe_state_test.go index 4f73af7..7df6333 100644 --- a/internal/manifest/transcribe_state_test.go +++ b/internal/manifest/transcribe_state_test.go @@ -12,6 +12,8 @@ import ( func TestTranscribeSpeakerStateRoundTripsPopulatedAndVersionedEmptyCollections(t *testing.T) { now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) store := &LocalStore{} + zeroAudio := validTranscribeSpeakerRecord("Alice Table 1") + zeroAudio.AudioSize = 0 for _, test := range []struct { name string @@ -24,6 +26,7 @@ func TestTranscribeSpeakerStateRoundTripsPopulatedAndVersionedEmptyCollections(t }, }, {name: "versioned empty", records: map[string]TranscribeSpeakerRecord{}}, + {name: "zero size audio", records: map[string]TranscribeSpeakerRecord{"Alice Table 1": zeroAudio}}, } { t.Run(test.name, func(t *testing.T) { m := New("session", now) @@ -49,6 +52,9 @@ func TestTranscribeSpeakerStateRoundTripsPopulatedAndVersionedEmptyCollections(t } if len(test.records) != 0 { got := stage.TranscribeSpeakers["Alice Table 1"] + if got.AudioSize != test.records["Alice Table 1"].AudioSize { + t.Fatalf("audio size = %d, want %d", got.AudioSize, test.records["Alice Table 1"].AudioSize) + } if got.AudioPath != "inputs/audio/Alice Table 1.flac" || got.Output.LocalPath != "transcripts/raw/Alice Table 1.json" { t.Fatalf("round-tripped record = %#v", got) } @@ -99,7 +105,7 @@ func TestValidateTranscribeSpeakerCollectionRejectsMalformedEvidence(t *testing. {name: "unsafe audio path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioPath = "../alice.flac" }, want: "audio_path is unsafe"}, {name: "noncanonical audio path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioPath = "inputs/audio/../alice.flac" }, want: "not canonical"}, {name: "bad audio checksum", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioSHA256 = strings.Repeat("G", 64) }, want: "audio_sha256 must be"}, - {name: "bad audio size", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioSize = 0 }, want: "audio_size must be positive"}, + {name: "negative audio size", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioSize = -1 }, want: "audio_size"}, {name: "bad output kind", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.Kind = "transcript" }, want: "must be transcript_raw"}, {name: "unsafe output path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.LocalPath = "/tmp/alice.json" }, want: "output local_path is unsafe"}, {name: "bad output checksum", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.Checksum = "bad" }, want: "output checksum must be"},