Harden prepare and transcribe transitions
This commit is contained in:
@@ -30,10 +30,12 @@ Materialize canonical current-session inputs before processing stages.
|
|||||||
|
|
||||||
- validates required config/store state.
|
- validates required config/store state.
|
||||||
- enforces local audio vs S3 audio mutual exclusivity.
|
- enforces local audio vs S3 audio mutual exclusivity.
|
||||||
|
- rejects duplicate explicit local audio sources after resolution.
|
||||||
|
- gives distinct local source paths with the same basename deterministic unique
|
||||||
|
prepared filenames so neither source is overwritten.
|
||||||
- materializes S3 audio through spool/cache-aware logic.
|
- materializes S3 audio through spool/cache-aware logic.
|
||||||
- scans enabled configured artifact inputs for `narratio.previous_session.artifact.*` requirements.
|
- scans enabled configured artifact inputs for `narratio.previous_session.artifact.*` requirements.
|
||||||
- when previous requirements exist:
|
- clears managed `previous/` state on every invocation, then, when requirements exist:
|
||||||
- clears managed `previous/` state;
|
|
||||||
- resolves the pointer-selected previous source through the shared resolver;
|
- resolves the pointer-selected previous source through the shared resolver;
|
||||||
- downloads previous manifest/artifacts;
|
- downloads previous manifest/artifacts;
|
||||||
- records previous inputs in `manifest.inputs`.
|
- records previous inputs in `manifest.inputs`.
|
||||||
@@ -47,6 +49,7 @@ mapping, while the isolated legacy reader rejects ambiguous fallback matches.
|
|||||||
- only `prepare` hydrates canonical `previous/` cache state.
|
- only `prepare` hydrates canonical `previous/` cache state.
|
||||||
- managed previous artifacts are stored under `previous/artifacts/**` without
|
- managed previous artifacts are stored under `previous/artifacts/**` without
|
||||||
duplicate `artifacts/artifacts/` nesting.
|
duplicate `artifacts/artifacts/` nesting.
|
||||||
|
- managed `previous/` state represents only the current requirement set.
|
||||||
- `manifest.inputs` ordering is deterministic (`kind`, `path`).
|
- `manifest.inputs` ordering is deterministic (`kind`, `path`).
|
||||||
|
|
||||||
## Related Contracts And Tests
|
## Related Contracts And Tests
|
||||||
|
|||||||
@@ -15,16 +15,19 @@ Generate raw per-speaker transcripts from prepared audio using WhisperX.
|
|||||||
## Key Behavior
|
## Key Behavior
|
||||||
|
|
||||||
- discovers prepared audio from manifest inputs or canonical audio directory.
|
- discovers prepared audio from manifest inputs or canonical audio directory.
|
||||||
- derives speaker ID from `.flac` basename.
|
- derives the transcript identity from the prepared `.flac` filename.
|
||||||
- dispatches WhisperX requests through a bounded worker pool.
|
- dispatches WhisperX requests through a bounded worker pool.
|
||||||
- validates each output as JSON.
|
- validates each output as JSON.
|
||||||
- writes run-local outputs then materializes canonical transcript outputs.
|
- writes run-local outputs then materializes canonical transcript outputs only
|
||||||
|
after every planned request succeeds.
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- speaker basenames must be unique.
|
- prepared audio identities must be unique; prepare disambiguates distinct
|
||||||
|
source paths that share a basename.
|
||||||
- output path returned by adapter must match requested output path.
|
- output path returned by adapter must match requested output path.
|
||||||
- each successful output is validated before stage success.
|
- each successful output is validated before stage success, and cancellation or
|
||||||
|
incomplete dispatch cannot be reported as a successful result.
|
||||||
|
|
||||||
## Related Contracts And Tests
|
## Related Contracts And Tests
|
||||||
|
|
||||||
|
|||||||
@@ -840,6 +840,8 @@ cancellation at each phase, partial completion, duplicate source, same-basename
|
|||||||
distinct source, retry, and no-false-manifest tests. This is the primary TST-008
|
distinct source, retry, and no-false-manifest tests. This is the primary TST-008
|
||||||
stage; Stage 25 adds output/resolver boundary cases.
|
stage; Stage 25 adds output/resolver boundary cases.
|
||||||
|
|
||||||
|
**Status:** Completed.
|
||||||
|
|
||||||
## Stage 25 — Enforce output-path authority and shared singleton resolution
|
## Stage 25 — Enforce output-path authority and shared singleton resolution
|
||||||
|
|
||||||
**Read first:** `audit-findings.md` lines 3306–3330 (ARC-006), 3515–3537
|
**Read first:** `audit-findings.md` lines 3306–3330 (ARC-006), 3515–3537
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestValidateWhisperXRequiresAbsoluteHTTPSEndpoint(t *testing.T) {
|
|||||||
Language: "en",
|
Language: "en",
|
||||||
Timeout: "1s",
|
Timeout: "1s",
|
||||||
Retries: &retries,
|
Retries: &retries,
|
||||||
RetryDelay: "0s",
|
RetryDelay: "1ms",
|
||||||
Concurrency: &concurrency,
|
Concurrency: &concurrency,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,10 +205,10 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
|
|
||||||
previousRequirements := collectPreparePreviousRequirements(env.Config)
|
previousRequirements := collectPreparePreviousRequirements(env.Config)
|
||||||
var previousHydration *previousSessionHydrationResult
|
var previousHydration *previousSessionHydrationResult
|
||||||
|
if err := clearManagedPreviousState(paths); err != nil {
|
||||||
|
return nil, fmt.Errorf("prepare: clear previous-session cache: %w", err)
|
||||||
|
}
|
||||||
if len(previousRequirements) > 0 {
|
if len(previousRequirements) > 0 {
|
||||||
if err := clearManagedPreviousState(paths); err != nil {
|
|
||||||
return nil, fmt.Errorf("prepare: clear previous-session cache: %w", err)
|
|
||||||
}
|
|
||||||
hydration, err := hydratePreviousSessionArtifacts(ctx, env, paths, previousRequirements)
|
hydration, err := hydratePreviousSessionArtifacts(ctx, env, paths, previousRequirements)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("prepare: hydrate previous-session artifacts: %w", err)
|
return nil, fmt.Errorf("prepare: hydrate previous-session artifacts: %w", err)
|
||||||
@@ -302,6 +302,7 @@ func resolveAudioInputs(sessionDir string, inputs config.SessionInputsConfig) ([
|
|||||||
func resolveLocalAudioFiles(sessionDir string, inputs config.SessionInputsConfig) ([]string, error) {
|
func resolveLocalAudioFiles(sessionDir string, inputs config.SessionInputsConfig) ([]string, error) {
|
||||||
if len(inputs.AudioFiles) > 0 {
|
if len(inputs.AudioFiles) > 0 {
|
||||||
out := make([]string, 0, len(inputs.AudioFiles))
|
out := make([]string, 0, len(inputs.AudioFiles))
|
||||||
|
seen := make(map[string]struct{}, len(inputs.AudioFiles))
|
||||||
for _, p := range inputs.AudioFiles {
|
for _, p := range inputs.AudioFiles {
|
||||||
resolved, err := resolvePath(sessionDir, p)
|
resolved, err := resolvePath(sessionDir, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -313,6 +314,10 @@ func resolveLocalAudioFiles(sessionDir string, inputs config.SessionInputsConfig
|
|||||||
if err := requireFile(resolved, "audio file"); err != nil {
|
if err := requireFile(resolved, "audio file"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if _, exists := seen[resolved]; exists {
|
||||||
|
return nil, fmt.Errorf("duplicate audio source %q", resolved)
|
||||||
|
}
|
||||||
|
seen[resolved] = struct{}{}
|
||||||
out = append(out, resolved)
|
out = append(out, resolved)
|
||||||
}
|
}
|
||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
@@ -352,24 +357,41 @@ func resolveLocalAudioFiles(sessionDir string, inputs config.SessionInputsConfig
|
|||||||
}
|
}
|
||||||
|
|
||||||
func materializeLocalAudioInputs(env *Env, paths artifacts.SessionPaths, resolvedAudio []string, registerInput func(kind, path, checksum string)) error {
|
func materializeLocalAudioInputs(env *Env, paths artifacts.SessionPaths, resolvedAudio []string, registerInput func(kind, path, checksum string)) error {
|
||||||
copiedByDest := map[string]string{}
|
destinations := localAudioDestinations(resolvedAudio)
|
||||||
for _, src := range resolvedAudio {
|
for _, src := range resolvedAudio {
|
||||||
base := filepath.Base(src)
|
dst := filepath.Join(paths.AudioDir, destinations[src])
|
||||||
if prev, exists := copiedByDest[base]; exists && prev != src {
|
|
||||||
return fmt.Errorf("duplicate audio basename %q from %q and %q", base, prev, src)
|
|
||||||
}
|
|
||||||
copiedByDest[base] = src
|
|
||||||
|
|
||||||
dst := filepath.Join(paths.AudioDir, base)
|
|
||||||
checksum, err := copyFileIfChanged(env.ArtifactStore, src, dst)
|
checksum, err := copyFileIfChanged(env.ArtifactStore, src, dst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("materialize audio %q: %w", base, err)
|
return fmt.Errorf("materialize audio %q: %w", src, err)
|
||||||
}
|
}
|
||||||
registerInput("audio", dst, checksum)
|
registerInput("audio", dst, checksum)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func localAudioDestinations(audioPaths []string) map[string]string {
|
||||||
|
byBase := make(map[string][]string, len(audioPaths))
|
||||||
|
for _, audioPath := range audioPaths {
|
||||||
|
base := filepath.Base(audioPath)
|
||||||
|
byBase[base] = append(byBase[base], audioPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
destinations := make(map[string]string, len(audioPaths))
|
||||||
|
for base, sources := range byBase {
|
||||||
|
if len(sources) == 1 {
|
||||||
|
destinations[sources[0]] = base
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
extension := filepath.Ext(base)
|
||||||
|
stem := strings.TrimSuffix(base, extension)
|
||||||
|
for _, source := range sources {
|
||||||
|
digest := sha256.Sum256([]byte(filepath.Clean(source)))
|
||||||
|
destinations[source] = fmt.Sprintf("%s-%s%s", stem, hex.EncodeToString(digest[:8]), extension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return destinations
|
||||||
|
}
|
||||||
|
|
||||||
type s3AudioMaterializationStats struct {
|
type s3AudioMaterializationStats struct {
|
||||||
CacheHits int
|
CacheHits int
|
||||||
CacheMisses int
|
CacheMisses int
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ func TestPrepareStageAudioSourceConflictFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPrepareStageWithoutPreviousRequirementsDoesNotTouchPreviousState(t *testing.T) {
|
func TestPrepareStageWithoutPreviousRequirementsClearsPreviousState(t *testing.T) {
|
||||||
env, m := setupPrepareEnv(t)
|
env, m := setupPrepareEnv(t)
|
||||||
root := filepath.Dir(env.Config.SessionPath)
|
root := filepath.Dir(env.Config.SessionPath)
|
||||||
writeFile(t, filepath.Join(root, "audio", "a.flac"), "a")
|
writeFile(t, filepath.Join(root, "audio", "a.flac"), "a")
|
||||||
@@ -427,8 +427,23 @@ func TestPrepareStageWithoutPreviousRequirementsDoesNotTouchPreviousState(t *tes
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("prepare.Run() error = %v", err)
|
t.Fatalf("prepare.Run() error = %v", err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(stalePath); err != nil {
|
if _, err := os.Stat(stalePath); !os.IsNotExist(err) {
|
||||||
t.Fatalf("expected previous stale file to remain untouched: %v", err)
|
t.Fatalf("expected stale previous file to be cleared, stat err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareStageRejectsDuplicateExplicitAudioSources(t *testing.T) {
|
||||||
|
env, m := setupPrepareEnv(t)
|
||||||
|
root := filepath.Dir(env.Config.SessionPath)
|
||||||
|
writeFile(t, filepath.Join(root, "audio", "alice.flac"), "alice")
|
||||||
|
env.Config.Session.Inputs.AudioFiles = []string{"./audio/alice.flac", "./audio/../audio/alice.flac"}
|
||||||
|
|
||||||
|
_, err := (prepareStage{}).Run(context.Background(), env, m)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "duplicate audio source") {
|
||||||
|
t.Fatalf("prepare.Run() error = %v, want duplicate audio source error", err)
|
||||||
|
}
|
||||||
|
if len(m.Inputs) != 0 {
|
||||||
|
t.Fatalf("manifest inputs = %#v, want no prepared inputs", m.Inputs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ func (transcribeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest)
|
|||||||
jobCh = make(chan job)
|
jobCh = make(chan job)
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
firstErr error
|
firstErr error
|
||||||
|
completed int
|
||||||
perFile = map[string]map[string]any{}
|
perFile = map[string]map[string]any{}
|
||||||
outputRef = map[string]artifacts.Ref{}
|
outputRef = map[string]artifacts.Ref{}
|
||||||
)
|
)
|
||||||
@@ -175,6 +176,7 @@ func (transcribeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest)
|
|||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
AbsolutePath: j.outPath,
|
AbsolutePath: j.outPath,
|
||||||
}
|
}
|
||||||
|
completed++
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,12 +186,14 @@ func (transcribeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest)
|
|||||||
go worker()
|
go worker()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispatched := 0
|
||||||
dispatch:
|
dispatch:
|
||||||
for _, j := range jobs {
|
for _, j := range jobs {
|
||||||
select {
|
select {
|
||||||
case <-stageCtx.Done():
|
case <-stageCtx.Done():
|
||||||
break dispatch
|
break dispatch
|
||||||
case jobCh <- j:
|
case jobCh <- j:
|
||||||
|
dispatched++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
close(jobCh)
|
close(jobCh)
|
||||||
@@ -198,6 +202,12 @@ dispatch:
|
|||||||
if firstErr != nil {
|
if firstErr != nil {
|
||||||
return nil, firstErr
|
return nil, firstErr
|
||||||
}
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("transcribe: canceled after dispatching %d of %d audio jobs and completing %d: %w", dispatched, len(jobs), completed, err)
|
||||||
|
}
|
||||||
|
if completed != len(jobs) {
|
||||||
|
return nil, fmt.Errorf("transcribe: completed %d of %d planned audio jobs after dispatching %d", completed, len(jobs), dispatched)
|
||||||
|
}
|
||||||
|
|
||||||
speakers := make([]string, 0, len(perFile))
|
speakers := make([]string, 0, len(perFile))
|
||||||
for speaker := range perFile {
|
for speaker := range perFile {
|
||||||
|
|||||||
@@ -222,6 +222,94 @@ func TestTranscribeStageUsesRunLocalOutputAndMaterializesCanonical(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTranscribeStagePreservesDistinctSameBasenameAudio(t *testing.T) {
|
||||||
|
env, m := setupTranscribeEnv(t, nil)
|
||||||
|
root := filepath.Dir(env.Config.SessionPath)
|
||||||
|
writeFile(t, filepath.Join(root, "first", "alice.flac"), "first")
|
||||||
|
writeFile(t, filepath.Join(root, "second", "alice.flac"), "second")
|
||||||
|
env.Config.Session.Inputs.AudioDir = ""
|
||||||
|
env.Config.Session.Inputs.AudioFiles = []string{"./first/alice.flac", "./second/alice.flac"}
|
||||||
|
env.WhisperX = &whisperx.FakeClient{}
|
||||||
|
|
||||||
|
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("prepare.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
result, err := (transcribeStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("transcribe.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Outputs) != 2 {
|
||||||
|
t.Fatalf("outputs = %#v, want two distinct outputs", result.Outputs)
|
||||||
|
}
|
||||||
|
if result.Outputs[0].AbsolutePath == result.Outputs[1].AbsolutePath {
|
||||||
|
t.Fatalf("output paths = %#v, want distinct identities", result.Outputs)
|
||||||
|
}
|
||||||
|
requests := env.WhisperX.(*whisperx.FakeClient).RequestsSnapshot()
|
||||||
|
if len(requests) != 2 || requests[0].AudioPath == requests[1].AudioPath || requests[0].OutputRawTranscriptPath == requests[1].OutputRawTranscriptPath {
|
||||||
|
t.Fatalf("requests = %#v, want distinct audio and output paths", requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranscribeStageCancellationDoesNotMaterializePartialOutputs(t *testing.T) {
|
||||||
|
env, m := setupTranscribeEnv(t, []string{"alice.flac", "bob.flac", "cara.flac"})
|
||||||
|
m.RunID = "20260810T214607Z-a1b2c3d4"
|
||||||
|
fastComplete := make(chan struct{})
|
||||||
|
env.WhisperX = &whisperx.FakeClient{
|
||||||
|
TranscribeFn: func(ctx context.Context, req whisperx.TranscribeRequest) (whisperx.TranscribeResult, error) {
|
||||||
|
if req.SpeakerID == "alice" {
|
||||||
|
if err := writeJSONFile(req.OutputRawTranscriptPath, map[string]any{"speaker": req.SpeakerID}); err != nil {
|
||||||
|
return whisperx.TranscribeResult{}, err
|
||||||
|
}
|
||||||
|
close(fastComplete)
|
||||||
|
return whisperx.TranscribeResult{OutputRawTranscriptPath: req.OutputRawTranscriptPath}, nil
|
||||||
|
}
|
||||||
|
<-ctx.Done()
|
||||||
|
return whisperx.TranscribeResult{}, ctx.Err()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("prepare.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := (transcribeStage{}).Run(ctx, env, m)
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-fastComplete:
|
||||||
|
cancel()
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
cancel()
|
||||||
|
t.Fatal("no transcription completed before cancellation")
|
||||||
|
}
|
||||||
|
if err := <-done; err == nil || !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("transcribe.Run() error = %v, want context cancellation", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
canonical := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsRawDir, "alice.json")
|
||||||
|
if _, err := os.Stat(canonical); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("partial canonical output should not exist, stat err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranscribeStageRejectsCanceledContext(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)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err := (transcribeStage{}).Run(ctx, env, m); err == nil || !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("transcribe.Run() error = %v, want context cancellation", err)
|
||||||
|
}
|
||||||
|
if got := len(env.WhisperX.(*whisperx.FakeClient).RequestsSnapshot()); got != 0 {
|
||||||
|
t.Fatalf("adapter calls = %d, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Manifest) {
|
func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Manifest) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user