Skip to content

Commit 8eed5e6

Browse files
Add HTTP service mode and ship v0.2.0 (#4)
* Ignore _reqs, _plans, _docs working dirs Local feature-planning artifacts (requirements docs, implementation plans, scratch notes) live under these prefixes per the feature-planning skill convention. They're per-developer working notes, not shipped artifact, so they stay out of git. * Suppress Slack webhooks during tests notifier._send() calls load_dotenv() on every invocation, which loads the developer's real SLACK_WEBHOOK_URL from .env and POSTs live messages during test runs whenever pipeline code paths hit an end-of-batch notification. Tests must never fire real external side effects. Add an autouse conftest fixture that strips SLACK_WEBHOOK_URL and patches notifier._load_dotenv to None for the test session. Unit test runtime drops from ~5.5s to ~3s as a side benefit. * Add service-mode package scaffolding Phase 1 of the service-mode work (see _plans/service-mode-plan.md). Lay down the empty src/service/ package and its module stubs so subsequent phases can fill them in without dependency churn. New dependencies (fastapi, uvicorn[standard], httpx) are added to the main runtime requirements. Pydantic, click, and rich are already present and unchanged. Adds a second project script entry, audio-refinery-service, that will become the long-lived HTTP service entrypoint once Phase 7 lands. The existing audio-refinery CLI is unchanged; service mode is additive. * Scope GitHub Actions workflows to least privilege CodeQL flags both workflows for missing top-level permissions blocks. The default GITHUB_TOKEN grants more than these jobs need. Set workflow-level default to contents:read for ci.yml and release.yml. The github-release job in release.yml retains its existing job-level contents:write override to publish releases. Tests, lint, type-check, and build jobs only read source. Closes CodeQL alerts #1 through #5. * Upgrade vulnerable transitive dependencies Refresh the lockfile to pull in security fixes for transitive deps that uv was previously holding at older versions: idna 3.11 -> 3.15 (CVE-2024-3651 follow-up) Mako 1.3.10 -> 1.3.12 (path traversal fixes) Pillow 12.1.1 -> 12.2.0 (PSD OOB write + others) urllib3 2.6.3 -> 2.7.0 (redirect header forwarding + decompression bomb) Targeted upgrades only — torch and transformers stay pinned at their WhisperX-compatible versions (2.1.2 and >=4.30,<4.40). Those Dependabot alerts close together when v0.3.0 drops WhisperX. All 216 unit tests pass against the upgraded versions. Closes Dependabot alerts #21, #22, #23, #24, #25, #26, #27, #28, #29. * Document security work in Unreleased changelog Capture the workflow-permissions scoping, transitive-dep upgrades, and the test-suite Slack suppression under the [Unreleased] section. Also call out the known security debt against torch and transformers that stays open until v0.3.0 drops WhisperX. * Implement service-mode URI I/O Phase 2 of the service-mode work. Adds fetch_input() and upload() helpers that handle both https:// (presigned, via httpx) and file:// (local-disk, with parent-dir creation on upload) schemes. fetch_input streams HTTPS bodies to a destination path and returns the destination; for file:// it verifies the source exists and returns the original path without copying so the worker reads it in-place from the bind-mounted location. upload accepts a JSON-serializable dict and PUTs it (HTTPS) or writes it to disk (file://). The same function serves both per-job transcript uploads and the per-batch summary upload. Custom UnsupportedScheme / FetchError / UploadError exceptions map the underlying failures into a service-layer surface that the worker exception path can react to cleanly. 21 new unit tests; full unit suite 237/237 passing. * Implement combined transcript and batch summary schemas Phase 3 of the service-mode work. Adds the two service-layer Pydantic documents: CombinedTranscript wraps the existing per-stage results (DiarizationResult + TranscriptionResult + optional SentimentResult) into one document per successful job, uploaded to the caller's output_uri. Includes a model_versions block for quick lookup and top-level schema_version for forward-compatibility when v0.3.0 alignment changes the shape. BatchSummary captures per-job terminal outcomes for the whole batch, written to the caller-supplied summary_uri after every job settles. Per-job entries carry status="completed" (with duration_seconds) or status="failed" (with stage, error, retryable). Totals are derived from the job entries by the factory so callers don't track them separately. Both schemas start at schema_version 1.0.0 in this release. build_combined() and build_summary() factories assemble the documents from caller-supplied inputs; no coupling to the Job dataclass that lands in Phase 5. 11 new unit tests; full unit suite 248/248 passing. * Split service schemas and factories into separate modules The Phase 3 work landed schemas plus their factory functions in one file (src/service/transcript.py), mixing pure data with assembly behavior. The rest of the project's models follow the convention that Pydantic data lives in dedicated files (src/models/*.py) while factories live in the modules that have other reasons to exist. Apply the same convention within src/service/: - src/service/schemas.py (renamed from transcript.py): pure Pydantic data only — CombinedTranscript, JobSummaryEntry, BatchTotals, BatchSummary, JobStatus and JobFailureStage type aliases, schema version constants. No imports of factory helpers, no side effects. - src/service/jobs.py: schema factories build_combined() and build_summary() (plus the _audio_refinery_version helper that reads importlib.metadata). The worker and registries land here in Phase 5; the factories are co-located now so they're already next to their natural caller when the worker arrives. The rename also fixes a content-vs-name mismatch: the old "transcript.py" held both the transcript and the batch summary schemas, so "schemas.py" describes what's in the file. tests/service/test_transcript.py renamed to test_schemas.py; imports updated to source schemas from schemas and factories from jobs. CLAUDE.md, docs/DEVELOPMENT.md project-structure trees and the implementation plan refreshed to reference the new layout. All 248 unit tests pass against the new module layout. * Refactor pipeline for service-mode pre-loaded model handles Phase 4 of the service-mode work. Adds the model lifecycle pieces the long-lived container needs so it can pay each stage's model load cost once at startup rather than on every job. src/service/lifecycle.py: - PipelineHandles dataclass bundles pre-loaded pyannote, WhisperX, and (optionally) sentiment handles. - ServiceConfig dataclass captures container-startup config resolved from env vars (device, models, compute_type, sentiment toggle, HF token). - ServiceReadiness is the thread-safe state object the /health endpoint reads. Three states: loading, ready, failed (with the offending stage and detail). - warm_up(config, readiness) orchestrates the in-order load of diarization -> transcription -> sentiment. On any failure it raises WarmupError carrying the stage name, and updates the readiness object so /health can attribute the failure. - start_thermal_guard ports the CLI's _run_temp_guard pattern (src/cli.py:126) into the service: a daemon thread polls GPU temperature every 5s and invokes a caller-supplied on_trip callback when the limit is reached. Returns a threading.Event the caller flips to terminate the guard cleanly during shutdown. Returns None for cpu device or when limit <= 0. - default_thermal_trip fires the existing notify_thermal_shutdown Slack notification and calls os._exit(1). Phase 5 will replace this with a worker-aware callback that also marks the in-flight job failed and writes the partial batch summary. src/pipeline.py: - run_pipeline() accepts a new optional model_handles parameter. When supplied the pipeline reuses the caller's handles via the existing _pipeline= / _whisperx_model= / _sentiment_pipeline= injection seams and skips the internal load entirely. When None (CLI default) behavior is exactly as before. 17 new lifecycle tests plus 2 new pipeline injection tests cover warmup orchestration, readiness state, thread-safety, thermal guard behavior (trips/skips/cpu/disabled), and the run_pipeline handle-injection path. Full unit suite: 267/267. * Make thermal-guard polling configurable via ServiceConfig Add gpu_temp_limit_celsius and gpu_temp_poll_seconds fields to ServiceConfig so the thermal guard's threshold and polling cadence are first-class config rather than hard-coded at the call site. The new start_thermal_guard_from_config(config, on_trip) helper threads those two fields plus config.device into the underlying start_thermal_guard call. Phase 7's lifespan handler calls this once at startup; direct callers (tests, custom integrations) can still use start_thermal_guard with explicit args. Default poll cadence stays at 5.0 seconds to match the CLI's existing _run_temp_guard behavior. Operators on shared hosts where nvidia-smi polling is expensive can bump REFINERY_GPU_TEMP_POLL_SECONDS to back off. 3 new tests cover the wrapper: field threading, disabled-config returns None, configured poll interval actually reaches the daemon thread (verified via mocked temperature query and timing). Full unit suite: 270/270. * Add structlog dep, worker config, and notify_job_failed Phase 5a: foundation pieces the worker needs in subsequent commits. - structlog>=24.0 added to runtime deps. Logger configuration lands in Phase 7's app.py lifespan handler; the worker will import and use structlog from Phase 5c. - ServiceConfig gains three new fields tied to env vars the worker honors: intermediate_dir (REFINERY_INTERMEDIATE_DIR), max_queue_size (REFINERY_MAX_QUEUE_SIZE, default 100), and job_retention_seconds (REFINERY_JOB_RETENTION_SECONDS, default 3600). - notifier.notify_job_failed(job_id, stage, input_uri, error) is the Slack hook the worker fires on per-job failure. Presigned URL query strings are stripped before the message is sent so the channel stays legible. Successful jobs continue to fire nothing — the failure-only policy is enforced at the call sites in Phase 5c. * Add in-memory data primitives for service-mode jobs Phase 5b: dataclasses, registries, queue, and ID-generation helpers the worker will operate on in Phase 5c. - Job dataclass tracks a single job's lifecycle: queued -> processing -> completed | failed. Carries timestamps, failure detail (stage, error, retryable), and duration on success. - Batch dataclass tracks a whole batch: batch_id, summary_uri, job_ids, and a pending_count that the worker decrements as jobs settle. - JobRegistry and BatchRegistry are thread-safe in-memory dicts. BatchRegistry.decrement_pending atomically decrements and returns the new value so the worker can detect the zero-transition without a separate read; never goes negative. - JobQueue wraps queue.Queue with a configurable maxsize. Over-capacity put_nowait raises queue.Full, which the HTTP endpoint will translate into 429 Too Many Requests in Phase 7. - Registries bundle ties the three together for the worker and endpoint constructors. - make_job_id() returns "rfj_<16-hex>" via secrets.token_hex(8); make_batch_id() returns "btc_<16-hex>". 64 bits of entropy per id, collision-resistant for any realistic deployment. 22 new tests cover identifier shape, registry CRUD, thread-safety under contention, decrement atomicity across 8 threads, queue FIFO ordering, and queue cap enforcement. Full unit suite: 295/295. * Implement service-mode worker, process_job, and finalize_batch Phase 5c: the actual job-processing loop and batch finalization. process_job(job, handles, config, registries) runs one job end to end: marks the job as processing, downloads or symlinks the input into a per-job temp dir (no copy for file:// inputs), invokes run_pipeline with the supplied warm handles, inspects per-stage outcomes for the content_id, deserializes the per-stage JSONs the pipeline wrote, assembles a CombinedTranscript via build_combined, and uploads to job.output_uri. On any failure (download error, unreachable file, stage failure, upload error, uncaught exception) the worker records the failure detail on the in-memory Job record, fires notify_job_failed via the failure-only Slack hook, and never writes anything to job.output_uri. Failures collapse into the batch summary as the canonical failure surface. When REFINERY_INTERMEDIATE_DIR is set on the config, the worker copies the per-stage JSONs from the temp dir to <dir>/<job_id>/ after the upload succeeds. Best-effort: copy failures log a structured warning and do not fail the job. finalize_batch(batch_id, registries) reads every terminal Job from the batch's job_ids, converts each into a JobSummaryEntry, builds a BatchSummary via build_summary, and uploads to the caller-supplied summary_uri. Summary upload failures log but do not crash the worker — the orchestrator's CloudWatch idle-time alarm catches "summary never appeared" as the backstop. Worker class wraps the queue.get -> process_job -> mark_terminal -> maybe-finalize loop in a daemon thread. One worker per container — GPU is the bottleneck. Uncaught exceptions inside process_job are absorbed by the top-level handler so the worker thread never dies; the affected job is marked failed and the loop continues. External dependencies (run_pipeline, fetch_input, upload, notify_job_failed) are looked up at module scope so tests patch via the standard patch("src.service.jobs.<name>") seam. structlog emits structured events at every state transition; logger configuration lands in Phase 7's lifespan handler. 11 new tests cover the worker behavior in isolation: happy path, download / transcribe / upload / uncaught-exception failure paths, optional intermediate persistence, finalize_batch upload success and failure, Worker class end-to-end with queue, worker continues after one job fails in a batch, worker stop terminates the thread. Full unit suite: 306/306. * Add RetentionSweeper for terminal job and batch records Phase 5d: the periodic cleanup that keeps the in-memory registries from growing unboundedly over the container's lifetime. RetentionSweeper is a background daemon that ticks every 60s (configurable). On each tick it evicts every terminal Job (status completed or failed, completed_at/failed_at older than config.job_retention_seconds) and every terminal Batch (completed_at older than the same window). Pending jobs and in-flight batches are never touched. After eviction GET /jobs/{id} returns 404, matching the integration contract's "absence is not a contract violation" clause. Orchestrators that need to find old outcomes already have the per-batch summary in object storage at that point — the per-job in-memory record is just a polling-convenience surface. Best-effort: a sweep_once exception logs a structured warning and the thread keeps ticking. Never dies on its own. Exposed sweep_once as a public method so tests and a future admin endpoint can trigger an immediate sweep without waiting for the next tick. 6 new tests cover terminal-job eviction, terminal-batch eviction, empty-registry sanity, thread lifecycle, end-to-end sweep via the daemon thread, and continue-after-error resilience. Full unit suite: 312/312. Phase 5 complete. * Implement bearer-token auth middleware Phase 6: validates Authorization: Bearer <key> against an env-loaded allowlist (REFINERY_API_KEYS). One tier of API keys — "is this caller allowed to use Refinery at all" — decoupled from the storage-auth story (presigned URLs handle bucket access on the caller's side). - load_allowlist_from_env() reads REFINERY_API_KEYS, splits on comma, strips whitespace, drops empty entries. Raises AllowlistError on empty/unset so the container fails fast at startup. - fingerprint(token) returns the first 8 hex chars of SHA-256(token). Stable across calls, one-way, suitable for audit-log correlation without leaking the bearer. - make_bearer_dependency(allowlist) returns a FastAPI Depends-compatible callable that validates the bearer scheme + token. Returns the fingerprint on success so route handlers can include it in structured logs. Raises HTTPException(401) with {"error": "invalid_bearer"} on any failure (missing header, wrong scheme, empty token, not in allowlist) — the same response shape regardless of which check failed so callers can't distinguish failure modes via timing or body. The allowlist set is frozen at dependency-construction time so later mutations to the caller's set don't change auth behavior — a deliberate guard against accidental privilege escalation. 20 new tests: fingerprint properties (stable, distinct, opaque, 8-hex chars), allowlist parsing edge cases (whitespace, empty entries, unset, alternative env var), direct dependency invocation (valid/invalid token, missing header, wrong scheme, empty token, frozen-against-mutation), and four end-to-end TestClient round-trips including the route-scoped behavior that lets /health stay unauthenticated alongside protected /transcribe and /jobs routes. Full unit suite: 332/332. * Implement FastAPI app, lifespan, and endpoints Phase 7: wires every prior service-mode commit into the actual HTTP surface that satisfies the integration contract. src/service/app.py: - create_app(config, *, registries, readiness, api_keys, handles, enable_lifespan_warmup) — pure factory. Production callers (run()) pass only config; tests inject pre-built state. State attrs are set at construction so endpoints work whether or not the lifespan handler has run. - TranscribeRequest / JobRequest Pydantic models with URI scheme validation via src.service.uri_io.validate_scheme. - POST /transcribe (auth): per-config batch cap (default 25), queue-capacity precheck (429 on full), registers Job records first, Batch second, then enqueues so the worker never pulls a job whose record isn't in the registry. - GET /jobs/{job_id} (auth): 404 with {"error":"job_not_found"} on miss; full status body otherwise. - GET /health (no auth): 200 when ready, 503 with stage+detail while loading or failed. - Lifespan handler: pre-supplied handles -> starts Worker + RetentionSweeper synchronously. Otherwise spawns warm_up() in a background daemon thread so /health stays reachable as 503/loading while the ~10s model load runs. - _StructLogContextMiddleware binds http_method + http_path into structlog contextvars so every log record processed during a request includes them — without the Authorization header. - _configure_structlog wires JSON renderer (default) or console renderer (REFINERY_LOG_FORMAT=console) for local dev. - run() is the `audio-refinery-service` entry point: configures structlog, validates the allowlist (fails fast on empty), builds the app, runs uvicorn binding to all interfaces on REFINERY_PORT. src/service/lifecycle.py: - ServiceConfig gains max_batch_size: int = 25 to match the new server-side batch cap in POST /transcribe. 21 new tests cover the full HTTP surface: /health states (loading/ready/failed, unauthenticated), /transcribe happy paths and validation (missing summary_uri, empty jobs, bad scheme on each URI, batch-too-large), auth + queue-full 429, /jobs happy and 404 + auth paths, and a lifespan smoke test confirming Worker + RetentionSweeper start when handles are supplied. Full unit suite: 353/353. * Extract HTTP transport schemas to src/service/api_schemas.py Phase 7's app.py had Pydantic request/response models inline, mixing pure data with framework wiring. That's the same pattern Phase 3's schemas split moved away from. Move the 5 transport schemas (JobRequest, TranscribeRequest, TranscribeResponse, JobStatusResponse, HealthResponse) and the _validate_uri helper into a new src/service/api_schemas.py. app.py imports them and keeps only the framework wiring (endpoints, lifespan, middleware, run() entrypoint). Two schema files now exist with clearly different scopes: schemas.py — content (what we serialize to disk: transcript + batch summary). Evolution gated by what the pipeline produces. api_schemas.py — transport (what flows over the wire). Evolution gated by the integration contract in _docs/refinery-integration.md. Keeping the two layers separate means a transcript-schema bump (e.g., v0.3.0 alignment splitting aligned words into a separate array) doesn't churn the HTTP wire format, and vice versa. Tests unchanged — they hit the HTTP endpoints, not the models directly. Full unit suite: 353/353. * Extract ServiceConfig and PipelineHandles to src/service/config.py Apply the same data-vs-behavior split the rest of the package now follows. ServiceConfig and PipelineHandles are pure data — a frozen dataclass and a mutable bundle of pre-loaded model handles — that previously lived in lifecycle.py alongside warm_up(), ServiceReadiness, the thermal guard, and the default_thermal_trip callback. Mixing pure data with the behavior that consumes it violates the same convention we've now applied to schemas.py vs jobs.py and api_schemas.py vs app.py. New src/service/config.py holds: - ServiceConfig (16 frozen fields covering device, models, queue, retention, thermal guard, and worker behavior). - PipelineHandles (diarization, whisperx, optional sentiment). - No behavior, no imports of anything that runs side effects. lifecycle.py now holds only behavior: warm_up(), ServiceReadiness state machine, WarmupError, start_thermal_guard(*), default_thermal_trip. Imports the two dataclasses from config. Touched importers across the package and test suite: src/pipeline.py — TYPE_CHECKING import path updated. src/service/app.py — split import into config + lifecycle. src/service/jobs.py — import from config. tests/service/ — three files updated. tests/test_pipeline.py — both injection-path tests. CLAUDE.md and docs/DEVELOPMENT.md project-structure trees list the new module. Full unit suite: 353/353. mypy clean across all 9 service modules. * Rewrite Dockerfile for service mode The existing Dockerfile defaulted to `audio-refinery --help` and used the CUDA devel base image. Service mode replaces both. Dockerfile changes: - Base swap: nvidia/cuda:12.1.1-cudnn8-devel-ubuntu22.04 -> nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04. Drops the full CUDA SDK we don't need at runtime; ~3GB image-size win. - syntax=docker/dockerfile:1.7 header for BuildKit features. - apt install gets --no-install-recommends so the image stays lean. ffmpeg + curl + git remain in place. - All Python/whisperx/torch install steps carry over verbatim, with `--system` flags on uv pip install so packages land in the container's system Python (no venv). - EXPOSE 8000 declares the service port. - HEALTHCHECK polls /health every 10s after a 60s start-period. Start-period covers the ~10s warmup before /health flips from 503 (status=loading) to 200 (status=ok); 60s leaves headroom for slower GPU loads and avoids flapping the container. - CMD is now ["audio-refinery-service"]. CLI mode still works as an override: `docker run --gpus all <image> audio-refinery pipeline --help`. .dockerignore picks up the new gitignored working dirs (_reqs/, _plans/, _docs/) so the build context stays tight. Makefile gains two targets: - build-image reads the version from pyproject.toml and tags both lunarcommand/audio-refinery:<version> and :latest. The version pin keeps the tag in lock-step with releases; bumping pyproject.toml is the only place to change the image tag. - run-service-local does a `docker run --gpus all` against bind-mounted /inbox + /outbox + /summaries dirs for local file:// dev. Fails fast if REFINERY_API_KEYS or HF_TOKEN isn't set in the operator's environment. Tests unchanged: 353/353. Image build is a Phase 9 / Phase 12 concern (GPU host + multi-GB image); this commit just lands the source artifacts that the build will consume. * Add service-mode end-to-end and integration tests Phase 9: two new test files cover the full service stack at different levels. tests/service/test_e2e.py (4 tests, no GPU, runs in CI): - The pipeline is mocked but every other layer runs real. POST -> auth -> URI validation -> queue -> Worker thread -> URI I/O all execute against the real FastAPI app and Worker class. - Covers happy path (transcript + summary written), failure surface (bad input -> no transcript, summary captures failure with retryable flag), multi-job submission order, and HTTPS end-to-end via httpx.MockTransport (verifies GET on input URL plus PUT on both transcript and summary URLs with JSON bodies that round-trip through the schema validators). tests/service/test_integration.py (1 test, GPU-required): - test_real_pipeline_file_uri_end_to_end runs the real pipeline against a known test WAV. Lifespan warm_up loads real pyannote + WhisperX + (optional) sentiment, POSTs a job via file://, waits up to 5 minutes for the batch, then asserts the transcript JSON is a valid CombinedTranscript with non-empty diarization + transcription + model_versions, and the summary JSON captures the success. - Skips when the test WAV is missing or HF_TOKEN is unset. - @pytest.mark.integration excludes it from default runs; triggered via make test-integration. The e2e tests complement test_app.py (HTTP boundary in isolation) and test_jobs.py (Worker in isolation) by exercising both together. The integration test complements both by exercising the real pipeline at the same time. Full unit suite: 357/357. 6 integration tests deselected by default (5 existing CLI + 1 new service). * Make integration-test audio location configurable The hardcoded /mnt/fast_scratch/test_fixtures/test_audio.wav path was workstation-specific (a local RAM disk that may not be present on other GPU hosts). Replace it with a configurable fixture so any host with WAV files can run the integration suite. tests/conftest.py adds two shared fixtures: - integration_audio_files: returns every WAV in the configured directory, or skips cleanly with a clear message. Resolution order: REFINERY_TEST_AUDIO_DIR env var, then tests/_audio_fixtures/ in the repo (gitignored). - integration_audio: single-file convenience wrapper that returns the first WAV. Existing tests that only need one file use this unchanged. tests/test_integration.py drops its local fixture and the hardcoded TEST_AUDIO_PATH constant. Existing 5 tests now use the shared conftest fixture and skip with the clearer message when no audio is configured. tests/service/test_integration.py drops its local fixture and adds a second test that exercises the batch-summary path against the real pipeline: - test_real_pipeline_multi_job_batch POSTs every WAV in the fixture dir as a single batch, waits for completion (timeout scales with input count), and asserts every transcript landed, the summary captures all jobs in submission order, and the totals are correct. - Skips when fewer than 2 WAVs are available, since the single- job case is already covered by the existing test_real_pipeline_file_uri_end_to_end. - Common spin-up logic extracted into _run_batch() so both tests share the warmup + POST + wait scaffolding. .gitignore and .dockerignore add tests/_audio_fixtures/ so the developer-local fallback dir doesn't leak into git or Docker context. Makefile help line updated to mention REFINERY_TEST_AUDIO_DIR and HF_TOKEN; an echo before the pytest invocation reminds operators of the requirements. Usage: export REFINERY_TEST_AUDIO_DIR=/path/to/wavs export HF_TOKEN=hf_xxx make test-integration Full unit suite: 357/357. 7 integration tests now skip cleanly when fixtures are missing (was 6 — added the multi-job batch). * Make service-mode Demucs scratch location configurable Service mode was silently dropping the RAM-disk benefit the CLI had. Each job's tempfile.TemporaryDirectory() landed in /tmp, which in Docker is overlayfs (disk-backed) by default. Operators on RAM-rich hosts had no clean knob to point scratch at tmpfs; operators on RAM-tight VMs had no knob to point it at an SSD. ServiceConfig gains scratch_dir: Path | None = None, sourced from new REFINERY_SCRATCH_DIR env var. When set, process_job passes it as `dir=` to tempfile.TemporaryDirectory(), so every job's input download + Demucs stems + per-stage JSONs land under the operator- chosen path. When unset (CLI test runs, dev setups), tempfile falls back to TMPDIR / /tmp. src/service/app.py: - _resolve_scratch_location(config) returns (path, fstype). - _detect_fstype(path) reads /proc/mounts and returns the filesystem type of the deepest mount containing path. None on non-Linux or unreadable /proc. Pure stdlib, no extra deps. - service.ready log line includes scratch_dir + scratch_fstype + scratch_is_tmpfs so operators can verify from container logs what they got. - When the detected fstype is not tmpfs, emit a scratch.not_tmpfs warning with a hint about mounting tmpfs and setting REFINERY_SCRATCH_DIR. Operators on RAM-tight VMs can ignore the warning intentionally. Dockerfile: - Pre-create /scratch owned by the refinery user. - ENV REFINERY_SCRATCH_DIR=/scratch makes it the default location. - VOLUME ["/scratch"] declares the path so the contract is visible in docker inspect and any operator scanning the image. - Operators bind tmpfs at /scratch via: docker run --mount type=tmpfs,destination=/scratch ... or accept the disk-backed default (overlayfs) on tight hosts. Tests: - test_process_job_creates_per_job_temp_under_scratch_dir_when_configured asserts the worker honors scratch_dir. - 4 tests for _resolve_scratch_location and _detect_fstype cover the config-set / config-None branches plus /proc/mounts parsing (synthetic fake mounts) and the non-Linux fallback to None. Full unit suite: 362/362. * Drop workstation-specific Demucs scratch default in CLI The hardcoded /mnt/fast_scratch path in src/separator.py and the interactive prompt UX in src/cli.py were specific to one workstation's setup — same anti-pattern as the integration-test audio path we just cleaned up. Resolution rules now match service mode: 1. REFINERY_SCRATCH_DIR env var (shared with the service). 2. tempfile.gettempdir() / "audio-refinery-demucs" — host-agnostic default that works on laptops, containers, shared hosts. src/separator.py: - DEFAULT_OUTPUT_DIR is computed by _default_output_dir() at import time, honoring REFINERY_SCRATCH_DIR. - No more hardcoded /mnt/fast_scratch. src/cli.py: - _resolve_demucs_scratch drops the /mnt/fast_scratch.is_mount() check and the interactive "RAM Disk Not Available" prompt. It now consults REFINERY_SCRATCH_DIR / DEFAULT_OUTPUT_DIR and detects whether the resolved path lives on tmpfs. - _mkdir_demucs drops the interactive "RAM Disk Not Writable" PermissionError fallback prompt. A non-writable scratch dir is a real operator misconfiguration now (since the default works everywhere) and surfaces as a normal PermissionError. - demucs_on_ramdisk detection moves from .is_mount() (which only works for explicit mount points) to detect_fstype() == "tmpfs" (works for any path on a tmpfs mount). - The runtime banner shows "(RAM-backed)" or "(disk-backed — set REFINERY_SCRATCH_DIR to a tmpfs mount for faster batches)" instead of the workstation-specific "RAM disk not mounted" wording. src/fs_utils.py is a new tiny stdlib-only module hosting detect_fstype(path). Both src/service/app.py and src/cli.py import it. Previously only service/app.py had a private copy; extracting it removes the duplication and gives the CLI access to the same logic. Tests: - tests/test_fs_utils.py is new, with the 2 tests that previously lived in tests/service/test_app.py (moved with the function). - No CLI tests change because the old workstation prompt path wasn't exercised by tests (it was interactive-prompt code). Full unit suite: 362/362. Note: tests/service/test_e2e.py::test_e2e_https_uri_routes_* flakes occasionally during the full-suite run but passes consistently in isolation. Likely a TestClient lifespan teardown timing issue interacting with httpx MockTransport state in neighboring tests. Tracking as a known flake — not blocking. * Fix flake in test_e2e_https_uri_routes_* The test was patching run_pipeline AFTER the POST. The worker thread is real and starts immediately when the TestClient lifespan opens, so under load it could pull the queued job and call the unpatched run_pipeline before the post-POST patch took effect — the real run_pipeline then crashed against the MagicMock handles and the assertions failed. Use the dynamic-side-effect pattern that the multi-job test already used: derive content_id from the source_dir glob at call time, apply the patch in the OUTER `with` block before the POST. The race window is closed: by the time the worker pulls the job, every patch it needs is already in place. Confirmed clean across 5 consecutive full-suite runs. Note for posterity: this was a test-only race. Production POST ordering is jobs.add → batches.add → queue.put_nowait, so the worker can never pull a job whose registry records aren't already populated. * Gate POST /transcribe on service readiness The endpoint accepted work during warmup, creating two problems: 1. Zombie jobs: if warmup ultimately failed (CUDA OOM, missing weights, bad HF token), every job queued before that point sat in the queue of a container the orchestrator was about to restart. Those jobs got dropped silently. 2. Split-brain readiness: /health reported "loading" / "failed" while /transcribe still answered 202. Operators and consumers reasonably expected the two surfaces to track. Add a readiness gate at the top of POST /transcribe: - state="ready" -> 202 (existing happy path) - state="loading" -> 503 with detail {state, stage} - state="failed" -> 503 with detail {state, stage} Response carries a Retry-After: 5 header so the caller's SQS retry path can back off intentionally. GET /jobs/{id} is deliberately NOT gated. The in-memory registry is independent of pipeline readiness, and the endpoint stays useful for operators debugging a stuck-loading container. Lifespan fix: when create_app is called with handles pre-supplied (test path, or any caller that warms outside the lifespan), warm_up is skipped — but warm_up is also what normally calls readiness.mark_ready(). Without an explicit mark in the handles-supplied branch, readiness stayed "loading" and the new gate rejected every request. Mark ready alongside starting the background workers in that branch. Tests: - test_transcribe_returns_503_during_warmup - test_transcribe_returns_503_when_warmup_failed - _client() helper defaults to a ready ServiceReadiness so the happy-path tests don't have to opt in. Tests that exercise the gate pass an explicit loading/failed readiness. - Two e2e tests had the same race the HTTPS test had (patching run_pipeline after the POST). Switched both to the dynamic- side-effect pattern that lets the patch sit in the outer `with` block. 10 consecutive full-suite runs clean. Full unit suite: 364/364. * Refresh CLI help text after scratch-location cleanup The pipeline command's docstring still described the old /mnt/fast_scratch + interactive-prompt UX from before the scratch cleanup landed. Updated to describe the new resolution order: REFINERY_SCRATCH_DIR/demucs (operator-configured tmpfs mount preferred) -> tempfile.gettempdir()/audio-refinery-demucs. Also: - --demucs-dir help: drop "RAM disk check" phrasing; mention REFINERY_SCRATCH_DIR as the normal operator knob. - pipeline-parallel scratch-suffix label: "(RAM-backed)" / "(disk-backed)" matches the wording the pipeline command's banner uses. - Pipeline docstring: replaced "RAM disk" with "scratch directory" where the wording was assumptive. No behavior change; help text only. Tests stay at 364/364. * Drop required audio_ prefix on CLI source filenames The pipeline's file discovery was hardcoded to match audio_<id>.wav, a naming convention inherited from the upstream audio-extractor tool. New OSS adopters with their own audio files were getting "No audio_*.wav files found" against a directory full of legitimate .wav files. The convention leaked into help text, error messages, and the path helpers that mirror Demucs's output subdir. CLI mode now accepts any .wav file. content_id is the filename stem with an optional audio_ prefix stripped, so previous runs that wrote diarization_<id>.json keep landing in the same place after the rename. src/pipeline.py: - discover_files: glob("*.wav"); content_id = stem.removeprefix("audio_"). - _vocals_path / _no_vocals_path: take input_stem (the actual filename stem Demucs derives its subdir from) instead of content_id. Demucs subdir matches the input filename whether or not it has the audio_ prefix. - All call sites updated to pass wav_path.stem. - Stage runners (run_diarization_stage, run_transcription_stage) now capture wav_path from the iterated tuple instead of discarding it with _. src/service/jobs.py: - Service mode creates the per-job audio file as <content_id>.wav (no audio_ prefix). Equivalent to what came before, just one fewer indirection in the filename. - _content_id_from_job_id docstring no longer claims the prefix is required. src/cli.py: - "No audio_*.wav files found" -> "No .wav files found" - "Create the directory and place audio_<content_id>.wav files" -> "place .wav files" tests/test_pipeline.py: - source_dir fixture creates <cid>.wav instead of audio_<cid>.wav. - test_discover_files_ignores_non_matching split into: - test_discover_files_ignores_non_wav_extensions (.mp3 still skipped) - test_discover_files_accepts_arbitrary_stem (proves any stem works, including dashes and mixed case) - test_discover_files_strips_audio_prefix_for_backward_compat (proves the prefix-strip backward-compat path) - _sep_side_effect helpers no longer slice off audio_ since the stem doesn't have it. tests/service/test_e2e.py + test_jobs.py: - Dynamic-side-effect helpers glob "*.wav" instead of "audio_*.wav". - Synthetic audio paths in mock pipeline outputs use <content_id>.wav. Full unit suite: 366/366 across 5 consecutive runs. Two new tests explicitly cover the relaxed behavior; existing tests cover the backward-compat prefix-strip path. * Treat silent input as a sentiment no-op, not a failure analyze_sentiment() raised SentimentError("No usable text found in transcription segments") when the transcription had zero usable text. That collapsed two distinct outcomes into the same failure path: 1. Silent input — the file genuinely had no speech, so Whisper returned no segments (or empty segments). Not a real error; sentiment ran successfully, it just had nothing to score. 2. Pipeline crashed on every segment — the segments had text but every classifier call raised. Actual failure. Distinguish them: return an empty SentimentResult for case 1, keep raising for case 2. The pipeline's Failed table no longer flags silent inputs; the Slack summary stops saying "complete with 1 failure(s)" when sentiment found nothing to score. Tests: - test_no_segments_returns_empty_result (was _raises_sentiment_error) - test_all_empty_text_returns_empty_result (same flip) - test_all_segments_fail_classification_raises (new — proves the real-failure path still raises with the matching message) The merge_sentiment_into_transcription helper handles empty results correctly already (it just writes no fields per segment). Full unit suite: 367/367. * Resolve symlinks when predicting Demucs output paths src/separator.py:separate() calls Path(input_file).resolve() before invoking Demucs, so Demucs writes its per-track subdir using the resolved file's stem — not the symlink's name. The pipeline's _vocals_path / _no_vocals_path callers used wav_path.stem (the symlink's name when the input was symlinked), producing a path mismatch the diarization stage surfaced as: Diarization failed for <id>: Input file not found: /tmp/.../stems/htdemucs/<symlink-stem>/vocals.wav This bit service mode in particular: process_job symlinks the fetched input into the per-job source_dir as <content_id>.wav, where content_id is derived from job_id. The real file's stem (e.g., the original WAV name) is different, so Demucs's actual output landed at htdemucs/<original-stem>/ while the pipeline looked at htdemucs/<content_id>/. Fix: change all 7 _vocals_path / _no_vocals_path callers in pipeline.py to pass wav_path.resolve().stem. For regular files (typical CLI mode) this is a no-op — resolve() returns the file itself. For symlinks (service mode, or any CLI user whose extracted/ contains symlinks) it follows the link the same way separate() does, so the predicted path matches Demucs's actual output. The helper signatures stay string-based; the .resolve() lives at the call sites where wav_path is in scope. New test test_run_pipeline_resolves_symlinks_for_demucs_output_paths sets up a symlink with a different stem from the target, mocks separate() to write to the resolved name (mirroring real Demucs behavior), and asserts the diarization stage doesn't fail with a missing-vocals error. Pinned so we don't regress this. Full unit suite: 368/368. * Force UTF-8 decoding of Demucs subprocess output subprocess.run(cmd, capture_output=True, text=True) decodes stdout and stderr using locale.getpreferredencoding(False). On hosts whose locale resolves to ASCII — common in minimal container images and some CI / service runners — Demucs's tqdm progress bars (which use Unicode glyphs ━, █, U+2501 etc.) crash the decoding with: 'ascii' codec can't decode byte 0xe2 in position 127: ordinal not in range(128) The error bubbles out of subprocess.run before separate() can even check the returncode. Demucs's actual separation work already finished and the stems are on disk; the failure is purely in capturing stderr. Specify encoding="utf-8", errors="replace" explicitly. errors= "replace" maps any genuinely undecodable byte sequence to U+FFFD rather than raising — losing a few progress-bar chars to replacement is harmless, while crashing the entire job is not. text=True and capture_output=True stay as-is. This bit service mode in particular: the worker hit it on the second job in a batch (the first apparently got lucky with output timing). CLI mode worked on the same files for the same reason — Demucs's stderr varies between invocations. Regression test pins the kwargs separate() passes to subprocess.run so we don't drift back. Full unit suite: 369/369. * Pin Dockerfile uv installs to python3.11 ubuntu:22.04 ships python3.10 as the default python3. The base image installs python3.11 alongside via apt, but uv's --system flag resolves to whatever python3 points at (3.10), then fails dependency resolution: Because the current Python version (3.10.12) does not satisfy Python>=3.11,<3.12 and audio-refinery==0.1.1 depends on Python>=3.11,<3.12, audio-refinery==0.1.1 cannot be used. Set UV_PYTHON=python3.11 once, before the first `uv pip install`, so every install in this Dockerfile targets the 3.11 site-packages that pyproject.toml requires. uv accepts bare interpreter names and resolves via PATH; no need to thread --python through each command. `pip install --user uv` still runs against python3 (3.10), which is fine because uv is a Rust binary that doesn't depend on the installer's Python. Image build was failing at: [ 7/11] RUN uv pip install --system -e . This fix unblocks `make build-image` so Test 6 can proceed. * Fix Docker build perms and include README The previous Dockerfile switched to the refinery user before any pip installs ran, but `uv pip install --system` writes to root-owned `/usr/lib/python3.11/site-packages`, and the editable install needs to write `audio_refinery.egg-info` into `/app`. Move all installs to root and switch to refinery only for the runtime. Also un-ignore README.md so setuptools stops warning about a missing file referenced from pyproject.toml's `readme` field. * Add `audio-refinery serve` CLI subcommand Expose the HTTP service under the same binary users already know, so local-dev workflows need only one entry point name. It lazy-imports src.service.app.run, keeping uvicorn/FastAPI off the import path for every other CLI invocation. The production container CMD still calls audio-refinery-service directly. * Rename docs/ files to lowercase Adopt lowercase filenames inside docs/ ahead of the dual-mode docs restructure, reserving uppercase for the root meta files (README, CHANGELOG, etc.) per UNIX/GitHub convention. Updates every live cross-reference; the [0.1.1] CHANGELOG entry keeps its original DEPLOYMENT.md spelling as a historical record. * Add docs/cli.md and slim README to a two-path landing Extract the full per-command CLI manual into docs/cli.md and reduce the README to a high-level overview, a "choose your path" landing for the CLI and service modes, shared install/token setup, and the docs table. Correct stale references while extracting: the default Demucs scratch dir is /tmp/audio-refinery-demucs (REFINERY_SCRATCH_DIR override), batch input accepts any *.wav with the audio_ prefix optional, and the scaffolded --emotion/--events pipeline flags are documented. * Add docs/service.md operational guide Document the HTTP service mode end to end: quickstart, the three endpoints with status codes, combined-transcript and batch-summary schemas (v1.0.0), the full REFINERY_* / HF_TOKEN / SLACK env table, URI schemes, readiness-probe and scaling ops, the file:// dev loop, and troubleshooting. Field names, defaults, and status codes are drawn from the service code so the guide stays authoritative. * Add docs index hub and service notes to existing docs Add docs/index.md as the navigation hub. Document service mode in the guides that contributors read: development.md gains "Running the Service Locally" and a service-tests note, CLAUDE.md lists the serve subcommand and the FastAPI/uvicorn/httpx deps, and CONTRIBUTING.md flags that run_pipeline's optional model_handles parameter must stay intact for service mode. * Rework deployment.md around CLI and service modes Split the guide into CLI deployment (workstation batch) and service deployment (containerized HTTP API). Replace the stale embedded Dockerfile with a pointer to the repo Dockerfile and make build-image, and replace the DIY PostgreSQL worker-queue pattern — now superseded by service mode — with service deployment guidance cross-linked to service.md. Correct the scratch-directory default and keep the prerequisites, monitoring, and VRAM sections. * Document execution modes and fix stale doc references Add an "Execution Modes: CLI and Service" section to architecture.md so it covers the core/CLI/service split the README and index promise, correct the stale audio_<id>.wav input-naming note in use-cases.md to match the any-*.wav behavior, and point the service dev loop at the make run-service-local target. * Prepare release v0.2.0 Bump the version to 0.2.0 and promote the CHANGELOG [Unreleased] section, documenting service mode, the audio-refinery serve subcommand, the host-agnostic scratch directory, the relaxed batch input naming, and the docs restructure. Add a docker-publish job to the release workflow so a v*.*.* tag also builds and pushes the image to Docker Hub. The job needs DOCKERHUB_USERNAME and DOCKERHUB_TOKEN repo secrets; without them it fails at login while the GitHub release still succeeds. * Address PR review feedback for v0.2.0 Substantive fixes: - Make all service-mode timestamps UTC-aware via a shared _utcnow() helper, so the combined transcript no longer mixes naive and aware datetimes and the retention cutoff compares correctly. - Redact the full bearer token in the access-log filter with a regex instead of slicing a fixed 16-character window. - Derive the FastAPI app version from package metadata instead of a hardcoded 0.2.0-dev, so it tracks the release. - Align the standalone separate scratch default to $REFINERY_SCRATCH_DIR/demucs, matching the pipeline and docs. Cleanup: - Remove the unused JobStatus documentation alias in jobs.py. - Drop a dead Registries assignment and move test delete() calls out of assert expressions so they run under python -O.
1 parent 2f24343 commit 8eed5e6

53 files changed

Lines changed: 7920 additions & 1334 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.dockerignore

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ coverage.xml
2020
.env
2121
assets/
2222

23+
# Feature-planning working artifacts (gitignored locally; not part of the image)
24+
_reqs/
25+
_plans/
26+
_docs/
27+
28+
# Local audio fixtures for integration tests (large, host-specific)
29+
tests/_audio_fixtures/
30+
2331
# Dev tooling
2432
.pre-commit-config.yaml
2533
.claude/
@@ -33,13 +41,14 @@ assets/
3341
.git/
3442
.github/
3543

36-
# Docs and non-runtime files
44+
# Docs and non-runtime files. README.md is intentionally NOT excluded —
45+
# pyproject.toml references it via the `readme` field, and excluding it
46+
# triggers a setuptools warning at build time.
3747
docs/
3848
tests/
3949
CHANGELOG.md
4050
CONTRIBUTING.md
4151
CLAUDE.md
4252
SECURITY.md
4353
Makefile
44-
README.md
4554
uv.lock

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ on:
66
pull_request:
77
branches: [main]
88

9+
permissions:
10+
contents: read
11+
912
jobs:
1013
test:
1114
name: Run Tests

.github/workflows/release.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ on:
66
- 'v[0-9]*.[0-9]*.[0-9]*'
77
workflow_dispatch:
88

9+
permissions:
10+
contents: read
11+
912
jobs:
1013
test:
1114
name: Run Tests
@@ -78,3 +81,49 @@ jobs:
7881
generate_release_notes: true
7982
env:
8083
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
84+
85+
docker-publish:
86+
name: Publish Docker Image
87+
needs: github-release
88+
runs-on: ubuntu-latest
89+
# Requires DOCKERHUB_USERNAME and DOCKERHUB_TOKEN repo secrets. Without them
90+
# this job fails at the login step; the GitHub release above still succeeds.
91+
steps:
92+
- name: Checkout code
93+
uses: actions/checkout@v4
94+
95+
- name: Free disk space
96+
# The CUDA image is ~10GB built; standard runners need room cleared.
97+
run: |
98+
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/share/boost
99+
sudo apt-get clean
100+
df -h
101+
102+
- name: Set up Docker Buildx
103+
uses: docker/setup-buildx-action@v3
104+
105+
- name: Login to Docker Hub
106+
uses: docker/login-action@v3
107+
with:
108+
username: ${{ secrets.DOCKERHUB_USERNAME }}
109+
password: ${{ secrets.DOCKERHUB_TOKEN }}
110+
111+
- name: Compute image tags
112+
id: meta
113+
uses: docker/metadata-action@v5
114+
with:
115+
images: lunarcommand/audio-refinery
116+
tags: |
117+
type=semver,pattern={{version}}
118+
type=raw,value=latest,enable=${{ !contains(github.ref, '-') }}
119+
120+
- name: Build and push
121+
uses: docker/build-push-action@v6
122+
with:
123+
context: .
124+
push: true
125+
platforms: linux/amd64
126+
tags: ${{ steps.meta.outputs.tags }}
127+
labels: ${{ steps.meta.outputs.labels }}
128+
cache-from: type=gha
129+
cache-to: type=gha,mode=max

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
assets/
22
.claude/
3+
_reqs/
4+
_docs/
5+
_plans/
6+
tests/_audio_fixtures/
37

48
# Python
59
.venv/

CHANGELOG.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,39 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [Unreleased]
8+
## [0.2.0] - 2026-05-20
9+
10+
### Added
11+
12+
- HTTP service mode: a long-lived FastAPI service (`audio-refinery-service` entry point) exposing `POST /transcribe`, `GET /jobs/{id}`, and `GET /health`. Accepts multi-job batches, processes them serially on a background worker, and writes one combined transcript per job plus one summary per batch. Models load once at container startup and stay resident across jobs.
13+
- Combined transcript schema (v1.0.0) and per-batch summary schema (v1.0.0) — service-mode output documents that envelope the existing per-stage results so consumers read a single document per job/batch.
14+
- Bearer-token authentication via the `REFINERY_API_KEYS` allowlist; request and job-lifecycle logs carry a non-reversible caller fingerprint.
15+
- URI-driven I/O for the service: `https://` (presigned GET for input, presigned PUT for output/summary) and `file://` for local-dev and shared-volume deployments.
16+
- Multi-stage Dockerfile (CUDA 12.1 runtime base, non-root `refinery` user) published as `lunarcommand/audio-refinery` on Docker Hub.
17+
- `audio-refinery serve` CLI subcommand — a convenience wrapper around the service entry point.
18+
- Optional GPU thermal guard in service mode via `REFINERY_GPU_TEMP_LIMIT` / `REFINERY_GPU_TEMP_POLL_SECONDS`, reusing the CLI's monitor.
19+
- Service configuration via environment variables: `REFINERY_API_KEYS`, `REFINERY_DEVICE`, `REFINERY_WHISPER_MODEL`, `REFINERY_COMPUTE_TYPE`, `REFINERY_DEFAULT_LANGUAGE`, `REFINERY_SENTIMENT_ENABLED`, `REFINERY_SCRATCH_DIR`, `REFINERY_INTERMEDIATE_DIR`, `REFINERY_MAX_BATCH_SIZE`, `REFINERY_MAX_QUEUE_SIZE`, `REFINERY_JOB_RETENTION_SECONDS`, `REFINERY_PORT`, and `REFINERY_LOG_FORMAT`.
20+
21+
### Changed
22+
23+
- Demucs scratch directory is now host-agnostic: it defaults to `tempfile.gettempdir()/audio-refinery-demucs` and honors `REFINERY_SCRATCH_DIR` in both CLI and service mode, replacing the hard-coded `/mnt/fast_scratch` path.
24+
- CLI batch input now accepts any `*.wav` file; the `audio_` filename prefix is optional and stripped from the derived content ID, replacing the previous required `audio_<id>.wav` pattern.
25+
- Sentiment analysis treats segments with no transcribed speech (e.g. silent audio) as no-ops rather than failures.
26+
- Added `fastapi`, `uvicorn[standard]`, and `httpx` as main dependencies for service mode.
27+
- Documentation restructured into a two-path (CLI + service) layout: lowercase `docs/` filenames, a slimmed README with a "choose your path" landing, and new `docs/cli.md`, `docs/service.md`, and `docs/index.md`.
28+
29+
### Security
30+
31+
- Scoped both GitHub Actions workflows (`ci.yml`, `release.yml`) to least-privilege permissions. Workflow default is now `contents: read`; the `github-release` job retains an explicit `contents: write` override. Closes CodeQL workflow-permission alerts.
32+
- Upgraded vulnerable transitive dependencies: `idna` 3.11 → 3.15, `Mako` 1.3.10 → 1.3.12, `Pillow` 12.1.1 → 12.2.0, `urllib3` 2.6.3 → 2.7.0. Closes nine Dependabot alerts covering path-traversal, redirect-header forwarding, decompression-bomb, OOB-write, and integer-overflow CVEs.
33+
34+
### Known security debt
35+
36+
- 18 Dependabot alerts remain open against `torch` and `transformers` (including one critical and several high severity). Both packages are pinned to WhisperX-compatible versions (`torch==2.1.2`, `transformers>=4.30,<4.40`) and cannot be upgraded without breaking the current ASR pipeline. These alerts close together when v0.3.0 lands and removes WhisperX from the critical path; see the universal alignment plan for the migration path.
37+
38+
### Tests
39+
40+
- Suppressed live Slack webhook calls during the test run via an autouse `_no_slack` fixture in `tests/conftest.py`. Previously, `notifier._send()` loaded the developer's `.env` on every invocation and POSTed real messages whenever pipeline tests triggered end-of-batch notifications.
941

1042
## [0.1.1] - 2026-03-06
1143

CLAUDE.md

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,22 @@ audio-refinery/
3131
│ ├── gpu_utils.py # GPU queries via nvidia-smi
3232
│ ├── notifier.py # Slack webhook notifications
3333
│ ├── gpu_tflops.toml # GPU performance lookup table
34-
│ └── models/ # Pydantic output models
34+
│ ├── models/ # Pydantic output models
35+
│ │ ├── __init__.py
36+
│ │ ├── audio.py # AudioFileInfo, SeparationResult
37+
│ │ ├── diarization.py # DiarizationResult, SpeakerSegment
38+
│ │ ├── transcription.py # TranscriptionResult, TranscriptSegment, WordSegment
39+
│ │ └── sentiment.py # SentimentResult, SegmentSentiment, SentimentScore
40+
│ └── service/ # HTTP service mode (parallel to CLI; same core pipeline)
3541
│ ├── __init__.py
36-
│ ├── audio.py # AudioFileInfo, SeparationResult
37-
│ ├── diarization.py # DiarizationResult, SpeakerSegment
38-
│ ├── transcription.py # TranscriptionResult, TranscriptSegment, WordSegment
39-
│ └── sentiment.py # SentimentResult, SegmentSentiment, SentimentScore
42+
│ ├── app.py # FastAPI app, endpoints, lifespan, `audio-refinery-service` entry
43+
│ ├── auth.py # Bearer-token middleware + allowlist
44+
│ ├── jobs.py # Job registry, FIFO queue, background-thread worker
45+
│ ├── lifecycle.py # Model warmup, readiness state, pre-loaded handles
46+
│ ├── api_schemas.py # HTTP transport schemas (request/response Pydantic models)
47+
│ ├── config.py # ServiceConfig + PipelineHandles (pure data)
48+
│ ├── schemas.py # Combined transcript + batch summary Pydantic schemas (v1.0.0)
49+
│ └── uri_io.py # URI fetch/upload (https://, file://)
4050
├── tests/ # Test suite
4151
│ ├── conftest.py # Shared fixtures (GPU mock, tmp dirs, synthetic audio)
4252
│ ├── test_cli.py
@@ -48,9 +58,10 @@ audio-refinery/
4858
│ ├── test_pipeline_parallel.py
4959
│ ├── test_gpu_utils.py
5060
│ ├── test_integration.py # GPU-required tests (mark: integration)
51-
│ └── models/ # Pydantic model validation tests
61+
│ ├── models/ # Pydantic model validation tests
62+
│ └── service/ # Service-mode unit/integration tests
5263
├── docs/
53-
│ └── DEVELOPMENT.md # Developer guide
64+
│ └── development.md # Developer guide
5465
├── .github/
5566
│ └── workflows/
5667
│ ├── ci.yml # CI: unit tests + lint + type check
@@ -78,10 +89,20 @@ sentiment_analyzer.py → analyze_sentiment(transcription_file, ...) → Sentime
7889

7990
`pipeline.py` orchestrates these in sequence for batch processing.
8091

92+
### Service Mode (src/service/)
93+
94+
A long-lived HTTP service that wraps the same `run_pipeline()` core as the CLI.
95+
Adds: bearer-auth HTTP API (`POST /transcribe`, `GET /jobs/{id}`, `GET /health`),
96+
URI fetch/upload (`https://` presigned + `file://`), background-thread worker
97+
processing jobs serially, single combined transcript JSON output, and
98+
`.error.json` sidecars for failures. Models load once at container startup
99+
and stay resident. See `_reqs/service-mode.md` and `_plans/service-mode-plan.md`.
100+
81101
### CLI (cli.py)
82102

83103
- Click command group: `audio-refinery`
84-
- Commands: `separate`, `diarize`, `transcribe`, `sentiment`, `pipeline`, `pipeline-parallel`
104+
- Commands: `separate`, `diarize`, `transcribe`, `sentiment`, `pipeline`, `pipeline-parallel`, `serve`
105+
- `serve` lazy-imports and runs the FastAPI service — equivalent to the `audio-refinery-service` entry point (`src.service.app:run`). It's a convenience wrapper; the container CMD uses the direct entry point.
85106
- All commands use Rich panels/tables for output
86107
- GPU pre-flight check runs before any GPU operation (via `query_compute_processes()`)
87108
- Device strings follow PyTorch convention: `cuda`, `cuda:0`, `cuda:1`, `cpu`
@@ -111,6 +132,7 @@ All pipeline outputs are Pydantic models with full provenance:
111132
- **NumPy**: must stay `<2.0.0` (WhisperX ctranslate2 backend)
112133
- **WhisperX**: in `[project.optional-dependencies] conflicting` — install separately AFTER main deps due to PyTorch version conflict
113134
- **Python**: strictly 3.11.x — pyannote.audio and WhisperX don't support 3.12+
135+
- **FastAPI / uvicorn / httpx**: main deps for service mode — FastAPI + uvicorn serve the HTTP API; httpx fetches/uploads `https://` URIs in `src/service/uri_io.py`
114136

115137
## Development Commands
116138

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ Key design decisions:
117117
- `cli.py` and `pipeline.py` are the only orchestrators; stage modules have no CLI coupling
118118
- GPU pre-flight checks (via `query_compute_processes()`) run before any GPU operation
119119
- Test mocking: the `_gpu_free` autouse fixture in `conftest.py` patches nvidia-smi
120+
- `run_pipeline()` takes an optional `model_handles` parameter — service mode passes pre-loaded models through it so they load once at container startup instead of per call. Keep this parameter intact (and optional) when changing the pipeline signature; both CLI mode (no handles) and service mode depend on it.
120121

121122
## License
122123

Dockerfile

Lines changed: 67 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,85 @@
1-
FROM nvidia/cuda:12.1.1-cudnn8-devel-ubuntu22.04
1+
# syntax=docker/dockerfile:1.7
2+
# CUDA runtime variant (not devel) — saves ~3GB by dropping the full CUDA SDK
3+
# that's only needed at compile time. Matches the torch==2.1.2+cu121 wheel
4+
# pinned in pyproject.toml.
5+
FROM nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04
26

37
# System dependencies
4-
RUN apt-get update && apt-get install -y \
8+
# python3.11{,-dev,-venv}: pinned interpreter (pyannote + WhisperX need 3.11)
9+
# ffmpeg : Demucs audio I/O
10+
# curl : container HEALTHCHECK + occasional debug
11+
# git : whisperx is installed from a pinned git ref
12+
RUN apt-get update && apt-get install -y --no-install-recommends \
513
python3.11 python3.11-dev python3-pip python3.11-venv \
614
ffmpeg git curl \
715
&& rm -rf /var/lib/apt/lists/*
816

9-
# Non-root user
10-
RUN useradd -m -u 1000 refinery
11-
WORKDIR /app
12-
USER refinery
17+
# Create the runtime user and the dirs the runtime needs to own. Installs
18+
# below run as root (the only user that can write to /usr/lib/python3.11
19+
# system site-packages and /app for editable installs); the final USER
20+
# switch at the bottom hands the runtime over to a non-privileged account.
21+
RUN useradd -m -u 1000 refinery \
22+
&& mkdir -p /app /scratch \
23+
&& chown refinery:refinery /app /scratch
24+
25+
# Install uv as a Rust binary; doesn't depend on which Python installed it.
26+
RUN pip install uv
1327

14-
# Install uv
15-
RUN pip install --user uv
28+
# Pin every `uv pip install` to python3.11. The base image's `python3` is
29+
# 3.10; without this, uv's `--system` would resolve to 3.10 and fail
30+
# dependency resolution (pyproject.toml requires-python = ">=3.11,<3.12").
31+
ENV UV_PYTHON=python3.11
32+
33+
WORKDIR /app
1634

1735
# Copy and install the package (resolves main deps; may pull CPU-only torch)
18-
COPY --chown=refinery:refinery . .
19-
RUN uv pip install -e .
36+
COPY . .
37+
RUN uv pip install --system -e .
2038

21-
# Install WhisperX at the pinned commit — no-deps to avoid overwriting torch
22-
# v3.1.1 tag has the old API without device_index; use the correct commit instead
23-
RUN uv pip install --no-deps \
39+
# Install WhisperX at the pinned commit — no-deps to avoid overwriting torch.
40+
# v3.1.1 tag has the old API without device_index; use the correct commit.
41+
RUN uv pip install --system --no-deps \
2442
"whisperx @ git+https://github.com/m-bain/whisperX.git@741ab9a2a8a1076c171e785363b23c55a91ceff1"
2543

26-
# Install pinned WhisperX runtime deps
27-
# transformers must stay <4.40.0 — 4.40+ uses torch.utils._pytree.register_pytree_node
28-
# which was added in PyTorch 2.2 and breaks with the pinned 2.1.2
29-
RUN uv pip install \
44+
# Install pinned WhisperX runtime deps.
45+
# transformers stays <4.40.0 — 4.40+ uses torch.utils._pytree.register_pytree_node
46+
# added in PyTorch 2.2, which breaks with the pinned 2.1.2.
47+
RUN uv pip install --system \
3048
"av==16.1.0" "ctranslate2==4.7.1" "faster-whisper==1.2.1" \
3149
"flatbuffers==25.12.19" "nltk==3.9.2" "onnxruntime==1.24.1" \
3250
"transformers>=4.30.0,<4.40.0"
3351

34-
# Reinstall PyTorch with CUDA 12.1 wheels last — uv pip install -e . above may have
35-
# pulled CPU-only builds; this guarantees the CUDA wheel is what's actually used
36-
RUN uv pip install torch==2.1.2+cu121 torchaudio==2.1.2+cu121 \
52+
# Reinstall PyTorch with CUDA 12.1 wheels last — `uv pip install -e .` above
53+
# may have pulled CPU-only builds; this guarantees the CUDA wheel is what's
54+
# actually used at runtime.
55+
RUN uv pip install --system torch==2.1.2+cu121 torchaudio==2.1.2+cu121 \
3756
--extra-index-url https://download.pytorch.org/whl/cu121
3857

39-
CMD ["audio-refinery", "--help"]
58+
# Fix ownership of the editable-install artifacts (egg-info, etc.) so the
59+
# refinery user can read them at runtime. site-packages stays root-owned;
60+
# Python's default read perms (755) cover the import path fine.
61+
RUN chown -R refinery:refinery /app
62+
63+
# Per-job Demucs scratch lives here. The directory is declared as a VOLUME
64+
# so operators can bind a tmpfs mount (recommended on RAM-rich hosts for the
65+
# RAM-disk benefit Demucs throughput likes) or a fast disk on RAM-tight VMs.
66+
# The env var is honored by `tempfile.TemporaryDirectory(dir=...)` in the
67+
# worker. Unset REFINERY_SCRATCH_DIR to fall back to the system /tmp.
68+
ENV REFINERY_SCRATCH_DIR=/scratch
69+
VOLUME ["/scratch"]
70+
71+
# Drop to the non-privileged runtime user.
72+
USER refinery
73+
74+
# Service mode binds REFINERY_PORT (default 8000) on all interfaces.
75+
EXPOSE 8000
76+
77+
# Orchestrator-friendly health probe. /health returns 503 with status="loading"
78+
# during the ~10s model warmup, then flips to 200 status="ok". start-period
79+
# of 60s gives warmup ample headroom without flapping the container.
80+
HEALTHCHECK --interval=10s --timeout=5s --start-period=60s --retries=3 \
81+
CMD curl -fsS "http://localhost:${REFINERY_PORT:-8000}/health" || exit 1
82+
83+
# Service mode is the default. CLI is still invocable as an override:
84+
# docker run --gpus all <image> audio-refinery pipeline --help
85+
CMD ["audio-refinery-service"]

Makefile

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
.PHONY: help install install-dev install-whisperx test test-verbose test-integration test-coverage lint lint-fix format format-check type-check clean pre-commit pre-commit-install all-checks ci dev-setup stats
1+
.PHONY: help install install-dev install-whisperx test test-verbose test-integration test-coverage lint lint-fix format format-check type-check clean pre-commit pre-commit-install all-checks ci dev-setup stats build-image run-service-local
2+
3+
# Read the version from pyproject.toml so build-image stays in sync with releases.
4+
VERSION := $(shell awk -F'"' '/^version = /{print $$2; exit}' pyproject.toml)
5+
IMAGE_NAME := lunarcommand/audio-refinery
26

37
# Default target
48
.DEFAULT_GOAL := help
@@ -25,7 +29,9 @@ test: ## Run unit tests (no GPU required)
2529
test-verbose: ## Run unit tests with verbose output
2630
uv run python -m pytest tests/ -m "not integration" -vv
2731

28-
test-integration: ## Run integration tests (requires GPU + models)
32+
test-integration: ## Run integration tests (GPU + REFINERY_TEST_AUDIO_DIR + HF_TOKEN required)
33+
@echo "Integration tests use WAV files from REFINERY_TEST_AUDIO_DIR (or tests/_audio_fixtures/)."
34+
@echo "Set HF_TOKEN for pyannote model download. Tests skip cleanly when fixtures are missing."
2935
uv run python -m pytest tests/ -m "integration" -v
3036

3137
test-coverage: ## Run unit tests with coverage report (fails under 75%)
@@ -90,6 +96,24 @@ req = urllib.request.Request(url, data=data, headers={'Content-Type': 'applicati
9096
urllib.request.urlopen(req, timeout=5); \
9197
print('Test notification sent — check your Slack channel')"
9298
99+
build-image: ## Build the Docker image, tagged with the pyproject.toml version + :latest
100+
docker build -t $(IMAGE_NAME):$(VERSION) -t $(IMAGE_NAME):latest .
101+
@echo ""
102+
@echo "Built $(IMAGE_NAME):$(VERSION) and $(IMAGE_NAME):latest"
103+
104+
run-service-local: ## Run the service container against bind-mounted /inbox + /outbox + /summaries
105+
@if [ -z "$(REFINERY_API_KEYS)" ]; then echo "REFINERY_API_KEYS must be set in your environment"; exit 1; fi
106+
@if [ -z "$(HF_TOKEN)" ]; then echo "HF_TOKEN must be set in your environment"; exit 1; fi
107+
docker run --rm --gpus all \
108+
-p 8000:8000 \
109+
-e REFINERY_API_KEYS \
110+
-e HF_TOKEN \
111+
-e REFINERY_LOG_FORMAT=console \
112+
-v $(PWD)/.docker-inbox:/inbox \
113+
-v $(PWD)/.docker-outbox:/outbox \
114+
-v $(PWD)/.docker-summaries:/summaries \
115+
$(IMAGE_NAME):latest
116+
93117
stats: ## Show project statistics
94118
@echo "Project Statistics:"
95119
@echo "==================="

0 commit comments

Comments
 (0)