Implemented a bugfix for the whisperx stage, and added corresponding regression tests
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,6 +27,7 @@ go.work.sum
|
|||||||
|
|
||||||
# Compiled binaries and test configuration
|
# Compiled binaries and test configuration
|
||||||
narratio
|
narratio
|
||||||
|
local-test
|
||||||
pipeline.yml
|
pipeline.yml
|
||||||
|
|
||||||
# ---> VisualStudioCode
|
# ---> VisualStudioCode
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ go run ./cmd/narratio plan --config examples/pipeline.minimal.yml --session exam
|
|||||||
|
|
||||||
The current `run` command executes `prepare` + real `transcribe` + placeholder downstream stages and records progress in `manifest.json`.
|
The current `run` command executes `prepare` + real `transcribe` + placeholder downstream stages and records progress in `manifest.json`.
|
||||||
|
|
||||||
Note: default CLI wiring currently injects `whisperx.NoopClient` in the runner to avoid implicit network dependency; the real HTTP adapter is implemented under `internal/adapters/whisperx/http.go` and is used via explicit env wiring/tests.
|
Default CLI wiring builds and uses the real WhisperX HTTP adapter from `pipeline.whisperx` when `whisperx.transcribe_url` is configured.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run ./cmd/narratio run --config examples/pipeline.minimal.yml --session examples/session.minimal.yml
|
go run ./cmd/narratio run --config examples/pipeline.minimal.yml --session examples/session.minimal.yml
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package app
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,7 +16,13 @@ import (
|
|||||||
|
|
||||||
func TestExecuteValidCommands(t *testing.T) {
|
func TestExecuteValidCommands(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"source":"commands-test","segments":[{"speaker":"alice"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
|
||||||
manifestPath := writeManifestPathForExecute(t)
|
manifestPath := writeManifestPathForExecute(t)
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
@@ -83,7 +91,7 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
|
|||||||
|
|
||||||
func TestExecuteRunStageUnknownFails(t *testing.T) {
|
func TestExecuteRunStageUnknownFails(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe")
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@@ -97,6 +105,50 @@ func TestExecuteRunStageUnknownFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteRunStageTranscribeUsesConfiguredWhisperXServer(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
var serverCalls int
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serverCalls++
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"source":"run-stage-test","segments":[{"speaker":"alice"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "prepare"}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("prepare exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
stdout.Reset()
|
||||||
|
stderr.Reset()
|
||||||
|
|
||||||
|
code = Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "--force", "transcribe"}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("transcribe exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if serverCalls == 0 {
|
||||||
|
t.Fatal("expected whisperx server to be called at least once")
|
||||||
|
}
|
||||||
|
|
||||||
|
outPath := filepath.Join(workspaceRoot, "work", "2026-05-03", "transcripts", "raw", "alice.json")
|
||||||
|
data, err := os.ReadFile(outPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(%q): %v", outPath, err)
|
||||||
|
}
|
||||||
|
got := strings.TrimSpace(string(data))
|
||||||
|
if got == `{"schema":"speaker_transcript.v1","segments":[]}` {
|
||||||
|
t.Fatalf("got noop transcript output: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `"source":"run-stage-test"`) {
|
||||||
|
t.Fatalf("output = %q, want run-stage server json", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteInvalidCommand(t *testing.T) {
|
func TestExecuteInvalidCommand(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@@ -133,19 +185,27 @@ func TestExecuteMissingCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeValidConfigFiles(t *testing.T, workspaceRoot string) (string, string) {
|
func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ...string) (string, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||||
sessionPath := filepath.Join(dir, "session.yml")
|
sessionPath := filepath.Join(dir, "session.yml")
|
||||||
|
url := "https://example.com/transcribe"
|
||||||
|
if len(transcribeURL) > 0 && strings.TrimSpace(transcribeURL[0]) != "" {
|
||||||
|
url = transcribeURL[0]
|
||||||
|
}
|
||||||
|
|
||||||
pipelineYAML := `workspace:
|
pipelineYAML := `workspace:
|
||||||
root: ` + workspaceRoot + `
|
root: ` + workspaceRoot + `
|
||||||
storage:
|
storage:
|
||||||
backend: s3
|
backend: s3
|
||||||
whisperx:
|
whisperx:
|
||||||
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
|
transcribe_url: ` + url + `
|
||||||
|
timeout: 2s
|
||||||
|
retries: 0
|
||||||
|
retry_delay: 1ms
|
||||||
|
concurrency: 1
|
||||||
seriatim:
|
seriatim:
|
||||||
timeout: 30s
|
timeout: 30s
|
||||||
audita:
|
audita:
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package app
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -68,7 +70,12 @@ func TestResumeNoRemainingStages(t *testing.T) {
|
|||||||
|
|
||||||
func TestResumeForceRerunsSucceeded(t *testing.T) {
|
func TestResumeForceRerunsSucceeded(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"source":"resume-force-test","segments":[{"speaker":"alice"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
|
||||||
manifestPath := filepath.Join(workspaceRoot, "work", "2026-05-03", "manifest.json")
|
manifestPath := filepath.Join(workspaceRoot, "work", "2026-05-03", "manifest.json")
|
||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/analyzer"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/analyzer"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/audita"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/audita"
|
||||||
@@ -51,7 +52,11 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
env.Logger = logging.NewLogger(os.Stderr, slog.LevelInfo)
|
env.Logger = logging.NewLogger(os.Stderr, slog.LevelInfo)
|
||||||
}
|
}
|
||||||
if env.WhisperX == nil {
|
if env.WhisperX == nil {
|
||||||
env.WhisperX = &whisperx.NoopClient{}
|
client, err := buildDefaultWhisperXClient(env.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("initialize whisperx client: %w", err)
|
||||||
|
}
|
||||||
|
env.WhisperX = client
|
||||||
}
|
}
|
||||||
if env.Seriatim == nil {
|
if env.Seriatim == nil {
|
||||||
env.Seriatim = &seriatim.NoopRunner{}
|
env.Seriatim = &seriatim.NoopRunner{}
|
||||||
@@ -145,6 +150,35 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildDefaultWhisperXClient(cfg *config.Config) (whisperx.Client, error) {
|
||||||
|
if cfg == nil || cfg.Pipeline == nil {
|
||||||
|
return &whisperx.NoopClient{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
wx := cfg.Pipeline.WhisperX
|
||||||
|
if strings.TrimSpace(wx.TranscribeURL) == "" {
|
||||||
|
// Compatibility fallback for tests or internal call paths that bypass config validation.
|
||||||
|
return &whisperx.NoopClient{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
retries := 0
|
||||||
|
if wx.Retries != nil {
|
||||||
|
retries = *wx.Retries
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := whisperx.NewHTTPClientFromConfigValues(
|
||||||
|
wx.TranscribeURL,
|
||||||
|
wx.Language,
|
||||||
|
wx.Timeout,
|
||||||
|
wx.RetryDelay,
|
||||||
|
retries,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("from pipeline.whisperx: %w", err)
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
func loadOrCreateManifest(ctx context.Context, store manifest.Store, path, sessionID string) (*manifest.Manifest, error) {
|
func loadOrCreateManifest(ctx context.Context, store manifest.Store, path, sessionID string) (*manifest.Manifest, error) {
|
||||||
exists, err := fileExists(path)
|
exists, err := fileExists(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
157
internal/app/whisperx_wiring_test.go
Normal file
157
internal/app/whisperx_wiring_test.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecuteStagesDefaultWiringUsesWhisperXHTTPClient(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
var gotLanguage string
|
||||||
|
var gotFileBytes int
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Fatalf("method = %s, want POST", r.Method)
|
||||||
|
}
|
||||||
|
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
||||||
|
t.Fatalf("ParseMultipartForm() error = %v", err)
|
||||||
|
}
|
||||||
|
gotLanguage = r.FormValue("language")
|
||||||
|
file, _, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FormFile(file) error = %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll(file) error = %v", err)
|
||||||
|
}
|
||||||
|
gotFileBytes = len(data)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"source":"httptest","segments":[{"speaker":"alice"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfig(t)
|
||||||
|
setWhisperXConfig(cfg, srv.URL, 0)
|
||||||
|
|
||||||
|
summary, err := executeStages(context.Background(), cfg, transcribePipelineStages(), RunOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if calls.Load() != 1 {
|
||||||
|
t.Fatalf("whisperx calls = %d, want 1", calls.Load())
|
||||||
|
}
|
||||||
|
if gotLanguage != "en" {
|
||||||
|
t.Fatalf("language = %q, want en", gotLanguage)
|
||||||
|
}
|
||||||
|
if gotFileBytes == 0 {
|
||||||
|
t.Fatal("audio file payload was empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
outPath := filepath.Join(cfg.Pipeline.Workspace.Root, "work", cfg.Session.SessionID, "transcripts", "raw", "alice.json")
|
||||||
|
data, err := os.ReadFile(outPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(%q) error = %v", outPath, err)
|
||||||
|
}
|
||||||
|
got := strings.TrimSpace(string(data))
|
||||||
|
if got == `{"schema":"speaker_transcript.v1","segments":[]}` {
|
||||||
|
t.Fatalf("got noop transcript output in default wiring: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `"source":"httptest"`) {
|
||||||
|
t.Fatalf("output = %q, want server response json", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
m, err := store.Load(context.Background(), summary.ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load manifest error = %v", err)
|
||||||
|
}
|
||||||
|
if m.Stages["transcribe"] == nil || m.Stages["transcribe"].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("transcribe stage = %#v, want succeeded", m.Stages["transcribe"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesDefaultWiringWhisperXFailureMarksManifestFailed(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
http.Error(w, "temporary", http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfig(t)
|
||||||
|
setWhisperXConfig(cfg, srv.URL, 1)
|
||||||
|
|
||||||
|
summary, err := executeStages(context.Background(), cfg, transcribePipelineStages(), RunOptions{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if summary != nil {
|
||||||
|
t.Fatalf("summary = %#v, want nil on failure", summary)
|
||||||
|
}
|
||||||
|
if calls.Load() != 2 {
|
||||||
|
t.Fatalf("whisperx calls = %d, want 2 (retry + initial)", calls.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
m, loadErr := store.Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatalf("Load manifest error = %v", loadErr)
|
||||||
|
}
|
||||||
|
if m.Stages["transcribe"] == nil || m.Stages["transcribe"].Status != manifest.StatusFailed {
|
||||||
|
t.Fatalf("transcribe stage = %#v, want failed", m.Stages["transcribe"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesExplicitWhisperXInjectionOverridesDefaultWiring(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
setWhisperXConfig(cfg, "https://127.0.0.1:1/transcribe", 0)
|
||||||
|
|
||||||
|
fake := &whisperx.FakeClient{}
|
||||||
|
_, err := executeStages(
|
||||||
|
context.Background(),
|
||||||
|
cfg,
|
||||||
|
transcribePipelineStages(),
|
||||||
|
RunOptions{Env: &Env{WhisperX: fake}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.Requests) != 1 {
|
||||||
|
t.Fatalf("fake whisperx requests = %d, want 1", len(fake.Requests))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setWhisperXConfig(cfg *config.Config, url string, retries int) {
|
||||||
|
concurrency := 2
|
||||||
|
retryDelay := "1ms"
|
||||||
|
timeout := "2s"
|
||||||
|
cfg.Pipeline.WhisperX = config.WhisperXConfig{
|
||||||
|
TranscribeURL: url,
|
||||||
|
Language: "en",
|
||||||
|
Timeout: timeout,
|
||||||
|
Retries: &retries,
|
||||||
|
RetryDelay: retryDelay,
|
||||||
|
Concurrency: &concurrency,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func transcribePipelineStages() []stage.Stage {
|
||||||
|
all := BuildFullPlan()
|
||||||
|
return []stage.Stage{all[0], all[1]}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user