diff --git a/solstone/observe/transcribe/failure-and-telemetry.md b/solstone/observe/transcribe/failure-and-telemetry.md index 23e24689a..27b8bc21c 100644 --- a/solstone/observe/transcribe/failure-and-telemetry.md +++ b/solstone/observe/transcribe/failure-and-telemetry.md @@ -59,6 +59,7 @@ Backend-specific policy: | Parakeet server unreachable, warming, or **dead mid-request** | Defer (`69`) | The server is a supervised process. It comes back. | | Confidential lane refuses cloud egress | Defer (`69`) | The lane may permit a local backend later; the audio must not be lost. | | Confidential backend already dispatched, then lane inactive or audio disabled | Defer (`69`) | The selected backend can no longer carry audio at dispatch time. The input is preserved for a later local or confidential retry. | +| Confidential backend selected and decoded audio exceeds the hosted duration cap | Defer (`69`) | No fallback is attempted; the input stays on disk for a later retry after the provider limit is addressed. | | Stranded confidential channel before dispatch and local RAM below floor | Fail (`1`) | `resolve_default_backend` surfaces the local-STT requirement before `_process_one`; no JSONL is written, input audio stays on disk, and the segment remains `incomplete`. | | Confidential hosted STT 400/413 or bad 200 contract | Defer (`69`) | Hosted STT is operated infrastructure. A 400 can be an engine-side regression, and owner audio is irreplaceable; preserving it for a post-fix drain is safer than failing permanently. | | Confidential hosted STT unreachable, backpressured, or unexpected status | Defer (`69`) | These are service-side or lane-side conditions. The deferred event carries the reason so health surfaces can show the condition without leaking content. | @@ -101,6 +102,7 @@ Every deferred and failed event carries a machine-readable `reason`. | `confidential_egress_blocked` | `process_audio` | The confidential lane refused to send audio to a cloud backend. | | `confidential_lane_inactive` | confidential STT gate or backend | Confidential STT was selected, but the confidential lane was no longer active. | | `confidential_audio_disabled` | confidential STT gate | Confidential STT was selected under the lane, but `transcribe.confidential_audio` was off. | +| `confidential_audio_too_long` | `process_audio` | Confidential STT was selected, but the decoded input exceeded the hosted audio duration cap. | | `attestation_unreachable` | confidential probe status | Confidential attestation could not reach the gateway. Reused from the confidential lane health vocabulary. | | `attestation_failed` | confidential probe status | Confidential attestation completed but did not verify. Reused from the confidential lane health vocabulary. | | `attestation_not_yet_verified` | confidential probe status | Confidential provenance exists, but this process has no verified attestation session yet. Reused from the confidential lane health vocabulary. | diff --git a/solstone/observe/transcribe/main.py b/solstone/observe/transcribe/main.py index 7fb46e926..0b457765c 100644 --- a/solstone/observe/transcribe/main.py +++ b/solstone/observe/transcribe/main.py @@ -912,9 +912,10 @@ def process_audio( _process_one. A fresh one is created when called without it. Raises: - SystemExit: EXIT_PROVIDER_BLOCKED when the STT provider is not ready or the - confidential lane refuses egress -- an honest deferral that preserves the - input for the next run. 1 on hard failure. + SystemExit: EXIT_PROVIDER_BLOCKED when the STT provider is not ready, the + confidential lane refuses egress, or confidential audio exceeds the + hosted duration cap -- an honest deferral that preserves the input for + the next run. 1 on hard failure. """ start_time = time.time() resolved_backend = backend or DEFAULT_BACKEND @@ -954,6 +955,17 @@ def process_audio( stt_buffer = audio_buffer try: + if ( + resolved_backend == "confidential" + and audio_seconds > CONFIDENTIAL_STT_MAX_AUDIO_SECONDS + ): + logging.info( + "Confidential STT cap exceeded (duration=%.1fs cap=%.1fs); deferring transcription", + audio_seconds, + CONFIDENTIAL_STT_MAX_AUDIO_SECONDS, + ) + raise ConfidentialTranscribeDeferral("confidential_audio_too_long") + # Dispatch to STT backend with timings.time("asr"): statements = stt_transcribe( @@ -1483,21 +1495,6 @@ def _process_one( # CLI --backend flag overrides the invocation-level default backend = args.backend or default_backend - if backend == "confidential": - audio_seconds = len(audio_buffer) / SAMPLE_RATE - if audio_seconds > CONFIDENTIAL_STT_MAX_AUDIO_SECONDS: - logging.info( - "Confidential STT cap exceeded (duration=%.1fs cap=%.1fs); routing to local STT placement", - audio_seconds, - CONFIDENTIAL_STT_MAX_AUDIO_SECONDS, - ) - backend = local_stt_backend() or STT_SURFACE - if backend == STT_SURFACE: - _surface_stt_requirement( - read_available_bytes(), stt_local_floor_bytes() - ) - raise SystemExit(1) - # Get backend-specific config from nested structure if _uses_parakeet_cpp(backend): parakeet_cpp_config = transcribe_config.get("parakeet-cpp", {}) diff --git a/tests/test_transcribe_confidential.py b/tests/test_transcribe_confidential.py index 809e9a705..25012233d 100644 --- a/tests/test_transcribe_confidential.py +++ b/tests/test_transcribe_confidential.py @@ -535,52 +535,82 @@ def test_process_one_builds_confidential_backend_config(tmp_path: Path) -> None: assert captured == {"backend": "confidential", "backend_config": {}} -def test_process_one_routes_over_cap_confidential_audio_to_local( +def test_process_audio_confidential_over_cap_defers_before_backend( tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, ) -> None: - from solstone.observe.transcribe.main import _process_one + from solstone.observe.transcribe.main import process_audio from solstone.observe.transcribe.resource import CONFIDENTIAL_STT_MAX_AUDIO_SECONDS - audio_path = _raw_audio_path(tmp_path) - captured = {} + raw_path = _raw_audio_path(tmp_path) + before = sorted(path.name for path in raw_path.parent.iterdir()) audio = np.zeros( - int((CONFIDENTIAL_STT_MAX_AUDIO_SECONDS + 1) * SAMPLE_RATE), + int(CONFIDENTIAL_STT_MAX_AUDIO_SECONDS * SAMPLE_RATE) + 1, dtype=np.float32, ) - monkeypatch.delenv("GOOGLE_API_KEY", raising=False) - def fake_process_audio( - _audio_path, - _audio_buffer, - _vad_result_value, - backend_config, - **kwargs, + with ( + patch("solstone.observe.transcribe.main.stt_transcribe") as mock_stt, + patch("solstone.observe.transcribe.main.get_backend") as mock_get_backend, + patch("solstone.observe.transcribe.get_backend") as mock_dispatch_get_backend, + patch("solstone.observe.transcribe.main.callosum_send") as mock_send, ): - captured["backend_config"] = backend_config - captured["backend"] = kwargs["backend"] + with pytest.raises(SystemExit) as exc_info: + process_audio( + raw_path, + audio, + _vad_result(CONFIDENTIAL_STT_MAX_AUDIO_SECONDS + 1 / SAMPLE_RATE), + {}, + backend="confidential", + ) + + assert exc_info.value.code == EXIT_PROVIDER_BLOCKED + assert raw_path.exists() + assert not raw_path.with_suffix(".jsonl").exists() + assert not raw_path.with_suffix(".npz").exists() + assert sorted(path.name for path in raw_path.parent.iterdir()) == before + mock_stt.assert_not_called() + mock_get_backend.assert_not_called() + mock_dispatch_get_backend.assert_not_called() + mock_send.assert_called_once() + assert mock_send.call_args.args == ("observe", "transcribed") + + kwargs = mock_send.call_args.kwargs + assert kwargs["outcome"] == "deferred" + assert kwargs["reason"] == "confidential_audio_too_long" + assert kwargs["backend"] == "confidential" + +def test_process_audio_confidential_at_cap_dispatches_to_confidential_backend( + tmp_path: Path, +) -> None: + from solstone.observe.transcribe.main import process_audio + from solstone.observe.transcribe.resource import CONFIDENTIAL_STT_MAX_AUDIO_SECONDS + + raw_path = _raw_audio_path(tmp_path) + audio = np.zeros( + int(CONFIDENTIAL_STT_MAX_AUDIO_SECONDS * SAMPLE_RATE), + dtype=np.float32, + ) with ( - patch("solstone.observe.transcribe.main.load_audio", return_value=audio), - patch( - "solstone.observe.vad.run_vad", - return_value=_vad_result(CONFIDENTIAL_STT_MAX_AUDIO_SECONDS + 1), - ), - patch("solstone.observe.vad.reduce_audio", return_value=(None, None)), patch( - "solstone.observe.transcribe.main.local_stt_backend", - return_value="parakeet", - ), - patch( - "solstone.observe.transcribe.main.process_audio", - side_effect=fake_process_audio, - ), + "solstone.observe.transcribe.main.stt_transcribe", + side_effect=ConfidentialTranscribeDeferral("hosted_transcribe_unreachable"), + ) as mock_stt, + patch("solstone.observe.transcribe.main.callosum_send"), ): - _process_one( - audio_path, - argparse.Namespace(backend=None, cpu=False, model=None, redo=False), - {}, - "confidential", - ) + with pytest.raises(SystemExit) as exc_info: + process_audio( + raw_path, + audio, + _vad_result(CONFIDENTIAL_STT_MAX_AUDIO_SECONDS), + {}, + backend="confidential", + ) - assert captured == {"backend": "parakeet", "backend_config": {}} + assert exc_info.value.code == EXIT_PROVIDER_BLOCKED + mock_stt.assert_called_once() + backend, stt_audio, sample_rate, config = mock_stt.call_args.args + assert backend == "confidential" + assert stt_audio is audio + assert sample_rate == SAMPLE_RATE + assert config == {}