Allow checkpoints for empty audio accepted by WhisperX
This commit is contained in:
@@ -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
|
producer run ID. Paths must be canonical and session-relative, checksums must
|
||||||
be lowercase SHA-256 values, and the record and output producer identities
|
be lowercase SHA-256 values, and the record and output producer identities
|
||||||
must agree. Other stages cannot carry this transcribe-owned state.
|
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
|
Transcribe reconciles this collection against the current prepared audio set
|
||||||
and observable WhisperX semantics. Missing or corrupt regular outputs become
|
and observable WhisperX semantics. Missing or corrupt regular outputs become
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Transcription Checkpoint Review Remediation
|
# Transcription Checkpoint Review Remediation
|
||||||
|
|
||||||
Status: Open.
|
Status: Complete.
|
||||||
|
|
||||||
Reviewed commits `7b0bf99` through `96b4e73` against the speaker-checkpoint and
|
Reviewed commits `7b0bf99` through `96b4e73` against the speaker-checkpoint and
|
||||||
upload-replay roadmap and the original user request. No production changes were
|
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
|
## 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`.
|
Location: `internal/manifest/transcribe_state.go:95`.
|
||||||
|
|
||||||
@@ -90,7 +96,9 @@ ordinary downstream-failure tests.
|
|||||||
|
|
||||||
## Verification After Remediation
|
## Verification After Remediation
|
||||||
|
|
||||||
Run focused stage/manifest/application tests, changed-package race tests, and
|
Completed on 2026-09-22 with Go 1.26.8: focused stage/application tests,
|
||||||
the standard repository validation gates from `docs/development.md`. Preserve
|
changed-package race tests, `go test ./...`, `go test -race ./...`,
|
||||||
the existing replay and cancellation regression tests; these findings do not
|
`go vet ./...`, `go build ./...`, `go test ./internal/doccheck`,
|
||||||
require redesigning the HTTP adapter.
|
`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.
|
||||||
|
|||||||
@@ -18,6 +18,57 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
"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) {
|
func TestExecuteStagesRetriesOnlyUncommittedTranscribeSpeakers(t *testing.T) {
|
||||||
cfg, baseStore := setupTranscribeRecoveryFixture(t, []string{"a", "b", "c", "d"})
|
cfg, baseStore := setupTranscribeRecoveryFixture(t, []string{"a", "b", "c", "d"})
|
||||||
concurrency := 1
|
concurrency := 1
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ func validateTranscribeSpeakerRecord(speakerID string, record TranscribeSpeakerR
|
|||||||
if err := validateSHA256("audio_sha256", record.AudioSHA256); err != nil {
|
if err := validateSHA256("audio_sha256", record.AudioSHA256); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if record.AudioSize <= 0 {
|
if record.AudioSize < 0 {
|
||||||
return fmt.Errorf("audio_size must be positive")
|
return fmt.Errorf("audio_size must not be negative")
|
||||||
}
|
}
|
||||||
if record.Output.Kind != "transcript_raw" {
|
if record.Output.Kind != "transcript_raw" {
|
||||||
return fmt.Errorf("output kind %q must be transcript_raw", record.Output.Kind)
|
return fmt.Errorf("output kind %q must be transcript_raw", record.Output.Kind)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
func TestTranscribeSpeakerStateRoundTripsPopulatedAndVersionedEmptyCollections(t *testing.T) {
|
func TestTranscribeSpeakerStateRoundTripsPopulatedAndVersionedEmptyCollections(t *testing.T) {
|
||||||
now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
|
||||||
store := &LocalStore{}
|
store := &LocalStore{}
|
||||||
|
zeroAudio := validTranscribeSpeakerRecord("Alice Table 1")
|
||||||
|
zeroAudio.AudioSize = 0
|
||||||
|
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -24,6 +26,7 @@ func TestTranscribeSpeakerStateRoundTripsPopulatedAndVersionedEmptyCollections(t
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{name: "versioned empty", records: map[string]TranscribeSpeakerRecord{}},
|
{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) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
m := New("session", now)
|
m := New("session", now)
|
||||||
@@ -49,6 +52,9 @@ func TestTranscribeSpeakerStateRoundTripsPopulatedAndVersionedEmptyCollections(t
|
|||||||
}
|
}
|
||||||
if len(test.records) != 0 {
|
if len(test.records) != 0 {
|
||||||
got := stage.TranscribeSpeakers["Alice Table 1"]
|
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" {
|
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)
|
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: "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: "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 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: "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: "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"},
|
{name: "bad output checksum", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.Checksum = "bad" }, want: "output checksum must be"},
|
||||||
|
|||||||
Reference in New Issue
Block a user