Checkpoint speaker transcriptions incrementally
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Implementation Plan: Speaker Checkpoints and Replayable Uploads
|
# Implementation Plan: Speaker Checkpoints and Replayable Uploads
|
||||||
|
|
||||||
Status: Implementation in progress; Stages 1-2 complete.
|
Status: Implementation in progress; Stages 1-3 complete.
|
||||||
|
|
||||||
## Scope and Instructions
|
## Scope and Instructions
|
||||||
|
|
||||||
@@ -186,7 +186,17 @@ work, without altering existing runtime behavior prematurely.
|
|||||||
|
|
||||||
## Stage 3: Execute and Checkpoint Speakers Incrementally
|
## Stage 3: Execute and Checkpoint Speakers Incrementally
|
||||||
|
|
||||||
Status: Pending.
|
Status: Complete.
|
||||||
|
|
||||||
|
Completion marker (2026-09-21): Connected reconciliation to bounded execution,
|
||||||
|
durably checkpointed the starting and per-speaker collections through detached
|
||||||
|
manifest candidates, preserved committed work across failures and cancellation,
|
||||||
|
rebuilt deterministic aggregate outputs from reusable evidence, and added
|
||||||
|
speaker-aware resume validation. Updated production-style fixtures and covered
|
||||||
|
serialized concurrent saves, force/retry replacement, input rechecks, save
|
||||||
|
failures, partial recovery, and aggregate reconstruction. Verified with
|
||||||
|
`go test ./internal/stage ./internal/app`, `go test -race ./internal/stage`, and
|
||||||
|
`git diff --check`.
|
||||||
|
|
||||||
Preparation: `internal/stage/transcribe.go`, `analyze_execution_state.go`,
|
Preparation: `internal/stage/transcribe.go`, `analyze_execution_state.go`,
|
||||||
`run_local.go`,
|
`run_local.go`,
|
||||||
|
|||||||
@@ -1082,7 +1082,7 @@ func TestExecuteStagesReportsSkippedStage(t *testing.T) {
|
|||||||
t.Fatalf("Save manifest error = %v", err)
|
t.Fatalf("Save manifest error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{BuildFullPlan()[1]}, RunOptions{})
|
summary, err := executeStages(context.Background(), cfg, []stage.Stage{resultStage{name: "transcribe"}}, RunOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -1302,7 +1302,7 @@ func TestExecuteStagesSkippedStagePreservesExistingOutputsProvenance(t *testing.
|
|||||||
t.Fatalf("Save manifest error = %v", err)
|
t.Fatalf("Save manifest error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{BuildFullPlan()[1]}, RunOptions{})
|
summary, err := executeStages(context.Background(), cfg, []stage.Stage{resultStage{name: "transcribe"}}, RunOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"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/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,8 +45,88 @@ func seedCurrentSemanticEvidence(t *testing.T, cfg *config.Config, m *manifest.M
|
|||||||
t.Fatalf("fingerprint stage %q: %v", name, err)
|
t.Fatalf("fingerprint stage %q: %v", name, err)
|
||||||
}
|
}
|
||||||
record.SemanticConfig = &fingerprint
|
record.SemanticConfig = &fingerprint
|
||||||
|
if name == "transcribe" {
|
||||||
|
seedCurrentTranscribeEvidence(t, cfg, m, fingerprint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedCurrentTranscribeEvidence(
|
||||||
|
t *testing.T,
|
||||||
|
cfg *config.Config,
|
||||||
|
m *manifest.Manifest,
|
||||||
|
semantic manifest.SemanticConfigFingerprint,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
store := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
|
||||||
|
paths := store.SessionPathsFor(cfg.Session.Campaign, m.SessionID)
|
||||||
|
if strings.TrimSpace(m.RunID) == "" {
|
||||||
|
m.RunID = "20260503T010203Z-a1b2c3d4"
|
||||||
|
}
|
||||||
|
records := make(map[string]manifest.TranscribeSpeakerRecord)
|
||||||
|
for _, input := range m.Inputs {
|
||||||
|
if input.Kind != "audio" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
audioPath := artifacts.ResolveSessionLocalPathForRead(paths, input.Path)
|
||||||
|
audioBytes, err := os.ReadFile(audioPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read transcribe audio fixture %q: %v", audioPath, err)
|
||||||
|
}
|
||||||
|
audioRelative, err := pathsafe.SlashRelativeFromRoot(paths.Root, audioPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("relativize transcribe audio fixture %q: %v", audioPath, err)
|
||||||
|
}
|
||||||
|
speaker := strings.TrimSuffix(filepath.Base(audioPath), filepath.Ext(audioPath))
|
||||||
|
outputRelative := filepath.ToSlash(filepath.Join("transcripts", "raw", speaker+".json"))
|
||||||
|
outputPath, err := pathsafe.JoinSlashRelativeUnderRoot(paths.Root, outputRelative)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve transcribe output fixture: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||||
|
mustWriteTestFile(t, outputPath, `{"segments":[]}`)
|
||||||
|
} else if err != nil {
|
||||||
|
t.Fatalf("inspect transcribe output fixture %q: %v", outputPath, err)
|
||||||
|
}
|
||||||
|
outputBytes, err := os.ReadFile(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read transcribe output fixture %q: %v", outputPath, err)
|
||||||
|
}
|
||||||
|
audioDigest := sha256.Sum256(audioBytes)
|
||||||
|
outputDigest := sha256.Sum256(outputBytes)
|
||||||
|
fingerprint, err := stage.FingerprintSemanticConfig(
|
||||||
|
manifest.TranscribeFingerprintContractVersion,
|
||||||
|
struct {
|
||||||
|
SpeakerID string `json:"speaker_id"`
|
||||||
|
AudioPath string `json:"audio_path"`
|
||||||
|
AudioSHA256 string `json:"audio_sha256"`
|
||||||
|
AudioSize int64 `json:"audio_size"`
|
||||||
|
SemanticConfigVersion int `json:"semantic_config_version"`
|
||||||
|
SemanticConfigDigest string `json:"semantic_config_digest"`
|
||||||
|
}{
|
||||||
|
SpeakerID: speaker, AudioPath: audioRelative,
|
||||||
|
AudioSHA256: hex.EncodeToString(audioDigest[:]), AudioSize: int64(len(audioBytes)),
|
||||||
|
SemanticConfigVersion: semantic.Version, SemanticConfigDigest: semantic.Digest,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fingerprint transcribe speaker fixture %q: %v", speaker, err)
|
||||||
|
}
|
||||||
|
output := manifest.ArtifactRecord{
|
||||||
|
Kind: "transcript_raw", LocalPath: outputRelative,
|
||||||
|
ProducerRunID: m.RunID, Checksum: hex.EncodeToString(outputDigest[:]),
|
||||||
|
}
|
||||||
|
records[speaker] = manifest.TranscribeSpeakerRecord{
|
||||||
|
FingerprintVersion: manifest.TranscribeFingerprintContractVersion,
|
||||||
|
Fingerprint: fingerprint.Digest, AudioPath: audioRelative,
|
||||||
|
AudioSHA256: hex.EncodeToString(audioDigest[:]), AudioSize: int64(len(audioBytes)),
|
||||||
|
Output: output, OutputSize: int64(len(outputBytes)), ProducerRunID: m.RunID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
record := m.Stages["transcribe"]
|
||||||
|
record.TranscribeStateVersion = manifest.TranscribeStateContractVersion
|
||||||
|
record.TranscribeSpeakers = records
|
||||||
|
}
|
||||||
|
|
||||||
func loadConfigForSemanticEvidence(t *testing.T, pipelinePath, campaignPath, sessionPath string) *config.Config {
|
func loadConfigForSemanticEvidence(t *testing.T, pipelinePath, campaignPath, sessionPath string) *config.Config {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
|
|
||||||
@@ -28,11 +27,14 @@ func (transcribeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest)
|
|||||||
if env.ArtifactStore == nil {
|
if env.ArtifactStore == nil {
|
||||||
return nil, fmt.Errorf("transcribe: artifact store is required")
|
return nil, fmt.Errorf("transcribe: artifact store is required")
|
||||||
}
|
}
|
||||||
|
if env.ManifestStore == nil {
|
||||||
|
return nil, fmt.Errorf("transcribe: manifest store is required")
|
||||||
|
}
|
||||||
if env.Config.Pipeline == nil || env.Config.Session == nil {
|
if env.Config.Pipeline == nil || env.Config.Session == nil {
|
||||||
return nil, fmt.Errorf("transcribe: resolved config must include pipeline and session")
|
return nil, fmt.Errorf("transcribe: resolved config must include pipeline and session")
|
||||||
}
|
}
|
||||||
if env.WhisperX == nil {
|
if m == nil {
|
||||||
return nil, fmt.Errorf("transcribe: whisperx adapter is required")
|
return nil, fmt.Errorf("transcribe: session manifest is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
var sessionID string
|
var sessionID string
|
||||||
@@ -47,37 +49,40 @@ func (transcribeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest)
|
|||||||
}
|
}
|
||||||
|
|
||||||
paths := sessionPathsForEnv(env, sessionID)
|
paths := sessionPathsForEnv(env, sessionID)
|
||||||
|
reconciliation, err := reconcileTranscribeSpeakers(ctx, env, m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("transcribe: reconcile speakers: %w", err)
|
||||||
|
}
|
||||||
|
state := newTranscribeExecutionState(env, m, paths, reconciliation.current)
|
||||||
|
if err := state.checkpointStartingCollection(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("transcribe: checkpoint reconciled speaker collection before execution: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs := append([]transcribeReconciliationJob(nil), reconciliation.work...)
|
||||||
|
if len(jobs) > 0 && env.WhisperX == nil {
|
||||||
|
return nil, fmt.Errorf("transcribe: whisperx adapter is required")
|
||||||
|
}
|
||||||
|
if len(jobs) > 0 {
|
||||||
runLayout, err := resolveRunStageLayout(env, m, paths, sessionID, "transcribe")
|
runLayout, err := resolveRunStageLayout(env, m, paths, sessionID, "transcribe")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("transcribe: resolve run-stage layout: %w", err)
|
return nil, fmt.Errorf("transcribe: resolve run-stage layout: %w", err)
|
||||||
}
|
}
|
||||||
jobs, err := buildTranscribeJobs(m, paths)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("transcribe: resolve audio inputs: %w", err)
|
|
||||||
}
|
|
||||||
if len(jobs) == 0 {
|
|
||||||
return nil, fmt.Errorf("transcribe: no prepared audio files found")
|
|
||||||
}
|
|
||||||
for index := range jobs {
|
for index := range jobs {
|
||||||
jobs[index].runOutputPath, err = runLocalPathForCanonical(runLayout, paths, jobs[index].canonicalOutputPath)
|
jobs[index].runOutputPath, err = runLocalPathForCanonical(runLayout, paths, jobs[index].canonicalOutputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("transcribe: resolve run-local output path for %q: %w", jobs[index].speakerID, err)
|
return nil, fmt.Errorf("transcribe: resolve run-local output path for %q: %w", jobs[index].speakerID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
concurrency := 1
|
concurrency := 1
|
||||||
if env.Config.Pipeline.WhisperX.Concurrency != nil && *env.Config.Pipeline.WhisperX.Concurrency > 0 {
|
if env.Config.Pipeline.WhisperX.Concurrency != nil && *env.Config.Pipeline.WhisperX.Concurrency > 0 {
|
||||||
concurrency = *env.Config.Pipeline.WhisperX.Concurrency
|
concurrency = *env.Config.Pipeline.WhisperX.Concurrency
|
||||||
}
|
}
|
||||||
if concurrency > len(jobs) {
|
if len(jobs) > 0 && concurrency > len(jobs) {
|
||||||
concurrency = len(jobs)
|
concurrency = len(jobs)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
completed int
|
|
||||||
perFile = map[string]map[string]any{}
|
|
||||||
)
|
|
||||||
group, groupCtx := errgroup.WithContext(ctx)
|
group, groupCtx := errgroup.WithContext(ctx)
|
||||||
group.SetLimit(concurrency)
|
group.SetLimit(concurrency)
|
||||||
for _, job := range jobs {
|
for _, job := range jobs {
|
||||||
@@ -86,6 +91,9 @@ func (transcribeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest)
|
|||||||
if err := groupCtx.Err(); err != nil {
|
if err := groupCtx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := state.checkpointFailure(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
res, runErr := env.WhisperX.Transcribe(groupCtx, whisperx.TranscribeRequest{
|
res, runErr := env.WhisperX.Transcribe(groupCtx, whisperx.TranscribeRequest{
|
||||||
SpeakerID: job.speakerID,
|
SpeakerID: job.speakerID,
|
||||||
AudioPath: job.audioPath,
|
AudioPath: job.audioPath,
|
||||||
@@ -95,13 +103,6 @@ func (transcribeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest)
|
|||||||
return fmt.Errorf("speaker %q (%s): %w", job.speakerID, filepath.Base(job.audioPath), runErr)
|
return fmt.Errorf("speaker %q (%s): %w", job.speakerID, filepath.Base(job.audioPath), runErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := authoritativeOutputPath(job.runOutputPath, res.OutputRawTranscriptPath); err != nil {
|
|
||||||
return fmt.Errorf("speaker %q: %w", job.speakerID, err)
|
|
||||||
}
|
|
||||||
if err := validateTranscriptJSONFile(job.runOutputPath); err != nil {
|
|
||||||
return fmt.Errorf("speaker %q output %q invalid: %w", job.speakerID, job.runOutputPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
meta := map[string]any{
|
meta := map[string]any{
|
||||||
"attempts": res.Attempts,
|
"attempts": res.Attempts,
|
||||||
"http_status": res.HTTPStatus,
|
"http_status": res.HTTPStatus,
|
||||||
@@ -109,17 +110,17 @@ func (transcribeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest)
|
|||||||
"output_path": job.runOutputPath,
|
"output_path": job.runOutputPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
mu.Lock()
|
if err := state.acceptSpeaker(ctx, job, res.OutputRawTranscriptPath, meta); err != nil {
|
||||||
perFile[job.speakerID] = meta
|
return fmt.Errorf("speaker %q: %w", job.speakerID, err)
|
||||||
completed++
|
}
|
||||||
mu.Unlock()
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
groupErr := group.Wait()
|
groupErr := group.Wait()
|
||||||
mu.Lock()
|
if err := state.checkpointFailure(); err != nil {
|
||||||
completedCount := completed
|
return nil, err
|
||||||
mu.Unlock()
|
}
|
||||||
|
completedCount := state.completedCount()
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, fmt.Errorf("transcribe: canceled after completing %d of %d audio jobs: %w", completedCount, len(jobs), err)
|
return nil, fmt.Errorf("transcribe: canceled after completing %d of %d audio jobs: %w", completedCount, len(jobs), err)
|
||||||
}
|
}
|
||||||
@@ -129,34 +130,9 @@ func (transcribeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest)
|
|||||||
if completedCount != len(jobs) {
|
if completedCount != len(jobs) {
|
||||||
return nil, fmt.Errorf("transcribe: completed %d of %d planned audio jobs", completedCount, len(jobs))
|
return nil, fmt.Errorf("transcribe: completed %d of %d planned audio jobs", completedCount, len(jobs))
|
||||||
}
|
}
|
||||||
|
outputs, outputPaths, runOutputPaths, orderedPerFile, err := state.successDetails()
|
||||||
speakers := make([]string, 0, len(perFile))
|
|
||||||
for speaker := range perFile {
|
|
||||||
speakers = append(speakers, speaker)
|
|
||||||
}
|
|
||||||
sort.Strings(speakers)
|
|
||||||
|
|
||||||
outputs := make([]artifacts.Ref, 0, len(speakers))
|
|
||||||
runOutputPaths := make([]string, 0, len(speakers))
|
|
||||||
outputPaths := make([]string, 0, len(speakers))
|
|
||||||
orderedPerFile := make(map[string]any, len(speakers))
|
|
||||||
for _, speaker := range speakers {
|
|
||||||
var selected transcribeJob
|
|
||||||
for _, job := range jobs {
|
|
||||||
if job.speakerID == speaker {
|
|
||||||
selected = job
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
runOutputPaths = append(runOutputPaths, selected.runOutputPath)
|
|
||||||
ref := artifacts.Ref{Kind: "transcript_raw", Category: "transcripts", SessionID: sessionID, AbsolutePath: selected.runOutputPath}
|
|
||||||
materialized, err := materializeRunLocalOutput(env.ArtifactStore, selected.runOutputPath, selected.canonicalOutputPath, ref)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("transcribe: materialize %q output: %w", speaker, err)
|
return nil, fmt.Errorf("transcribe: assemble completed outputs: %w", err)
|
||||||
}
|
|
||||||
outputs = append(outputs, materialized)
|
|
||||||
outputPaths = append(outputPaths, selected.canonicalOutputPath)
|
|
||||||
orderedPerFile[speaker] = perFile[speaker]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
retries := 0
|
retries := 0
|
||||||
@@ -168,7 +144,7 @@ func (transcribeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest)
|
|||||||
Outputs: outputs,
|
Outputs: outputs,
|
||||||
Metadata: map[string]any{
|
Metadata: map[string]any{
|
||||||
"stage": "transcribe",
|
"stage": "transcribe",
|
||||||
"audio_files_count": len(jobs),
|
"audio_files_count": len(reconciliation.jobs),
|
||||||
"language": env.Config.Pipeline.WhisperX.Language,
|
"language": env.Config.Pipeline.WhisperX.Language,
|
||||||
"concurrency": concurrency,
|
"concurrency": concurrency,
|
||||||
"retries": retries,
|
"retries": retries,
|
||||||
|
|||||||
299
internal/stage/transcribe_checkpoint_test.go
Normal file
299
internal/stage/transcribe_checkpoint_test.go
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
package stage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTranscribeFailureCheckpointsEarlierSpeakersAndRetryRunsOnlyRemaining(t *testing.T) {
|
||||||
|
env, m := setupTranscribeEnv(t, []string{"a.flac", "b.flac", "c.flac", "d.flac"})
|
||||||
|
concurrency := 1
|
||||||
|
env.Config.Pipeline.WhisperX.Concurrency = &concurrency
|
||||||
|
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("prepare.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
cause := errors.New("speaker c failed")
|
||||||
|
env.WhisperX = &whisperx.FakeClient{TranscribeFn: func(
|
||||||
|
_ context.Context,
|
||||||
|
req whisperx.TranscribeRequest,
|
||||||
|
) (whisperx.TranscribeResult, error) {
|
||||||
|
if req.SpeakerID == "c" {
|
||||||
|
return whisperx.TranscribeResult{}, cause
|
||||||
|
}
|
||||||
|
if req.SpeakerID == "d" {
|
||||||
|
t.Fatalf("speaker d was dispatched after failure")
|
||||||
|
}
|
||||||
|
if err := writeJSONFile(req.OutputRawTranscriptPath, map[string]any{"speaker": req.SpeakerID}); err != nil {
|
||||||
|
return whisperx.TranscribeResult{}, err
|
||||||
|
}
|
||||||
|
return whisperx.TranscribeResult{OutputRawTranscriptPath: req.OutputRawTranscriptPath}, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
if _, err := (transcribeStage{}).Run(context.Background(), env, m); !errors.Is(err, cause) {
|
||||||
|
t.Fatalf("Run() error = %v, want %v", err, cause)
|
||||||
|
}
|
||||||
|
durable, err := env.ManifestStore.Load(context.Background(), sessionPathsForEnv(env, m.SessionID).ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load durable manifest: %v", err)
|
||||||
|
}
|
||||||
|
if got := sortedTranscribeSpeakerKeys(durable.Stages["transcribe"].TranscribeSpeakers); !slices.Equal(got, []string{"a", "b"}) {
|
||||||
|
t.Fatalf("durable speakers = %#v, want a and b", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
retry := &whisperx.FakeClient{}
|
||||||
|
env.WhisperX = retry
|
||||||
|
result, err := (transcribeStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("retry Run() error = %v", err)
|
||||||
|
}
|
||||||
|
requests := retry.RequestsSnapshot()
|
||||||
|
gotRequests := make([]string, 0, len(requests))
|
||||||
|
for _, request := range requests {
|
||||||
|
gotRequests = append(gotRequests, request.SpeakerID)
|
||||||
|
}
|
||||||
|
if !slices.Equal(gotRequests, []string{"c", "d"}) {
|
||||||
|
t.Fatalf("retry speakers = %#v, want c and d", gotRequests)
|
||||||
|
}
|
||||||
|
if len(result.Outputs) != 4 {
|
||||||
|
t.Fatalf("retry outputs = %d, want all four speakers", len(result.Outputs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranscribeConcurrentCheckpointsDoNotLoseCompletions(t *testing.T) {
|
||||||
|
env, m := setupTranscribeEnv(t, []string{"a.flac", "b.flac", "c.flac", "d.flac"})
|
||||||
|
concurrency := 4
|
||||||
|
env.Config.Pipeline.WhisperX.Concurrency = &concurrency
|
||||||
|
env.WhisperX = &whisperx.FakeClient{}
|
||||||
|
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("prepare.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := (transcribeStage{}).Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
durable, err := env.ManifestStore.Load(context.Background(), sessionPathsForEnv(env, m.SessionID).ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load durable manifest: %v", err)
|
||||||
|
}
|
||||||
|
if got := sortedTranscribeSpeakerKeys(durable.Stages["transcribe"].TranscribeSpeakers); !slices.Equal(got, []string{"a", "b", "c", "d"}) {
|
||||||
|
t.Fatalf("durable speakers = %#v, want all completions", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranscribeStartingCheckpointFailurePreventsRequestsAndCanonicalWrites(t *testing.T) {
|
||||||
|
env, m := setupTranscribeEnv(t, []string{"alice.flac"})
|
||||||
|
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("prepare.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
cause := errors.New("starting checkpoint failed")
|
||||||
|
base := env.ManifestStore
|
||||||
|
env.ManifestStore = &failingTranscribeManifestStore{Store: base, FailAt: 1, Err: cause}
|
||||||
|
fake := &whisperx.FakeClient{}
|
||||||
|
env.WhisperX = fake
|
||||||
|
|
||||||
|
if _, err := (transcribeStage{}).Run(context.Background(), env, m); !errors.Is(err, cause) {
|
||||||
|
t.Fatalf("Run() error = %v, want %v", err, cause)
|
||||||
|
}
|
||||||
|
if got := len(fake.RequestsSnapshot()); got != 0 {
|
||||||
|
t.Fatalf("adapter requests = %d, want none", got)
|
||||||
|
}
|
||||||
|
canonical := sessionPathsForEnv(env, m.SessionID).TranscriptsRawDir + "/alice.json"
|
||||||
|
if _, err := os.Stat(canonical); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("canonical output exists after starting checkpoint failure: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranscribeCheckpointFailureRetainsEarlierAuthority(t *testing.T) {
|
||||||
|
env, m := setupTranscribeEnv(t, []string{"a.flac", "b.flac", "c.flac"})
|
||||||
|
concurrency := 1
|
||||||
|
env.Config.Pipeline.WhisperX.Concurrency = &concurrency
|
||||||
|
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("prepare.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
cause := errors.New("speaker checkpoint failed")
|
||||||
|
base := env.ManifestStore
|
||||||
|
env.ManifestStore = &failingTranscribeManifestStore{Store: base, FailAt: 3, Err: cause}
|
||||||
|
fake := &whisperx.FakeClient{}
|
||||||
|
env.WhisperX = fake
|
||||||
|
|
||||||
|
if _, err := (transcribeStage{}).Run(context.Background(), env, m); !errors.Is(err, cause) {
|
||||||
|
t.Fatalf("Run() error = %v, want %v", err, cause)
|
||||||
|
}
|
||||||
|
requests := fake.RequestsSnapshot()
|
||||||
|
if len(requests) != 2 {
|
||||||
|
t.Fatalf("adapter requests = %d, want a and b only", len(requests))
|
||||||
|
}
|
||||||
|
durable, err := base.Load(context.Background(), sessionPathsForEnv(env, m.SessionID).ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load durable manifest: %v", err)
|
||||||
|
}
|
||||||
|
if got := sortedTranscribeSpeakerKeys(durable.Stages["transcribe"].TranscribeSpeakers); !slices.Equal(got, []string{"a"}) {
|
||||||
|
t.Fatalf("durable speakers = %#v, want only a", got)
|
||||||
|
}
|
||||||
|
if got := sortedTranscribeSpeakerKeys(m.Stages["transcribe"].TranscribeSpeakers); !slices.Equal(got, []string{"a"}) {
|
||||||
|
t.Fatalf("live speakers = %#v, want last committed collection", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranscribeFailedForceRetryDoesNotResurrectOldSpeakers(t *testing.T) {
|
||||||
|
env, m := setupTranscribeEnv(t, []string{"a.flac", "b.flac"})
|
||||||
|
concurrency := 1
|
||||||
|
env.Config.Pipeline.WhisperX.Concurrency = &concurrency
|
||||||
|
env.WhisperX = &whisperx.FakeClient{}
|
||||||
|
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("prepare.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := (transcribeStage{}).Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("seed Run() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.RunID = "20260503T020304Z-b2c3d4e5"
|
||||||
|
env.Force = true
|
||||||
|
cause := errors.New("forced b failed")
|
||||||
|
env.WhisperX = &whisperx.FakeClient{TranscribeFn: func(
|
||||||
|
_ context.Context,
|
||||||
|
req whisperx.TranscribeRequest,
|
||||||
|
) (whisperx.TranscribeResult, error) {
|
||||||
|
if req.SpeakerID == "b" {
|
||||||
|
return whisperx.TranscribeResult{}, cause
|
||||||
|
}
|
||||||
|
if err := writeJSONFile(req.OutputRawTranscriptPath, map[string]any{"replacement": req.SpeakerID}); err != nil {
|
||||||
|
return whisperx.TranscribeResult{}, err
|
||||||
|
}
|
||||||
|
return whisperx.TranscribeResult{OutputRawTranscriptPath: req.OutputRawTranscriptPath}, nil
|
||||||
|
}}
|
||||||
|
if _, err := (transcribeStage{}).Run(context.Background(), env, m); !errors.Is(err, cause) {
|
||||||
|
t.Fatalf("forced Run() error = %v, want %v", err, cause)
|
||||||
|
}
|
||||||
|
if got := sortedTranscribeSpeakerKeys(m.Stages["transcribe"].TranscribeSpeakers); !slices.Equal(got, []string{"a"}) {
|
||||||
|
t.Fatalf("speakers after failed force = %#v, want only replacement a", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
env.Force = false
|
||||||
|
retry := &whisperx.FakeClient{}
|
||||||
|
env.WhisperX = retry
|
||||||
|
if _, err := (transcribeStage{}).Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("retry Run() error = %v", err)
|
||||||
|
}
|
||||||
|
requests := retry.RequestsSnapshot()
|
||||||
|
if len(requests) != 1 || requests[0].SpeakerID != "b" {
|
||||||
|
t.Fatalf("retry requests = %#v, want only b", requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranscribeRejectsAudioChangedDuringRequest(t *testing.T) {
|
||||||
|
env, m := setupTranscribeEnv(t, []string{"alice.flac"})
|
||||||
|
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("prepare.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
env.WhisperX = &whisperx.FakeClient{TranscribeFn: func(
|
||||||
|
_ context.Context,
|
||||||
|
req whisperx.TranscribeRequest,
|
||||||
|
) (whisperx.TranscribeResult, error) {
|
||||||
|
if err := writeJSONFile(req.OutputRawTranscriptPath, map[string]any{"speaker": req.SpeakerID}); err != nil {
|
||||||
|
return whisperx.TranscribeResult{}, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(req.AudioPath, []byte("changed"), 0o644); err != nil {
|
||||||
|
return whisperx.TranscribeResult{}, err
|
||||||
|
}
|
||||||
|
return whisperx.TranscribeResult{OutputRawTranscriptPath: req.OutputRawTranscriptPath}, nil
|
||||||
|
}}
|
||||||
|
if _, err := (transcribeStage{}).Run(context.Background(), env, m); err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want changed-audio failure")
|
||||||
|
}
|
||||||
|
if got := len(m.Stages["transcribe"].TranscribeSpeakers); got != 0 {
|
||||||
|
t.Fatalf("checkpointed speakers = %d, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranscribeResumeValidationUsesSpeakerEvidence(t *testing.T) {
|
||||||
|
env, m := setupTranscribeEnv(t, []string{"alice.flac"})
|
||||||
|
env.WhisperX = &whisperx.FakeClient{}
|
||||||
|
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("prepare.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := (transcribeStage{}).Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
validation, err := (transcribeStage{}).ValidateResume(context.Background(), env, m)
|
||||||
|
if err != nil || !validation.Resumable {
|
||||||
|
t.Fatalf("ValidateResume() = %#v, %v, want resumable", validation, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(m.Inputs[0].Path, []byte("changed"), 0o644); err != nil {
|
||||||
|
t.Fatalf("change audio: %v", err)
|
||||||
|
}
|
||||||
|
validation, err = (transcribeStage{}).ValidateResume(context.Background(), env, m)
|
||||||
|
if err != nil || validation.Resumable {
|
||||||
|
t.Fatalf("ValidateResume() = %#v, %v, want execution required", validation, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranscribeRebuildsAggregateFromCurrentSpeakersWithoutRequests(t *testing.T) {
|
||||||
|
env, m := setupTranscribeEnv(t, []string{"alice.flac", "bob.flac"})
|
||||||
|
fake := &whisperx.FakeClient{}
|
||||||
|
env.WhisperX = fake
|
||||||
|
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("prepare.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
seed, err := (transcribeStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed Run() error = %v", err)
|
||||||
|
}
|
||||||
|
originalRunID := m.RunID
|
||||||
|
m.MarkStageFailed("transcribe", m.UpdatedAt.Add(1), "aggregate save failed")
|
||||||
|
m.RunID = "20260503T030405Z-c3d4e5f6"
|
||||||
|
fake = &whisperx.FakeClient{}
|
||||||
|
env.WhisperX = fake
|
||||||
|
|
||||||
|
result, err := (transcribeStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rebuild Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := len(fake.RequestsSnapshot()); got != 0 {
|
||||||
|
t.Fatalf("adapter requests = %d, want none", got)
|
||||||
|
}
|
||||||
|
if len(result.Outputs) != len(seed.Outputs) {
|
||||||
|
t.Fatalf("rebuilt outputs = %d, want %d", len(result.Outputs), len(seed.Outputs))
|
||||||
|
}
|
||||||
|
for index, output := range result.Outputs {
|
||||||
|
if output.ProducerRunID != originalRunID {
|
||||||
|
t.Fatalf("output %d producer = %q, want %q", index, output.ProducerRunID, originalRunID)
|
||||||
|
}
|
||||||
|
if output.Checksum != seed.Outputs[index].Checksum {
|
||||||
|
t.Fatalf("output %d checksum = %q, want %q", index, output.Checksum, seed.Outputs[index].Checksum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingTranscribeManifestStore struct {
|
||||||
|
manifest.Store
|
||||||
|
Saves atomic.Int64
|
||||||
|
FailAt int64
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *failingTranscribeManifestStore) Save(
|
||||||
|
ctx context.Context,
|
||||||
|
path string,
|
||||||
|
m *manifest.Manifest,
|
||||||
|
) error {
|
||||||
|
if store.Saves.Add(1) == store.FailAt {
|
||||||
|
return store.Err
|
||||||
|
}
|
||||||
|
return store.Store.Save(ctx, path, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedTranscribeSpeakerKeys(records map[string]manifest.TranscribeSpeakerRecord) []string {
|
||||||
|
keys := make([]string, 0, len(records))
|
||||||
|
for key := range records {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
slices.Sort(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
292
internal/stage/transcribe_execution_state.go
Normal file
292
internal/stage/transcribe_execution_state.go
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
package stage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
type transcribeExecutionState struct {
|
||||||
|
env *Env
|
||||||
|
manifest *manifest.Manifest
|
||||||
|
paths artifacts.SessionPaths
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
records map[string]manifest.TranscribeSpeakerRecord
|
||||||
|
checkpointErr error
|
||||||
|
completed int
|
||||||
|
perFile map[string]map[string]any
|
||||||
|
runOutputs map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTranscribeExecutionState(
|
||||||
|
env *Env,
|
||||||
|
m *manifest.Manifest,
|
||||||
|
paths artifacts.SessionPaths,
|
||||||
|
current map[string]manifest.TranscribeSpeakerRecord,
|
||||||
|
) *transcribeExecutionState {
|
||||||
|
return &transcribeExecutionState{
|
||||||
|
env: env,
|
||||||
|
manifest: m,
|
||||||
|
paths: paths,
|
||||||
|
records: manifest.CloneTranscribeSpeakerCollection(current),
|
||||||
|
perFile: make(map[string]map[string]any),
|
||||||
|
runOutputs: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *transcribeExecutionState) checkpointStartingCollection(ctx context.Context) error {
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
if record := transcribeStageRecord(state.manifest); record != nil &&
|
||||||
|
record.TranscribeStateVersion == manifest.TranscribeStateContractVersion &&
|
||||||
|
reflect.DeepEqual(record.TranscribeSpeakers, state.records) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := state.checkpointCollectionLocked(ctx, state.records); err != nil {
|
||||||
|
state.checkpointErr = err
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *transcribeExecutionState) acceptSpeaker(
|
||||||
|
ctx context.Context,
|
||||||
|
job transcribeReconciliationJob,
|
||||||
|
adapterOutputPath string,
|
||||||
|
metadata map[string]any,
|
||||||
|
) error {
|
||||||
|
if _, err := authoritativeOutputPath(job.runOutputPath, adapterOutputPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateTranscriptJSONFile(job.runOutputPath); err != nil {
|
||||||
|
return fmt.Errorf("output %q invalid: %w", job.runOutputPath, err)
|
||||||
|
}
|
||||||
|
audioSHA256, audioSize, err := hashRegularFileWithContext(ctx, job.audioPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("recheck audio %q: %w", job.audioPath, err)
|
||||||
|
}
|
||||||
|
if audioSHA256 != job.audioSHA256 || audioSize != job.audioSize {
|
||||||
|
return fmt.Errorf("audio %q changed during transcription", job.audioPath)
|
||||||
|
}
|
||||||
|
if err := state.checkpointFailure(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
producerRunID := strings.TrimSpace(state.manifest.RunID)
|
||||||
|
ref := artifacts.Ref{
|
||||||
|
Kind: "transcript_raw",
|
||||||
|
Category: "transcripts",
|
||||||
|
SessionID: state.manifest.SessionID,
|
||||||
|
AbsolutePath: job.runOutputPath,
|
||||||
|
ProducerRunID: producerRunID,
|
||||||
|
}
|
||||||
|
materialized, err := materializeRunLocalOutput(
|
||||||
|
state.env.ArtifactStore,
|
||||||
|
job.runOutputPath,
|
||||||
|
job.canonicalOutputPath,
|
||||||
|
ref,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("materialize output: %w", err)
|
||||||
|
}
|
||||||
|
outputSHA256, outputSize, err := hashRegularFileWithContext(ctx, job.canonicalOutputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect materialized output %q: %w", job.canonicalOutputPath, err)
|
||||||
|
}
|
||||||
|
if materialized.Checksum != outputSHA256 {
|
||||||
|
return fmt.Errorf("materialized output checksum changed while inspecting")
|
||||||
|
}
|
||||||
|
record := manifest.TranscribeSpeakerRecord{
|
||||||
|
FingerprintVersion: manifest.TranscribeFingerprintContractVersion,
|
||||||
|
Fingerprint: job.fingerprint,
|
||||||
|
AudioPath: job.audioRelativePath,
|
||||||
|
AudioSHA256: job.audioSHA256,
|
||||||
|
AudioSize: job.audioSize,
|
||||||
|
Output: manifest.ArtifactRecord{
|
||||||
|
Kind: "transcript_raw",
|
||||||
|
LocalPath: job.outputRelativePath,
|
||||||
|
ArchivePath: materialized.ArchivePath,
|
||||||
|
ProducerRunID: producerRunID,
|
||||||
|
Checksum: outputSHA256,
|
||||||
|
},
|
||||||
|
OutputSize: outputSize,
|
||||||
|
ProducerRunID: producerRunID,
|
||||||
|
}
|
||||||
|
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
if state.checkpointErr != nil {
|
||||||
|
return state.checkpointErr
|
||||||
|
}
|
||||||
|
candidate := manifest.CloneTranscribeSpeakerCollection(state.records)
|
||||||
|
candidate[job.speakerID] = record
|
||||||
|
if err := state.checkpointCollectionLocked(ctx, candidate); err != nil {
|
||||||
|
state.checkpointErr = fmt.Errorf("transcribe: checkpoint speaker %q: %w", job.speakerID, err)
|
||||||
|
return state.checkpointErr
|
||||||
|
}
|
||||||
|
state.completed++
|
||||||
|
state.perFile[job.speakerID] = cloneStringAnyMap(metadata)
|
||||||
|
state.runOutputs[job.speakerID] = job.runOutputPath
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *transcribeExecutionState) checkpointCollectionLocked(
|
||||||
|
ctx context.Context,
|
||||||
|
records map[string]manifest.TranscribeSpeakerRecord,
|
||||||
|
) error {
|
||||||
|
if state.manifest == nil {
|
||||||
|
return fmt.Errorf("session manifest is required")
|
||||||
|
}
|
||||||
|
if state.env == nil || state.env.ManifestStore == nil {
|
||||||
|
return fmt.Errorf("manifest store is required")
|
||||||
|
}
|
||||||
|
if err := manifest.ValidateTranscribeSpeakerCollection(
|
||||||
|
manifest.TranscribeStateContractVersion,
|
||||||
|
records,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("validate transcribe speaker collection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := cloneManifestForTranscribeCheckpoint(state.manifest)
|
||||||
|
if candidate.Stages == nil {
|
||||||
|
candidate.Stages = make(map[string]*manifest.StageRecord)
|
||||||
|
}
|
||||||
|
record := candidate.Stages["transcribe"]
|
||||||
|
if record == nil {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
record = &manifest.StageRecord{
|
||||||
|
Name: "transcribe", Status: manifest.StatusRunning, CreatedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
candidate.Stages["transcribe"] = record
|
||||||
|
}
|
||||||
|
record.TranscribeStateVersion = manifest.TranscribeStateContractVersion
|
||||||
|
record.TranscribeSpeakers = manifest.CloneTranscribeSpeakerCollection(records)
|
||||||
|
record.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := state.env.ManifestStore.Save(ctx, state.paths.ManifestPath, candidate); err != nil {
|
||||||
|
return fmt.Errorf("save session manifest %q: %w", state.paths.ManifestPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
live := transcribeStageRecord(state.manifest)
|
||||||
|
if live == nil {
|
||||||
|
live = &manifest.StageRecord{}
|
||||||
|
if state.manifest.Stages == nil {
|
||||||
|
state.manifest.Stages = make(map[string]*manifest.StageRecord)
|
||||||
|
}
|
||||||
|
state.manifest.Stages["transcribe"] = live
|
||||||
|
}
|
||||||
|
live.TranscribeStateVersion = candidate.Stages["transcribe"].TranscribeStateVersion
|
||||||
|
live.TranscribeSpeakers = manifest.CloneTranscribeSpeakerCollection(
|
||||||
|
candidate.Stages["transcribe"].TranscribeSpeakers,
|
||||||
|
)
|
||||||
|
live.UpdatedAt = candidate.Stages["transcribe"].UpdatedAt
|
||||||
|
state.manifest.UpdatedAt = candidate.UpdatedAt
|
||||||
|
state.records = manifest.CloneTranscribeSpeakerCollection(live.TranscribeSpeakers)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *transcribeExecutionState) checkpointFailure() error {
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
return state.checkpointErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *transcribeExecutionState) completedCount() int {
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
return state.completed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *transcribeExecutionState) successDetails() (
|
||||||
|
[]artifacts.Ref,
|
||||||
|
[]string,
|
||||||
|
[]string,
|
||||||
|
map[string]any,
|
||||||
|
error,
|
||||||
|
) {
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
|
||||||
|
speakers := make([]string, 0, len(state.records))
|
||||||
|
for speaker := range state.records {
|
||||||
|
speakers = append(speakers, speaker)
|
||||||
|
}
|
||||||
|
sort.Strings(speakers)
|
||||||
|
outputs := make([]artifacts.Ref, 0, len(speakers))
|
||||||
|
outputPaths := make([]string, 0, len(speakers))
|
||||||
|
runOutputPaths := make([]string, 0, len(state.runOutputs))
|
||||||
|
orderedPerFile := make(map[string]any, len(state.perFile))
|
||||||
|
for _, speaker := range speakers {
|
||||||
|
record := state.records[speaker]
|
||||||
|
absolute, err := pathsafe.JoinSlashRelativeUnderRoot(state.paths.Root, record.Output.LocalPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, fmt.Errorf("speaker %q output path: %w", speaker, err)
|
||||||
|
}
|
||||||
|
outputs = append(outputs, artifacts.Ref{
|
||||||
|
Kind: record.Output.Kind,
|
||||||
|
Category: "transcripts",
|
||||||
|
SessionID: state.manifest.SessionID,
|
||||||
|
RelativePath: record.Output.LocalPath,
|
||||||
|
AbsolutePath: absolute,
|
||||||
|
ArchivePath: record.Output.ArchivePath,
|
||||||
|
RemoteKey: record.Output.RemoteKey,
|
||||||
|
Checksum: record.Output.Checksum,
|
||||||
|
ProducerRunID: record.ProducerRunID,
|
||||||
|
})
|
||||||
|
outputPaths = append(outputPaths, absolute)
|
||||||
|
if runPath, ok := state.runOutputs[speaker]; ok {
|
||||||
|
runOutputPaths = append(runOutputPaths, runPath)
|
||||||
|
}
|
||||||
|
if metadata, ok := state.perFile[speaker]; ok {
|
||||||
|
orderedPerFile[speaker] = cloneStringAnyMap(metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outputs, outputPaths, runOutputPaths, orderedPerFile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func transcribeStageRecord(m *manifest.Manifest) *manifest.StageRecord {
|
||||||
|
if m == nil || m.Stages == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.Stages["transcribe"]
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneManifestForTranscribeCheckpoint(source *manifest.Manifest) *manifest.Manifest {
|
||||||
|
clone := *source
|
||||||
|
clone.Inputs = append([]manifest.InputRecord(nil), source.Inputs...)
|
||||||
|
clone.Artifacts = append([]manifest.ArtifactRecord(nil), source.Artifacts...)
|
||||||
|
clone.Stages = make(map[string]*manifest.StageRecord, len(source.Stages))
|
||||||
|
for name, sourceRecord := range source.Stages {
|
||||||
|
if sourceRecord == nil {
|
||||||
|
clone.Stages[name] = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
record := *sourceRecord
|
||||||
|
record.Outputs = append([]manifest.ArtifactRecord(nil), sourceRecord.Outputs...)
|
||||||
|
record.Logs = append([]string(nil), sourceRecord.Logs...)
|
||||||
|
record.GeneratedConfigs = append([]string(nil), sourceRecord.GeneratedConfigs...)
|
||||||
|
record.TranscribeSpeakers = manifest.CloneTranscribeSpeakerCollection(sourceRecord.TranscribeSpeakers)
|
||||||
|
record.AnalyzeArtifacts = manifest.CloneAnalyzeArtifactCollection(sourceRecord.AnalyzeArtifacts)
|
||||||
|
clone.Stages[name] = &record
|
||||||
|
}
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringAnyMap(source map[string]any) map[string]any {
|
||||||
|
if source == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clone := make(map[string]any, len(source))
|
||||||
|
for key, value := range source {
|
||||||
|
clone[key] = value
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
32
internal/stage/transcribe_resume.go
Normal file
32
internal/stage/transcribe_resume.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package stage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (transcribeStage) ValidateResume(
|
||||||
|
ctx context.Context,
|
||||||
|
env *Env,
|
||||||
|
m *manifest.Manifest,
|
||||||
|
) (ResumeValidation, error) {
|
||||||
|
reconciliation, err := reconcileTranscribeSpeakers(ctx, env, m)
|
||||||
|
if err != nil {
|
||||||
|
return ResumeValidation{}, fmt.Errorf("transcribe resume: reconcile speakers: %w", err)
|
||||||
|
}
|
||||||
|
if len(reconciliation.work) == 0 {
|
||||||
|
return Resumable(), nil
|
||||||
|
}
|
||||||
|
speakers := make([]string, 0, len(reconciliation.work))
|
||||||
|
for _, job := range reconciliation.work {
|
||||||
|
speakers = append(speakers, job.speakerID)
|
||||||
|
}
|
||||||
|
sort.Strings(speakers)
|
||||||
|
return NonResumable("speaker transcripts require execution: " + strings.Join(speakers, ", ")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ResumeValidator = transcribeStage{}
|
||||||
@@ -322,7 +322,7 @@ func TestTranscribeStagePreservesDistinctSameBasenameAudio(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTranscribeStageCancellationDoesNotMaterializePartialOutputs(t *testing.T) {
|
func TestTranscribeStageCancellationPreservesCheckpointedOutputs(t *testing.T) {
|
||||||
env, m := setupTranscribeEnv(t, []string{"alice.flac", "bob.flac", "cara.flac"})
|
env, m := setupTranscribeEnv(t, []string{"alice.flac", "bob.flac", "cara.flac"})
|
||||||
m.RunID = "20260810T214607Z-a1b2c3d4"
|
m.RunID = "20260810T214607Z-a1b2c3d4"
|
||||||
fastComplete := make(chan struct{})
|
fastComplete := make(chan struct{})
|
||||||
@@ -351,6 +351,21 @@ func TestTranscribeStageCancellationDoesNotMaterializePartialOutputs(t *testing.
|
|||||||
}()
|
}()
|
||||||
select {
|
select {
|
||||||
case <-fastComplete:
|
case <-fastComplete:
|
||||||
|
manifestPath := sessionPathsForEnv(env, m.SessionID).ManifestPath
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for {
|
||||||
|
durable, err := env.ManifestStore.Load(context.Background(), manifestPath)
|
||||||
|
if err == nil {
|
||||||
|
if _, ok := durable.Stages["transcribe"].TranscribeSpeakers["alice"]; ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
cancel()
|
||||||
|
t.Fatal("alice completion was not checkpointed before cancellation")
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
cancel()
|
cancel()
|
||||||
case <-time.After(time.Second):
|
case <-time.After(time.Second):
|
||||||
cancel()
|
cancel()
|
||||||
@@ -365,8 +380,15 @@ func TestTranscribeStageCancellationDoesNotMaterializePartialOutputs(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
canonical := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsRawDir, "alice.json")
|
canonical := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsRawDir, "alice.json")
|
||||||
if _, err := os.Stat(canonical); !os.IsNotExist(err) {
|
if _, err := os.Stat(canonical); err != nil {
|
||||||
t.Fatalf("partial canonical output should not exist, stat err = %v", err)
|
t.Fatalf("checkpointed canonical output missing: %v", err)
|
||||||
|
}
|
||||||
|
durable, err := env.ManifestStore.Load(context.Background(), sessionPathsForEnv(env, m.SessionID).ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load checkpointed manifest: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := durable.Stages["transcribe"].TranscribeSpeakers["alice"]; !ok {
|
||||||
|
t.Fatalf("checkpointed speakers = %#v, want alice", durable.Stages["transcribe"].TranscribeSpeakers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,8 +476,13 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani
|
|||||||
env := &Env{
|
env := &Env{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
ArtifactStore: artifacts.NewLocalStore(workspace),
|
ArtifactStore: artifacts.NewLocalStore(workspace),
|
||||||
|
ManifestStore: &manifest.LocalStore{},
|
||||||
}
|
}
|
||||||
return env, manifest.New("2026-05-03", time.Now().UTC())
|
m := manifest.New("2026-05-03", time.Now().UTC())
|
||||||
|
m.Campaign = "sample-campaign"
|
||||||
|
m.RunID = "20260503T010203Z-a1b2c3d4"
|
||||||
|
m.MarkStageRunning("transcribe", time.Now().UTC())
|
||||||
|
return env, m
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSONFile(path string, payload any) error {
|
func writeJSONFile(path string, payload any) error {
|
||||||
|
|||||||
Reference in New Issue
Block a user