@@ -2507,17 +2475,6 @@ button:focus:not(:focus-visible) {
-
-
-
-
-
Use AI to extract topics, correct transcription errors, and add descriptions
-
-
-
-
-
-
-
-
-
-
automatically use Rev.ai for noisy audio (requires Rev.ai token)
-
@@ -3719,14 +3665,8 @@ function populateFields(config) {
const parakeetCpp = transcribe['parakeet-cpp'] || {};
setValue('field-parakeet-cpp-device', parakeetCpp.device || 'auto');
- // Rev.ai settings (nested)
- const revai = transcribe.revai || {};
- setValue('field-revai-model', revai.model || 'fusion');
-
- // Shared settings - enrich and noise_upgrade default to true when not set
- document.getElementById('field-transcribe-enrich').checked = transcribe.enrich !== false;
+ // Shared settings
document.getElementById('field-transcribe-preserve').checked = transcribe.preserve_all || false;
- document.getElementById('field-transcribe-noise-upgrade').checked = transcribe.noise_upgrade !== false;
// Support settings
const support = config.support || {};
@@ -3748,7 +3688,6 @@ function populateFields(config) {
const env = config.env || {};
const sysEnv = config.runtime_env || {};
keyValidationData = config.key_validation || {};
- updateEnvStatus('field-env-revai', env.REVAI_ACCESS_TOKEN, sysEnv.REVAI_ACCESS_TOKEN);
updateEnvStatus('field-env-plaud', env.PLAUD_ACCESS_TOKEN, sysEnv.PLAUD_ACCESS_TOKEN);
document.querySelectorAll('[data-section][data-key]').forEach((el) => {
@@ -3790,7 +3729,7 @@ function updateEnvStatus(fieldId, isJournalConfigured, isSystemConfigured) {
// Add validation status for transcription/import service tokens.
const providerName = fieldId.replace('field-env-', '');
- if (['revai', 'plaud'].includes(providerName)) {
+ if (['plaud'].includes(providerName)) {
const validation = keyValidationData[providerName];
const oldValidation = label.querySelector('.key-validation-status');
if (oldValidation) oldValidation.remove();
@@ -3831,7 +3770,6 @@ async function revalidateAllKeys() {
}
const env = configData?.env || {};
const sysEnv = configData?.runtime_env || {};
- updateEnvStatus('field-env-revai', env.REVAI_ACCESS_TOKEN, sysEnv.REVAI_ACCESS_TOKEN);
updateEnvStatus('field-env-plaud', env.PLAUD_ACCESS_TOKEN, sysEnv.PLAUD_ACCESS_TOKEN);
status.textContent = 'done';
status.className = 'key-status-valid';
@@ -5059,7 +4997,7 @@ function renderTranscribeResourceNotice(resource) {
const notice = document.getElementById('transcribeResourceNotice');
const text = document.getElementById('transcribeResourceNoticeText');
if (!notice || !text) return;
- const show = !!(resource?.auto_switched || resource?.needs_setup);
+ const show = !!resource?.needs_setup;
if (!show) {
text.textContent = '';
notice.style.display = 'none';
@@ -5067,7 +5005,6 @@ function renderTranscribeResourceNotice(resource) {
}
const lines = [];
if (resource.notice) lines.push(resource.notice);
- if (resource.force_local_hint) lines.push(resource.force_local_hint);
text.textContent = lines.join(' ');
notice.style.display = 'flex';
}
@@ -5125,8 +5062,6 @@ function switchTranscribeBackend(backend, config) {
const showParakeetCppSettings = backend === 'parakeet-cpp' || (backend === 'parakeet' && parakeetUsesCpp);
document.getElementById('parakeet-settings').style.display = (backend === 'parakeet' && !parakeetUsesCpp) ? 'block' : 'none';
document.getElementById('parakeet-cpp-settings').style.display = showParakeetCppSettings ? 'block' : 'none';
- document.getElementById('revai-settings').style.display = backend === 'revai' ? 'block' : 'none';
- document.getElementById('gemini-settings').style.display = backend === 'gemini' ? 'block' : 'none';
// Find the backend metadata
const backendInfo = transcribeBackends.find(b => b.name === backend);
@@ -5145,16 +5080,6 @@ function switchTranscribeBackend(backend, config) {
} else if (warning) {
warning.style.display = 'none';
}
-
- // Disable enrich toggle for gemini (it has integrated enrichment)
- const enrichCheckbox = document.getElementById('field-transcribe-enrich');
- const enrichField = enrichCheckbox?.closest('.settings-field');
- if (enrichCheckbox && enrichField) {
- const isGemini = backend === 'gemini';
- enrichCheckbox.disabled = isGemini;
- enrichField.style.opacity = isGemini ? '0.5' : '1';
- enrichField.title = isGemini ? 'Gemini includes integrated enrichment' : '';
- }
}
// Backend selector change handler
@@ -5174,10 +5099,8 @@ document.getElementById('field-transcribe-backend').addEventListener('change', a
if (transcribeResource) {
transcribeResource = {
...transcribeResource,
- auto_switched: false,
needs_setup: false,
notice: '',
- force_local_hint: '',
};
renderTranscribeResourceInfo(transcribeResource);
renderTranscribeResourceNotice(transcribeResource);
@@ -5204,29 +5127,6 @@ document.getElementById('plaudApiKeysLink')?.addEventListener('click', (e) => {
switchSection('apikeys');
});
-// Rev.ai settings change handlers
-document.getElementById('field-revai-model')?.addEventListener('change', async (e) => {
- const value = e.target.value;
-
- try {
- const response = await fetch('api/config', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ section: 'transcribe', data: { revai: { model: value } } })
- });
- const result = await response.json();
- if (result.success) {
- configData = result.config;
- showFieldStatus(e.target, 'saved');
- } else {
- throw new Error(result.error);
- }
- } catch (err) {
- console.error('Error saving revai setting:', err);
- showFieldStatus(e.target, 'error', err.message);
- }
-});
-
['field-parakeet-model-version', 'field-parakeet-device', 'field-parakeet-timeout'].forEach(id => {
document.getElementById(id)?.addEventListener('change', async (e) => {
const key = id === 'field-parakeet-model-version' ? 'model_version' :
diff --git a/solstone/apps/support/diagnostics.py b/solstone/apps/support/diagnostics.py
index e92fad6dd..d805efa09 100644
--- a/solstone/apps/support/diagnostics.py
+++ b/solstone/apps/support/diagnostics.py
@@ -30,7 +30,6 @@ _SECRET_KEYS = frozenset(
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GOOGLE_API_KEY",
- "REVAI_ACCESS_TOKEN",
"PLAUD_ACCESS_TOKEN",
"password",
"secret",
diff --git a/solstone/observe/enrich.md b/solstone/observe/enrich.md
deleted file mode 100644
index af812e6a9..000000000
--- a/solstone/observe/enrich.md
+++ /dev/null
@@ -1,57 +0,0 @@
----
-context: observe.enrich
-label: Audio Enrichment
-group: Observe
----
-You are correcting and enriching an audio transcript. You receive numbered statements with transcribed text and corresponding audio clips.
-
-For each statement:
-1. Listen to the audio and correct any transcription errors
-2. Note the speaker's verbal tone/emotion
-
-After processing all statements, identify topics discussed, the setting, and any audio quality issues.
-
-Common names that may appear: $entity_names
-
-## Output Format
-
-Return JSON only:
-
-```json
-{
- "statements": [
- {"corrected": "
", "emotion": ""},
- ...
- ],
- "topics": ", , ...",
- "setting": "",
- "warning": ""
-}
-```
-
-IMPORTANT: The statements array MUST match the number of input statements, in order.
-
-## Guidelines
-
-### Corrected Text
-- Fix clear transcription errors (misheard words, names, garbled phrases)
-- Only correct when you can clearly hear the difference
-- Preserve exact meaning - don't paraphrase or improve grammar
-- Return original unchanged if correct
-
-### Emotion
-- Brief tone description for accessibility
-- Focus on speaker's delivery not what the words mean, but how they say them, ignore background sounds
-- Use "neutral" when tone is unremarkable
-
-### Topics
-- Extract 2-5 main topics as comma-separated string
-- Use concise noun phrases: "project deadline, API design, weekend plans"
-- Order by prominence
-
-### Setting
-- Best guess at the setting the audio was captured in.
-
-### Warning
-- Note audio issues such as background noise, music, audio issues, etc.
-- Empty string if audio quality is good
diff --git a/solstone/observe/enrich.py b/solstone/observe/enrich.py
deleted file mode 100644
index b893641d9..000000000
--- a/solstone/observe/enrich.py
+++ /dev/null
@@ -1,159 +0,0 @@
-# SPDX-License-Identifier: AGPL-3.0-only
-# Copyright (c) 2026 sol pbc
-
-"""Enrich audio transcripts with contextual information using LLM analysis.
-
-Takes transcript statements paired with audio clips and extracts:
-- Per-statement corrected text (fixing transcription errors)
-- Per-statement emotion (tone, delivery)
-- Overall topics discussed
-- Setting classification (workplace, personal, etc.)
-- Audio quality warnings
-"""
-
-from __future__ import annotations
-
-import json
-import logging
-import time
-from pathlib import Path
-
-import numpy as np
-from google.genai import types
-
-from solstone.observe.utils import audio_to_flac_bytes
-from solstone.think.models import NoBrainConfiguredError, generate
-from solstone.think.prompts import load_prompt
-
-logger = logging.getLogger(__name__)
-
-_SCHEMA = json.loads(
- (Path(__file__).parent / "enrich.schema.json").read_text(encoding="utf-8")
-)
-
-
-def _statement_to_flac_bytes(
- wav: np.ndarray, start: float, end: float, sample_rate: int
-) -> bytes:
- """Extract a statement's audio and encode as FLAC bytes.
-
- Args:
- wav: Audio waveform (mono, float32)
- start: Start time in seconds
- end: End time in seconds
- sample_rate: Sample rate of the audio
-
- Returns:
- FLAC-encoded audio bytes
- """
- start_sample = int(start * sample_rate)
- end_sample = int(end * sample_rate)
- stmt_audio = wav[start_sample:end_sample]
-
- return audio_to_flac_bytes(stmt_audio, sample_rate)
-
-
-def enrich_transcript(
- audio: np.ndarray,
- sample_rate: int,
- statements: list[dict],
- entity_names: list[str] | None = None,
-) -> dict | None:
- """Enrich transcript statements with audio context using LLM analysis.
-
- Sends numbered statements with text and audio clips to extract corrected
- text, per-statement emotion, and overall topics/setting/warnings.
-
- Args:
- audio: Audio waveform (mono, float32)
- sample_rate: Sample rate in Hz
- statements: List of statement dicts with 'id', 'start', 'end', 'text'
- entity_names: Optional list of entity names for prompt context
-
- Returns:
- Dict with enrichment data or None on error:
- - statements: List of dicts with 'corrected' and 'emotion' keys
- - topics: Comma-separated topic string
- - setting: Setting classification string
- - warning: Audio quality issues (may be empty string)
- """
- if not statements:
- return None
-
- try:
- # Format entity names for prompt context
- entity_names_str = ", ".join(entity_names) if entity_names else None
-
- # Build interleaved content: numbered text label + audio clip for each statement
- prompt_content = load_prompt(
- "enrich",
- base_dir=Path(__file__).parent,
- context={"entity_names": entity_names_str},
- )
- contents: list = [prompt_content.text]
-
- for i, stmt in enumerate(statements, start=1):
- # Add numbered text label
- text = stmt.get("text", "")
- contents.append(f"Statement {i}: {text}")
-
- # Add audio clip
- audio_bytes = _statement_to_flac_bytes(
- audio, stmt["start"], stmt["end"], sample_rate
- )
- contents.append(
- types.Part.from_bytes(data=audio_bytes, mime_type="audio/flac")
- )
-
- # Call LLM (tier from enrich.md frontmatter)
- logger.info(f"Enriching {len(statements)} statements...")
- t0 = time.perf_counter()
-
- response_text = generate(
- contents=contents,
- context="observe.enrich",
- temperature=0.3,
- max_output_tokens=16384,
- thinking_budget=4096,
- json_output=True,
- json_schema=_SCHEMA,
- )
-
- result = json.loads(response_text)
- logger.info(f" Enrichment complete in {time.perf_counter() - t0:.2f}s")
-
- if not isinstance(result, dict):
- logger.warning(f"Enrichment returned unexpected type: {type(result)}")
- return None
-
- if "statements" not in result or "topics" not in result:
- logger.warning(f"Enrichment missing required fields: {result.keys()}")
- return None
-
- # Validate statements array
- enriched_statements = result["statements"]
- if not isinstance(enriched_statements, list):
- logger.warning("Enrichment 'statements' is not a list")
- return None
-
- if len(enriched_statements) != len(statements):
- logger.warning(
- f"Enrichment returned {len(enriched_statements)} statements "
- f"for {len(statements)} input statements"
- )
- # Still usable - we'll align what we can
-
- return result
-
- except NoBrainConfiguredError:
- logger.info("No thinking engine chosen; audio enrichment skipped")
- return None
- except Exception as e:
- logger.warning(f"Enrichment failed: {e}")
- from solstone.think.models import IncompleteJSONError
-
- if isinstance(e, IncompleteJSONError) and e.partial_text:
- text = e.partial_text
- logger.warning(f"Partial response ({len(text)} chars) HEAD: {text[:1000]}")
- logger.warning(f"Partial response TAIL: {text[-1000:]}")
- return None
diff --git a/solstone/observe/enrich.schema.json b/solstone/observe/enrich.schema.json
deleted file mode 100644
index ae6214f45..000000000
--- a/solstone/observe/enrich.schema.json
+++ /dev/null
@@ -1,40 +0,0 @@
-{
- "type": "object",
- "additionalProperties": false,
- "required": [
- "statements",
- "topics",
- "setting",
- "warning"
- ],
- "properties": {
- "statements": {
- "type": "array",
- "items": {
- "type": "object",
- "additionalProperties": false,
- "required": [
- "corrected",
- "emotion"
- ],
- "properties": {
- "corrected": {
- "type": "string"
- },
- "emotion": {
- "type": "string"
- }
- }
- }
- },
- "topics": {
- "type": "string"
- },
- "setting": {
- "type": "string"
- },
- "warning": {
- "type": "string"
- }
- }
-}
diff --git a/solstone/observe/transcribe/__init__.py b/solstone/observe/transcribe/__init__.py
index f4b07fac2..0604d7304 100644
--- a/solstone/observe/transcribe/__init__.py
+++ b/solstone/observe/transcribe/__init__.py
@@ -14,9 +14,8 @@ Terminology:
Available backends:
- parakeet: Default local backend via Apple Silicon helper or Linux parakeet.cpp
+- parakeet-cpp: Explicit Linux local backend via the supervised parakeet.cpp server
- confidential: Operated attested STT over the verified confidential forwarder
-- revai: Rev.ai cloud API (speaker diarization)
-- gemini: Google Gemini API (speaker diarization)
Backend Interface:
Each backend module must export a transcribe() function:
@@ -35,7 +34,7 @@ Backend Interface:
"end": float, # seconds
"text": str, # transcribed text
"words": list[dict] | None, # word-level data if available
- "speaker": int | None, # speaker ID (revai/gemini, 1-indexed)
+ "speaker": int | None, # speaker ID assigned by local diarization
}
Word format (when available):
@@ -62,8 +61,6 @@ if TYPE_CHECKING:
# ---------------------------------------------------------------------------
BACKEND_REGISTRY: dict[str, str] = {
- "revai": "solstone.observe.transcribe.revai",
- "gemini": "solstone.observe.transcribe.gemini",
"parakeet": "solstone.observe.transcribe.parakeet",
"parakeet-cpp": "solstone.observe.transcribe._parakeet_cpp",
"confidential": "solstone.observe.transcribe.confidential",
@@ -77,22 +74,6 @@ BACKEND_REGISTRY: dict[str, str] = {
# ---------------------------------------------------------------------------
BACKEND_METADATA: dict[str, dict] = {
- "revai": {
- "label": "Rev.ai - Cloud with speaker diarization",
- "description": "Cloud-based transcription with speaker identification",
- "env_key": "REVAI_ACCESS_TOKEN",
- "settings": ["model"],
- "local": False,
- "selectable": True,
- },
- "gemini": {
- "label": "Gemini - Cloud with speaker diarization",
- "description": "Cloud-based transcription with speaker identification",
- "env_key": "GOOGLE_API_KEY",
- "settings": [],
- "local": False,
- "selectable": True,
- },
"parakeet": {
"label": "Parakeet - Local processing (Apple Silicon CoreML or Linux parakeet.cpp)",
"description": "On-device speech recognition via Parakeet TDT; macOS uses a FluidAudio/CoreML helper, Linux uses the supervised parakeet.cpp server. Requires `make install`.",
@@ -206,7 +187,6 @@ def transcribe(
audio: "np.ndarray",
sample_rate: int,
config: dict,
- speech_segments: list[tuple[float, float]] | None = None,
) -> list[dict]:
"""Dispatch transcription to the specified backend.
@@ -215,8 +195,6 @@ def transcribe(
audio: Audio buffer (float32, mono)
sample_rate: Sample rate in Hz (typically 16000)
config: Backend-specific configuration dict
- speech_segments: Optional VAD speech segments for chunk-based transcription.
- Currently only used by the Gemini backend for timestamp anchoring.
Returns:
List of statement dicts with id, start, end, text, and optionally words
@@ -255,11 +233,6 @@ def transcribe(
raise ConfidentialTranscribeDeferral("confidential_lane_inactive")
backend_mod = get_backend(backend)
-
- # Pass speech_segments to backends that support it (currently only Gemini)
- if backend == "gemini" and speech_segments is not None:
- return backend_mod.transcribe(audio, sample_rate, config, speech_segments)
-
return backend_mod.transcribe(audio, sample_rate, config)
diff --git a/solstone/observe/transcribe/failure-and-telemetry.md b/solstone/observe/transcribe/failure-and-telemetry.md
index 681f4ee76..a4751c6dd 100644
--- a/solstone/observe/transcribe/failure-and-telemetry.md
+++ b/solstone/observe/transcribe/failure-and-telemetry.md
@@ -53,7 +53,6 @@ Backend-specific policy:
|---------|-----------------|
| `parakeet` / `parakeet-cpp` | Local STT. Supervised-server unavailability defers; live-server bad responses and contract failures fail loudly. |
| `confidential` | Hosted STT over the verified loopback forwarder. Lane, attestation, backpressure, transport, rejected-request, unexpected-status, and bad-200 contract failures defer with hosted reason codes. |
-| `gemini` / `revai` | Third-party cloud STT. Under the confidential lane, raw-audio egress is denied before provider dispatch. |
| Condition | Classified as | Why |
|-----------|--------------|-----|
@@ -132,7 +131,7 @@ One event name, five outcomes. Every attempt emits exactly one event.
| `output` | journal-relative path of the `.jsonl` | success |
| `reason` | machine reason (table above) | deferred, failed |
| `error` | exception **type name** — never the message (see below) | failed |
-| `backend` | STT backend name (`parakeet-cpp`, `gemini`, …) | whenever resolved |
+| `backend` | STT backend name (`parakeet`, `parakeet-cpp`, or `confidential`) | whenever resolved |
| `device` | resolved placement (`cpu` / `gpu`) when a placement record exists; configured device otherwise | whenever known (see below) |
| `model` | model filename | success, and failures after the backend reported it |
| `audio_seconds` | original decoded length, 1 dp | whenever decoded |
@@ -156,7 +155,6 @@ the jsonl and the npz) reports its total.
| `vad_ms` | `run_vad` |
| `reduce_ms` | `reduce_audio` (absent when reduction was skipped) |
| `asr_ms` | `stt_transcribe` — the STT call itself |
-| `enrich_ms` | `enrich_transcript` (absent when enrichment is disabled) |
| `embed_ms` | sentence-embedding generation |
| `overlap_ms` | overlap + log-prob computation |
| `diarize_ms` | local diarization (absent when skipped — the common case) |
@@ -174,10 +172,11 @@ nowhere in the serialized event payload — on the success path *and* the failed
**`error` is the exception's type name, never its message.** This is the load-bearing
detail, and it is structural rather than a matter of care: exception *messages* can
-embed model output. `SchemaValidationError` (`think/models.py`) builds its message with
-a ~197-character preview of the raw response, and `transcribe/gemini.py` interpolates
-that into a `RuntimeError` of its own. Putting `str(e)` on the bus would therefore
-publish transcript text whenever a Gemini response failed schema validation.
+embed provider output. `SchemaValidationError` (`think/models.py`) builds its message
+with a ~197-character preview of the raw response, and provider wrappers may
+interpolate that into their own exceptions. Putting `str(e)` on the bus would
+therefore publish transcript text whenever a provider response failed schema
+validation.
Carrying only `type(e).__name__` makes the guarantee hold by construction, so a new
provider exception cannot quietly reintroduce the leak. The full message and traceback
diff --git a/solstone/observe/transcribe/gemini.md b/solstone/observe/transcribe/gemini.md
deleted file mode 100644
index f49887abd..000000000
--- a/solstone/observe/transcribe/gemini.md
+++ /dev/null
@@ -1,41 +0,0 @@
----
-context: observe.transcribe.gemini
-label: Audio Transcription (Gemini)
-group: Observe
----
-You are transcribing audio clips from a continuous recording. Each clip is labeled with its start time and duration. Your task is to extract all statements from each clip, identify speakers, and produce a sequential transcript.
-
-## Input Format
-
-You will receive multiple audio clips, each preceded by a label like:
-`Clip starting at 01:23 (15s):`
-
-This means the clip begins at 1 minute 23 seconds into the original recording and is 15 seconds long.
-
-## Output Format
-
-Return a single JSON object with one key, "segments", containing an array of statement objects. Do not wrap the object in an array.
-
-{"segments": [{"start": "01:23", "speaker": "Speaker 1", "text": "First statement in clip"}, {"start": "01:28", "speaker": "Speaker 2", "text": "Response from another person"}]}
-
-Each statement object has exactly three fields:
-- "start": absolute MM:SS timestamp in the original recording
-- "speaker": consistent speaker label like "Speaker 1"
-- "text": verbatim transcription of what was said
-
-## Guidelines
-
-### Timestamps
-- Output absolute timestamps as MM:SS (time in the original recording)
-- Calculate by adding the offset within the clip to the clip's start time
-- Example: If clip starts at 01:23 and someone speaks 5 seconds in, output "01:28"
-
-### Statements
-- Extract EVERY statement from each clip - clips may contain multiple speakers and sentences
-- Create a new segment when the speaker changes or at natural sentence boundaries
-- Transcribe exactly what you hear with professional accuracy
-
-### Speaker Identification
-- Label speakers consistently across ALL clips: "Speaker 1", "Speaker 2", etc.
-- Use voice characteristics to track the same speaker across different clips
-- Assign speaker numbers in order of first appearance
diff --git a/solstone/observe/transcribe/gemini.py b/solstone/observe/transcribe/gemini.py
deleted file mode 100644
index a1682ff15..000000000
--- a/solstone/observe/transcribe/gemini.py
+++ /dev/null
@@ -1,480 +0,0 @@
-# SPDX-License-Identifier: AGPL-3.0-only
-# Copyright (c) 2026 sol pbc
-
-"""Gemini STT backend for speech-to-text transcription.
-
-This module provides cloud-based speech-to-text transcription using Google's
-Gemini API with speaker diarization (identifies who said what).
-
-When VAD speech segments are provided, audio is sent as labeled clips with
-explicit timestamps. Gemini returns absolute MM:SS timestamps which are then
-mapped back to the audio timeline. This anchors output to known clip boundaries
-rather than relying solely on Gemini's internal clock.
-
-Enrichment (topics, setting, emotion, corrections) is handled separately by
-the enrich step, same as other backends. This keeps the transcription focused
-and avoids hallucinations from entity name hints in the prompt.
-
-Environment:
-- GOOGLE_API_KEY: API key (required)
-"""
-
-from __future__ import annotations
-
-import json
-import logging
-import re
-import time
-from pathlib import Path
-
-import numpy as np
-from google.genai import types
-
-from solstone.observe.utils import audio_to_flac_bytes
-from solstone.think.models import (
- DEFAULT_PROVIDER_TIMEOUT_S,
- IncompleteJSONError,
- SchemaValidationError,
- generate,
-)
-from solstone.think.prompts import load_prompt
-
-logger = logging.getLogger(__name__)
-
-_SCHEMA = json.loads(
- (Path(__file__).parent / "gemini.schema.json").read_text(encoding="utf-8")
-)
-
-# Regex for parsing speaker strings like "Speaker 1", "Speaker 2"
-SPEAKER_PATTERN = re.compile(r"(?:speaker\s*)?(\d+)", re.IGNORECASE)
-
-
-def _format_timestamp(seconds: float) -> str:
- """Format seconds as MM:SS timestamp string.
-
- Args:
- seconds: Time in seconds
-
- Returns:
- Formatted string like "01:23" or "12:05"
- """
- minutes = int(seconds // 60)
- secs = int(seconds % 60)
- return f"{minutes:02d}:{secs:02d}"
-
-
-def _parse_timestamp(ts: str) -> float | None:
- """Parse MM:SS timestamp to seconds.
-
- Args:
- ts: Timestamp string like "01:23" or "1:23"
-
- Returns:
- Seconds as float, or None if unparseable
- """
- if not ts or not isinstance(ts, str):
- return None
-
- ts = ts.strip()
- if not ts:
- return None
-
- try:
- parts = ts.split(":")
- if len(parts) == 2:
- minutes = int(parts[0])
- seconds = float(parts[1])
- return max(0.0, minutes * 60 + seconds)
- elif len(parts) == 1:
- # Just seconds
- return max(0.0, float(parts[0]))
- except (ValueError, TypeError):
- pass
-
- return None
-
-
-def _parse_speaker(speaker: str | int | None) -> int | None:
- """Parse speaker identifier to 1-indexed integer.
-
- Handles:
- - "Speaker 1" -> 1
- - "speaker 2" -> 2
- - "1" -> 1
- - 1 -> 1
-
- Args:
- speaker: Speaker identifier (string or int)
-
- Returns:
- 1-indexed speaker ID, or None if unparseable
- """
- if speaker is None:
- return None
-
- if isinstance(speaker, int):
- return speaker if speaker > 0 else None
-
- if isinstance(speaker, str):
- # Try regex match for "Speaker N" pattern
- match = SPEAKER_PATTERN.search(speaker)
- if match:
- val = int(match.group(1))
- return val if val > 0 else None
-
- # Try direct int conversion
- try:
- val = int(speaker)
- return val if val > 0 else None
- except ValueError:
- pass
-
- return None
-
-
-def _extract_segments(result: dict) -> list:
- """Extract the segments list from Gemini's schema-constrained response.
-
- Raises RuntimeError if the result does not match the documented
- {"segments": [...]} wrapper shape.
- """
- if isinstance(result, dict) and isinstance(result.get("segments"), list):
- return result["segments"]
- logger.warning(
- "Gemini returned unexpected shape: type=%s keys=%s",
- type(result).__name__,
- list(result.keys()) if isinstance(result, dict) else None,
- )
- raise RuntimeError(f"Gemini returned unexpected shape: {type(result).__name__}")
-
-
-def _build_chunk_contents(
- audio: np.ndarray,
- sample_rate: int,
- speech_segments: list[tuple[float, float]],
- prompt_text: str,
-) -> list:
- """Build interleaved content list with labeled audio clips.
-
- Creates a list of [prompt, label1, audio1, label2, audio2, ...] where
- each label tells Gemini the clip's start time and duration.
-
- Args:
- audio: Full audio buffer (float32, mono)
- sample_rate: Sample rate in Hz
- speech_segments: List of (start, end) tuples from VAD
- prompt_text: The transcription prompt
-
- Returns:
- List of content parts for Gemini API
- """
- contents: list = [prompt_text]
-
- for start, end in speech_segments:
- # Extract audio chunk
- start_sample = int(start * sample_rate)
- end_sample = int(end * sample_rate)
- chunk_audio = audio[start_sample:end_sample]
-
- # Skip empty chunks
- if len(chunk_audio) == 0:
- continue
-
- # Add label with start time and duration
- timestamp = _format_timestamp(start)
- duration = int(end - start)
- contents.append(f"Clip starting at {timestamp} ({duration}s):")
-
- # Add audio bytes
- audio_bytes = audio_to_flac_bytes(chunk_audio, sample_rate)
- contents.append(types.Part.from_bytes(data=audio_bytes, mime_type="audio/flac"))
-
- return contents
-
-
-def _find_segment_for_timestamp(
- timestamp_seconds: float,
- speech_segments: list[tuple[float, float]],
-) -> tuple[float, float]:
- """Find the VAD segment that contains or is nearest to a timestamp.
-
- Args:
- timestamp_seconds: Absolute timestamp in seconds
- speech_segments: List of (start, end) tuples from VAD
-
- Returns:
- The (start, end) tuple of the matching or nearest segment
- """
- # Check if timestamp falls within any segment
- for start, end in speech_segments:
- if start <= timestamp_seconds <= end:
- return (start, end)
-
- # Find nearest segment
- min_distance = float("inf")
- nearest = speech_segments[0]
-
- for start, end in speech_segments:
- # Distance to segment (0 if inside, otherwise distance to nearest edge)
- if timestamp_seconds < start:
- distance = start - timestamp_seconds
- else:
- distance = timestamp_seconds - end
-
- if distance < min_distance:
- min_distance = distance
- nearest = (start, end)
-
- return nearest
-
-
-def _normalize_chunked_segments(
- segments: list[dict],
- speech_segments: list[tuple[float, float]],
-) -> list[dict]:
- """Convert Gemini segments with MM:SS timestamps to standard statement format.
-
- Parses absolute timestamps from Gemini output and maps them to VAD segments.
- Falls back to segment boundaries if timestamp parsing fails.
-
- Args:
- segments: Raw segments from Gemini response with "start" timestamps
- speech_segments: Original VAD segments with (start, end) times
-
- Returns:
- List of statements with proper timestamps
- """
- statements = []
- statement_id = 1
-
- # Calculate overall time range for clamping
- min_time = speech_segments[0][0] if speech_segments else 0.0
- max_time = speech_segments[-1][1] if speech_segments else 0.0
-
- for seg in segments:
- # Get and strip text - skip if empty
- text = seg.get("text", "").strip()
- if not text:
- continue
-
- # Parse timestamp from Gemini output
- raw_timestamp = seg.get("start", "")
- parsed_time = _parse_timestamp(raw_timestamp)
-
- if parsed_time is not None:
- # Clamp to valid range
- start = max(min_time, min(parsed_time, max_time))
- # Find the segment this timestamp belongs to for the end time
- seg_start, seg_end = _find_segment_for_timestamp(start, speech_segments)
- end = seg_end
- else:
- # Fallback: use first segment boundaries
- seg_start, seg_end = speech_segments[0] if speech_segments else (0.0, 0.0)
- start = seg_start
- end = seg_end
-
- # Build statement
- statement = {
- "id": statement_id,
- "start": start,
- "end": end,
- "text": text,
- "words": None, # Not available from Gemini
- }
- statement_id += 1
-
- # Parse speaker
- speaker = _parse_speaker(seg.get("speaker"))
- if speaker is not None:
- statement["speaker"] = speaker
-
- statements.append(statement)
-
- return statements
-
-
-def _transcribe_once(
- audio: np.ndarray,
- sample_rate: int,
- config: dict,
- speech_segments: list[tuple[float, float]] | None = None,
- *,
- timeout_s: float = DEFAULT_PROVIDER_TIMEOUT_S,
-) -> list[dict]:
- """Run one Gemini transcription request."""
-
- audio_duration = len(audio) / sample_rate
- use_chunks = speech_segments is not None and len(speech_segments) > 0
-
- if use_chunks:
- logger.info(
- f"Transcribing audio with Gemini ({audio_duration:.1f}s, "
- f"{len(speech_segments)} clips)..."
- )
- else:
- logger.info(f"Transcribing audio with Gemini ({audio_duration:.1f}s)...")
-
- t0 = time.perf_counter()
-
- # Load prompt from gemini.md
- prompt_text = load_prompt("gemini", base_dir=Path(__file__).parent).text
-
- # Build contents based on mode
- if use_chunks:
- contents = _build_chunk_contents(
- audio, sample_rate, speech_segments, prompt_text
- )
- else:
- # Legacy single-audio mode (for backwards compatibility)
- audio_bytes = audio_to_flac_bytes(audio, sample_rate)
- contents = [
- prompt_text,
- types.Part.from_bytes(data=audio_bytes, mime_type="audio/flac"),
- ]
-
- # Call Gemini via think.models.generate()
- # thinking_budget=0 disables thinking — transcription is extraction, not
- # reasoning, and Gemini's default thinking budget consumes output tokens.
- try:
- response_text = generate(
- contents=contents,
- context="observe.transcribe.gemini",
- temperature=0.3,
- max_output_tokens=16384,
- json_output=True,
- thinking_budget=0,
- json_schema=_SCHEMA,
- timeout_s=timeout_s,
- )
- except SchemaValidationError as e:
- logger.error("Gemini response failed schema validation: %s", e)
- logger.debug("Response text: %s", e.preview)
- raise RuntimeError(f"Gemini response failed schema validation: {e}") from e
-
- transcribe_time = time.perf_counter() - t0
- logger.debug(
- "Gemini raw response (%d chars):\n%s", len(response_text), response_text[:2000]
- )
-
- # Parse JSON response
- try:
- result = json.loads(response_text)
- except json.JSONDecodeError as e:
- logger.error(f"Gemini returned invalid JSON: {e}")
- logger.debug(f"Response text: {response_text[:500]}")
- raise RuntimeError(f"Gemini returned invalid JSON: {e}") from e
-
- segments = _extract_segments(result)
-
- # Normalize to standard statement format
- if use_chunks:
- statements = _normalize_chunked_segments(segments, speech_segments)
- else:
- # Legacy mode
- statements = _normalize_chunked_segments(
- segments,
- [(0.0, audio_duration)], # Single chunk covering entire audio
- )
-
- logger.info(
- f" Gemini returned {len(statements)} segments in {transcribe_time:.2f}s"
- )
-
- return statements
-
-
-def transcribe(
- audio: np.ndarray,
- sample_rate: int,
- config: dict,
- speech_segments: list[tuple[float, float]] | None = None,
- *,
- timeout_s: float = DEFAULT_PROVIDER_TIMEOUT_S,
-) -> list[dict]:
- """Transcribe audio using Gemini API.
-
- When speech_segments is provided (from VAD), sends audio as labeled clips
- with explicit timestamps. Gemini returns absolute MM:SS timestamps which
- are mapped back to the audio timeline.
-
- Args:
- audio: Audio buffer (float32, mono)
- sample_rate: Sample rate in Hz (typically 16000)
- config: Backend configuration dict (currently unused)
- speech_segments: Optional list of (start, end) tuples from VAD.
- When provided, enables clip-based transcription for better
- timestamp accuracy.
- timeout_s: Request timeout in seconds.
-
- Returns:
- List of statements with id, start, end, text, speaker.
- """
- try:
- return _transcribe_once(
- audio,
- sample_rate,
- config,
- speech_segments,
- timeout_s=timeout_s,
- )
- except IncompleteJSONError as original_error:
- if speech_segments is None or len(speech_segments) < 2:
- raise
-
- mid = len(speech_segments) // 2
- first_half = speech_segments[:mid]
- second_half = speech_segments[mid:]
- logger.info(
- "Gemini transcribe truncated at %d chunks; retrying as %d+%d split (one attempt)",
- len(speech_segments),
- len(first_half),
- len(second_half),
- )
-
- try:
- first_statements = _transcribe_once(
- audio,
- sample_rate,
- config,
- first_half,
- timeout_s=timeout_s,
- )
- second_statements = _transcribe_once(
- audio,
- sample_rate,
- config,
- second_half,
- timeout_s=timeout_s,
- )
- except Exception:
- logger.info("Gemini transcribe split-retry also truncated; raising")
- raise original_error
-
- statements = sorted(
- [*first_statements, *second_statements], key=lambda s: s["start"]
- )
- for i, statement in enumerate(statements):
- statement["id"] = i + 1
- logger.info(
- "Gemini transcribe split-retry succeeded; merged %d statements",
- len(statements),
- )
- return statements
-
-
-def get_model_info(config: dict) -> dict:
- """Get model configuration info for metadata.
-
- Args:
- config: Backend configuration dict
-
- Returns:
- Dict with model info for JSONL metadata
- """
- # Model is resolved by think.models based on context
- # We report "gemini" as the model family
- return {
- "model": "gemini",
- "device": "cloud",
- "compute_type": "api",
- }
diff --git a/solstone/observe/transcribe/gemini.schema.json b/solstone/observe/transcribe/gemini.schema.json
deleted file mode 100644
index e1b465f89..000000000
--- a/solstone/observe/transcribe/gemini.schema.json
+++ /dev/null
@@ -1,33 +0,0 @@
-{
- "type": "object",
- "additionalProperties": false,
- "required": [
- "segments"
- ],
- "properties": {
- "segments": {
- "type": "array",
- "items": {
- "type": "object",
- "additionalProperties": false,
- "required": [
- "start",
- "speaker",
- "text"
- ],
- "properties": {
- "start": {
- "type": "string",
- "pattern": "^\\d{2}:\\d{2}$"
- },
- "speaker": {
- "type": "string"
- },
- "text": {
- "type": "string"
- }
- }
- }
- }
- }
-}
diff --git a/solstone/observe/transcribe/main.py b/solstone/observe/transcribe/main.py
index 3e1144bf5..7e969c770 100644
--- a/solstone/observe/transcribe/main.py
+++ b/solstone/observe/transcribe/main.py
@@ -7,35 +7,23 @@ Transcription pipeline:
1. VAD stage: Run Silero VAD to detect speech and filter silent files early
2. Audio reduction: Trim long silence gaps for faster processing
3. Transcription: Dispatch to the configured or resource-aware STT backend
-4. Enrichment: Extract topics, setting, emotions, and warnings via LLM (optional)
-5. Embeddings: Generate voice embeddings for each sentence using wespeaker-resnet34
-6. Output: JSONL format compatible with format_audio() in observe/hear.py
+4. Embeddings: Generate voice embeddings for each sentence using wespeaker-resnet34
+5. Output: JSONL format compatible with format_audio() in observe/hear.py
Output files:
-- .jsonl: Transcript with HH:MM:SS timestamps, topics, setting, emotions
+- .jsonl: Transcript with HH:MM:SS timestamps and optional speaker labels
- .npz: Sentence-level voice embeddings indexed by statement id
Configuration (journal config transcribe section):
-- transcribe.backend: STT backend ("parakeet", "parakeet-cpp", "confidential", "revai", "gemini"). If unset, auto-selected by lane and resources.
-- transcribe.enrich: Enable/disable LLM enrichment (default: true)
+- transcribe.backend: STT backend ("parakeet", "parakeet-cpp", "confidential"). If unset, auto-selected by lane and resources.
- transcribe.preserve_all: Keep audio files even when no speech detected (default: false)
- transcribe.min_speech_seconds: Minimum speech duration to proceed. Default: 1.0
-- transcribe.noise_upgrade: Auto-switch to Rev.ai for noisy recordings (default: true)
-- transcribe.noise_upgrade_min_speech_ratio: Min speech/loud ratio required for noisy upgrade (default: 0.3). Filters out music and other non-speech noise.
Parakeet backend settings (transcribe.parakeet):
- model_version: Parakeet model version ("v3"). Default: "v3"
- cache_dir: Optional helper cache directory
- timeout_sec: Helper timeout in seconds. Default: 120.0
-Rev.ai backend settings (transcribe.revai):
-- model: Rev.ai transcriber ("fusion", "machine", "low_cost"). Default: "fusion"
-- Automatically loads recent entity names as custom vocabulary for improved recognition
-
-Gemini backend settings (transcribe.gemini):
-- No configuration needed (model resolved by think.models context system)
-- Includes speaker diarization
-
Platform optimizations:
- Apple Silicon hosts use the CoreML Parakeet helper.
- Linux hosts use a supervised parakeet.cpp server.
@@ -70,7 +58,7 @@ from solstone.apps.settings.install_copy import (
STT_EXPLICIT_LOCAL_LOW_TEMPLATE,
STT_LOCAL_REQUIREMENTS_TEMPLATE,
STT_LOCAL_UNSUPPORTED,
- STT_NO_KEY_RECOVERY,
+ STT_NO_LOCAL_STT_RECOVERY,
)
from solstone.apps.speakers.encoder_config import (
OVERLAP_DETECTOR_ID,
@@ -155,9 +143,6 @@ WESPEAKER_MODEL_SHA256 = (
)
PYANNOTE_OVERLAP_MODEL_SHA256 = OVERLAP_DETECTOR_SHA256
-# Number of recent entity names to load for transcription context
-ENTITY_NAMES_LIMIT = 40
-
# Module-level embedder cache
_embedder_session: ort.InferenceSession | None = None
@@ -172,17 +157,15 @@ def resolve_default_backend(args: argparse.Namespace, transcribe_config: dict) -
available_bytes = read_available_bytes()
floor_bytes = stt_local_floor_bytes()
local_backend = local_stt_backend()
- google_key_present = bool(os.getenv("GOOGLE_API_KEY"))
configured_backend = transcribe_config.get("backend")
explicit_backend = args.backend or configured_backend
if explicit_backend:
if explicit_backend not in BACKEND_REGISTRY:
logging.warning(
- "Configured STT backend %r is unavailable; using %s",
+ "Configured STT backend %r is unavailable; treating it as unset",
explicit_backend,
- DEFAULT_BACKEND,
)
- explicit_backend = DEFAULT_BACKEND
+ explicit_backend = None
from solstone.think.services import spp
confidential_lane_active = spp.confidential_provenance() is not None
@@ -190,7 +173,6 @@ def resolve_default_backend(args: argparse.Namespace, transcribe_config: dict) -
backend = resolve_stt_backend_choice(
explicit_backend,
available_bytes,
- google_key_present=google_key_present,
floor_bytes=floor_bytes,
local_backend=local_backend,
confidential_lane_active=confidential_lane_active,
@@ -243,7 +225,7 @@ def _surface_stt_requirement(
if available_gb is None
else STT_DETECTED_MEMORY_TEMPLATE.format(available_gb=available_gb)
)
- logging.error("%s %s %s", requirement, detected, STT_NO_KEY_RECOVERY)
+ logging.error("%s %s %s", requirement, detected, STT_NO_LOCAL_STT_RECOVERY)
def _select_onnx_providers() -> list[str]:
@@ -507,8 +489,8 @@ def _failure_label(exc: Exception) -> str:
"""The exception's type name -- the only part of it safe to put on the bus.
Exception *messages* are not safe: SchemaValidationError embeds a preview of the
- raw model output (think/models.py), and transcribe/gemini.py interpolates that
- into its own message, so a message could carry transcript text onto the event.
+ raw model output (think/models.py), and provider wrappers may interpolate
+ that into their own messages, so a message could carry transcript text onto the event.
The full message and traceback go to the handler log, which is where the health
UI already deep-links. Keeping only the type name makes the content-free
guarantee structural instead of a per-exception audit that any new provider
@@ -685,7 +667,6 @@ def _statements_to_jsonl(
model_info: dict,
source: str | None = None,
observer: str | None = None,
- enrichment: dict | None = None,
vad_result: VadResult | None = None,
segment_meta: dict | None = None,
backend: str | None = None,
@@ -704,12 +685,10 @@ def _statements_to_jsonl(
model_info: Dict with model, device, compute_type from backend
source: Optional source label (e.g., "mic", "sys")
observer: Optional observer name for metadata
- enrichment: Optional enrichment data with topics, setting, warning, and
- per-statement corrected text and emotions
vad_result: Optional VAD result for noise detection metadata
segment_meta: Optional metadata dict from SEGMENT_META env var
- (facet, setting, host, platform, etc.). Setting overrides enrichment.
- backend: Optional STT backend name (e.g., "parakeet", "revai")
+ (facet, setting, host, platform, etc.).
+ backend: Optional STT backend name (e.g., "parakeet")
overlap_fraction: Optional fraction of speech containing overlapping speakers
overlap_detector: Optional overlap detector identifier
processing_record: Optional _solstone_processing record
@@ -746,17 +725,7 @@ def _statements_to_jsonl(
metadata["overlap_fraction"] = round(float(overlap_fraction), 4)
metadata["overlap_detector"] = overlap_detector
- # Add enrichment metadata if available
- if enrichment:
- if "topics" in enrichment:
- metadata["topics"] = enrichment["topics"]
- if "setting" in enrichment:
- metadata["setting"] = enrichment["setting"]
- if "warning" in enrichment and enrichment["warning"]:
- metadata["warning"] = enrichment["warning"]
-
# Add segment metadata (from SEGMENT_META env var)
- # These fields override any enrichment values (e.g., setting)
if segment_meta:
for key, value in segment_meta.items():
metadata[key] = value
@@ -768,13 +737,8 @@ def _statements_to_jsonl(
lines = [json.dumps(metadata)]
- # Get enriched statements list (positional matching)
- enriched_statements = []
- if enrichment and "statements" in enrichment:
- enriched_statements = enrichment["statements"]
-
# Build entry lines
- for i, stmt in enumerate(statements):
+ for stmt in statements:
# Calculate absolute timestamp (handle None for invalid timestamps)
start_seconds = stmt["start"] if stmt["start"] is not None else 0.0
stmt_dt = base_datetime + datetime.timedelta(seconds=start_seconds)
@@ -787,23 +751,10 @@ def _statements_to_jsonl(
if source:
entry["source"] = source
- # Pass through speaker ID if present (from diarized backends like Rev.ai, Gemini)
+ # Pass through speaker ID if present from local diarization.
if "speaker" in stmt:
entry["speaker"] = stmt["speaker"]
- # Add corrected text and emotion from enrichment by position
- if i < len(enriched_statements):
- enriched = enriched_statements[i]
- if isinstance(enriched, dict):
- # Add corrected text only if different from original
- corrected = enriched.get("corrected", "")
- if corrected and corrected != stmt["text"]:
- entry["corrected"] = corrected
- # Add emotion (overrides emotion from statement)
- emotion = enriched.get("emotion", "")
- if emotion:
- entry["emotion"] = emotion
-
lines.append(json.dumps(entry))
return lines
@@ -873,7 +824,6 @@ def process_audio(
reduction: AudioReduction | None = None,
reduced_audio: np.ndarray | None = None,
backend: str | None = None,
- entity_names: list[str] | None = None,
*,
sound_tags: dict | None = None,
timings: _StageTimings | None = None,
@@ -882,7 +832,6 @@ def process_audio(
This is the main orchestration function that coordinates:
- STT backend dispatch
- - Enrichment (optional)
- Embedding generation
- Output file writing
- Event emission
@@ -896,7 +845,6 @@ def process_audio(
reduction: Optional AudioReduction mapping for timestamp restoration
reduced_audio: Optional reduced audio buffer (used if reduction provided)
backend: STT backend name. If omitted, uses DEFAULT_BACKEND.
- entity_names: Optional list of entity names for STT and enrichment context
sound_tags: Optional ambient sound-tag metadata computed from full audio
timings: Stage-timing accumulator carrying the pre-STT stages measured by
_process_one. A fresh one is created when called without it.
@@ -938,15 +886,7 @@ def process_audio(
except json.JSONDecodeError:
logging.warning(f"Invalid SEGMENT_META JSON: {segment_meta_str[:100]}")
- # Gemini uses chunk-based transcription with VAD segments for timestamp accuracy
- # Other backends use reduced audio with post-hoc timestamp restoration
- use_gemini_chunks = backend == "gemini" and vad_result.speech_segments
-
- if use_gemini_chunks:
- # Gemini: use full audio buffer with VAD segments for chunking
- stt_buffer = audio_buffer
- elif reduced_audio is not None:
- # Other backends: use reduced audio
+ if reduced_audio is not None:
stt_buffer = reduced_audio
else:
stt_buffer = audio_buffer
@@ -954,19 +894,9 @@ def process_audio(
try:
# Dispatch to STT backend
with timings.time("asr"):
- if use_gemini_chunks:
- # Pass VAD segments to Gemini for chunk-based transcription
- statements = stt_transcribe(
- resolved_backend,
- stt_buffer,
- SAMPLE_RATE,
- backend_config,
- speech_segments=vad_result.speech_segments,
- )
- else:
- statements = stt_transcribe(
- resolved_backend, stt_buffer, SAMPLE_RATE, backend_config
- )
+ statements = stt_transcribe(
+ resolved_backend, stt_buffer, SAMPLE_RATE, backend_config
+ )
# Get model info for metadata (dynamic import based on backend)
backend_module = get_backend(resolved_backend)
@@ -1044,17 +974,6 @@ def process_audio(
if suffix.endswith("_audio") and suffix != "audio":
source = suffix[:-6] # Remove "_audio" suffix
- # Run enrichment if enabled (extracts topics, setting, emotions, corrections)
- enrichment = None
- enrich_enabled = config.get("transcribe", {}).get("enrich", True)
- if enrich_enabled:
- from solstone.observe.enrich import enrich_transcript
-
- with timings.time("enrich"):
- enrichment = enrich_transcript(
- stt_buffer, SAMPLE_RATE, statements, entity_names=entity_names
- )
-
# Generate embeddings before timestamp restoration
# Use reduced audio buffer if available for consistent timestamps
with timings.time("embed"):
@@ -1066,9 +985,8 @@ def process_audio(
audio_buffer
)
- # Restore original timestamps if audio was reduced (non-Gemini backends only)
- # Gemini with chunks already has timestamps in original audio time
- if reduction and not use_gemini_chunks:
+ # Restore original timestamps if audio was reduced.
+ if reduction:
from solstone.observe.vad import restore_statement_timestamps
statements = restore_statement_timestamps(statements, reduction)
@@ -1082,39 +1000,38 @@ def process_audio(
# speech and diarization adds no value. Otherwise reuse the pyannote
# log-probs computed above so the diarizer skips its own pyannote pass.
_DIARIZE_MIN_OVERLAP = 0.05
- if resolved_backend in {"parakeet", "confidential"}:
- if overlap_fraction_value < _DIARIZE_MIN_OVERLAP:
+ if overlap_fraction_value < _DIARIZE_MIN_OVERLAP:
+ logging.info(
+ " Skipping diarization: overlap=%.2f (threshold %.2f)",
+ overlap_fraction_value,
+ _DIARIZE_MIN_OVERLAP,
+ )
+ else:
+ try:
+ from solstone.observe.transcribe.diarize import diarize_auto_k
+
+ with timings.time("diarize"):
+ labels = diarize_auto_k(
+ raw_path,
+ statements,
+ avg_log_probs=pyannote_logprobs,
+ audio=audio_buffer,
+ )
+ assigned = 0
+ for stmt, lbl in zip(statements, labels):
+ if lbl is not None:
+ stmt["speaker"] = lbl
+ assigned += 1
logging.info(
- " Skipping diarization: overlap=%.2f (threshold %.2f)",
+ " Local diarization: %d/%d sentences labeled (overlap=%.2f)",
+ assigned,
+ len(statements),
overlap_fraction_value,
- _DIARIZE_MIN_OVERLAP,
)
- else:
- try:
- from solstone.observe.transcribe.diarize import diarize_auto_k
-
- with timings.time("diarize"):
- labels = diarize_auto_k(
- raw_path,
- statements,
- avg_log_probs=pyannote_logprobs,
- audio=audio_buffer,
- )
- assigned = 0
- for stmt, lbl in zip(statements, labels):
- if lbl is not None:
- stmt["speaker"] = lbl
- assigned += 1
- logging.info(
- " Local diarization: %d/%d sentences labeled (overlap=%.2f)",
- assigned,
- len(statements),
- overlap_fraction_value,
- )
- except Exception:
- logging.exception(
- "Local diarization failed; speaker labels will be absent"
- )
+ except Exception:
+ logging.exception(
+ "Local diarization failed; speaker labels will be absent"
+ )
# Convert to JSONL format (now with original timestamps)
raw_filename = f"{raw_path.stem}{raw_path.suffix}"
@@ -1131,7 +1048,6 @@ def process_audio(
model_info,
source,
observer,
- enrichment,
vad_result,
segment_meta,
resolved_backend,
@@ -1294,7 +1210,6 @@ def _process_one(
args: argparse.Namespace,
transcribe_config: dict,
default_backend: str,
- entity_names: list[str],
) -> None:
"""Run the full transcription pipeline for a single audio file."""
min_speech_seconds = transcribe_config.get(
@@ -1428,44 +1343,6 @@ def _process_one(
# CLI --backend flag overrides the invocation-level default
backend = args.backend or default_backend
- # Check for noise upgrade: auto-switch to Rev.ai for noisy recordings
- # Only applies when:
- # - No explicit CLI --backend flag (respect user's explicit choice)
- # - Not already using Rev.ai
- # - noise_upgrade is enabled (default: true)
- # - Audio is noisy
- # - Rev.ai token is available
- noise_upgrade = transcribe_config.get("noise_upgrade", True)
- min_ratio = transcribe_config.get("noise_upgrade_min_speech_ratio", 0.3)
- from solstone.think.services import spp
-
- confidential_lane_active = spp.confidential_provenance() is not None
- if (
- not args.backend
- and noise_upgrade
- and not confidential_lane_active
- and backend != "revai"
- and vad_result.is_noisy()
- ):
- from solstone.observe.transcribe.revai import has_token
-
- ratio = vad_result.loud_speech_ratio
- if ratio is not None and ratio < min_ratio:
- logging.info(
- "Noisy audio (RMS=%.4f) looks like non-speech (loud_speech_ratio=%.2f < %.2f), "
- "skipping Rev.ai upgrade",
- vad_result.noisy_rms,
- ratio,
- min_ratio,
- )
- elif has_token():
- logging.info(
- "Noisy audio detected (RMS=%.4f, loud_speech_ratio=%s), upgrading to Rev.ai backend",
- vad_result.noisy_rms,
- f"{ratio:.2f}" if ratio is not None else "n/a",
- )
- backend = "revai"
-
if backend == "confidential":
audio_seconds = len(audio_buffer) / SAMPLE_RATE
if audio_seconds > CONFIDENTIAL_STT_MAX_AUDIO_SECONDS:
@@ -1482,20 +1359,7 @@ def _process_one(
raise SystemExit(1)
# Get backend-specific config from nested structure
- if backend == "revai":
- from solstone.observe.transcribe.revai import (
- DEFAULT_MODEL as REVAI_DEFAULT_MODEL,
- )
-
- revai_config = transcribe_config.get("revai", {})
- model = revai_config.get("model", REVAI_DEFAULT_MODEL)
- backend_config = {
- "model": model,
- }
- # Pass entities to Rev.ai for custom vocabulary
- if entity_names:
- backend_config["entities"] = entity_names
- elif _uses_parakeet_cpp(backend):
+ if _uses_parakeet_cpp(backend):
parakeet_cpp_config = transcribe_config.get("parakeet-cpp", {})
backend_config = {k: v for k, v in parakeet_cpp_config.items() if k == "device"}
elif backend == "parakeet":
@@ -1512,10 +1376,6 @@ def _process_one(
"quantization",
)
}
- elif backend == "gemini":
- # Gemini backend - model resolved by think.models based on context
- # Entity names handled by enrich step, not passed to transcription
- backend_config = {}
elif backend == "confidential":
backend_config = {}
else:
@@ -1532,7 +1392,6 @@ def _process_one(
reduction=reduction,
reduced_audio=reduced_audio,
backend=backend,
- entity_names=entity_names,
sound_tags=sound_tags,
timings=timings,
)
@@ -1577,12 +1436,6 @@ def main():
transcribe_config = config.get("transcribe", {})
default_backend = resolve_default_backend(args, transcribe_config)
- from solstone.think.entities import load_recent_entity_names
-
- entity_names = load_recent_entity_names(limit=ENTITY_NAMES_LIMIT)
- if entity_names:
- logging.info(f"Loaded {len(entity_names)} entities for transcription context")
-
if args.all:
processed = 0
skipped = 0
@@ -1606,7 +1459,6 @@ def main():
args,
transcribe_config,
default_backend,
- entity_names,
)
processed += 1
except SystemExit as exit_signal:
@@ -1660,7 +1512,7 @@ def main():
f"but parent is: {audio_path.parent.name}"
)
- _process_one(audio_path, args, transcribe_config, default_backend, entity_names)
+ _process_one(audio_path, args, transcribe_config, default_backend)
if __name__ == "__main__":
diff --git a/solstone/observe/transcribe/parakeet_hints.py b/solstone/observe/transcribe/parakeet_hints.py
index 5f082ed69..1f1be7b2e 100644
--- a/solstone/observe/transcribe/parakeet_hints.py
+++ b/solstone/observe/transcribe/parakeet_hints.py
@@ -8,8 +8,8 @@ Apple Silicon Macs running macOS 14 or newer. Your install does not
include it — likely because you're on an Intel Mac, on macOS 13 or
older, or pip selected the cross-platform fallback wheel.
-Whisper, the Gemini cloud backend, and the macOS observer app continue
-to work without the helper.
+The supervised Linux parakeet.cpp path and the macOS observer app continue
+to work without this CoreML helper.
If you want CoreML-accelerated parakeet transcription, install solstone
from a source checkout: see https://github.com/solpbc/solstone-journal/blob/main/CONTRIBUTING.md."""
diff --git a/solstone/observe/transcribe/resource.py b/solstone/observe/transcribe/resource.py
index 40e7e8026..ec6a28e31 100644
--- a/solstone/observe/transcribe/resource.py
+++ b/solstone/observe/transcribe/resource.py
@@ -39,7 +39,6 @@ def resolve_stt_backend_choice(
explicit_backend: str | None,
available_bytes: int | None,
*,
- google_key_present: bool,
floor_bytes: int | None,
local_backend: str | None,
confidential_lane_active: bool,
@@ -52,8 +51,6 @@ def resolve_stt_backend_choice(
if confidential_lane_active and confidential_audio_enabled:
return "confidential"
return local_backend if local_backend is not None else STT_SURFACE
- if explicit_backend in {"gemini", "revai"}:
- return explicit_backend
if confidential_lane_active and confidential_audio_enabled:
return "confidential"
@@ -68,8 +65,6 @@ def resolve_stt_backend_choice(
)
if local_fits:
return local_backend
- if google_key_present:
- return "gemini"
return STT_SURFACE
diff --git a/solstone/observe/transcribe/revai.py b/solstone/observe/transcribe/revai.py
deleted file mode 100644
index fbdc94a5f..000000000
--- a/solstone/observe/transcribe/revai.py
+++ /dev/null
@@ -1,417 +0,0 @@
-# SPDX-License-Identifier: AGPL-3.0-only
-# Copyright (c) 2026 sol pbc
-
-"""Rev.ai STT backend with speaker diarization.
-
-This module provides cloud-based speech-to-text transcription using Rev.ai,
-a professional transcription API with high-quality speaker diarization.
-
-Unlike the local Whisper backend, Rev.ai:
-- Provides speaker diarization (identifies who said what)
-- Returns per-speaker statements (not per-sentence)
-- Requires API credentials and network access
-- Processes asynchronously (submit job, poll, fetch)
-
-Configuration keys (passed in config dict):
-- model: Rev transcriber ("fusion", "machine", "low_cost"). Default: "fusion"
-- entities: Custom vocabulary terms for improved recognition (optional list)
-
-Environment:
-- REVAI_ACCESS_TOKEN or REV_ACCESS_TOKEN: API access token (required)
-"""
-
-from __future__ import annotations
-
-import json
-import logging
-import mimetypes
-import os
-import tempfile
-import time
-from pathlib import Path
-
-import numpy as np
-import requests
-import soundfile as sf
-
-API_BASE = "https://api.rev.ai/speechtotext/v1"
-
-# Default configuration
-DEFAULT_MODEL = "fusion"
-DEFAULT_LANGUAGE = "en"
-DEFAULT_DIARIZATION = "premium"
-DEFAULT_POLL_INTERVAL = 2.5
-DEFAULT_TIMEOUT = 1800 # 30 minutes
-
-# Valid Rev.ai transcriber models
-VALID_MODELS = frozenset({"fusion", "machine", "low_cost"})
-
-
-def _get_token() -> str:
- """Get Rev.ai API token from environment.
-
- Returns:
- API access token
-
- Raises:
- ValueError: If token not found in environment
- """
- token = os.getenv("REVAI_ACCESS_TOKEN") or os.getenv("REV_ACCESS_TOKEN")
- if not token:
- raise ValueError("Missing REVAI_ACCESS_TOKEN in environment")
- return token
-
-
-def has_token() -> bool:
- """Check if Rev.ai API token is available.
-
- Returns:
- True if token is configured, False otherwise
- """
- try:
- _get_token()
- return True
- except ValueError:
- return False
-
-
-def validate_token(token: str) -> dict:
- """Validate a Rev.ai access token by hitting the account endpoint.
-
- Returns {"valid": True} or {"valid": False, "error": "..."}.
- Never raises.
- """
- try:
- resp = requests.get(
- f"{API_BASE}/account",
- headers={"Authorization": f"Bearer {token}"},
- timeout=10,
- )
- if resp.status_code == 200:
- return {"valid": True}
- return {"valid": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
- except Exception as e:
- return {"valid": False, "error": str(e)}
-
-
-def submit_job(
- token: str,
- media_path: Path,
- config: dict,
-) -> str:
- """Submit transcription job to Rev.ai.
-
- Args:
- token: API access token
- media_path: Path to audio/video file
- config: Backend configuration dict
-
- Returns:
- Job ID string
-
- Raises:
- RuntimeError: If job submission fails
- """
- url = f"{API_BASE}/jobs"
- headers = {"Authorization": f"Bearer {token}"}
-
- # Build options from config
- # Note: Settings UI only exposes 'model', but CLI supports all options
- # Validate model - use default if the configured value is not a Rev.ai model.
- model = config.get("model", DEFAULT_MODEL)
- if model not in VALID_MODELS:
- model = DEFAULT_MODEL
-
- options = {
- "transcriber": model,
- "skip_diarization": False,
- "diarization_type": config.get("diarization_type", DEFAULT_DIARIZATION),
- "language": config.get("language", DEFAULT_LANGUAGE),
- "forced_alignment": config.get("forced_alignment", False),
- "remove_disfluencies": config.get("remove_disfluencies", False),
- "filter_profanity": config.get("filter_profanity", False),
- "skip_punctuation": config.get("skip_punctuation", False),
- }
-
- if config.get("speakers_count") is not None:
- options["speakers_count"] = config["speakers_count"]
- if config.get("speaker_channels_count") is not None:
- options["speaker_channels_count"] = config["speaker_channels_count"]
- if config.get("entities"):
- # Rev.ai rejects phrases containing digits (e.g. "R2", "GPT4")
- phrases = [e for e in config["entities"] if not any(c.isdigit() for c in e)]
- if phrases:
- options["custom_vocabularies"] = [{"phrases": phrases}]
- options["strict_custom_vocabulary"] = False
-
- data = {"options": json.dumps(options)}
-
- logging.info("Submitting job to Rev.ai: %s", json.dumps(options))
-
- # Use context manager to ensure file handle is closed
- with open(media_path, "rb") as media_file:
- files = {
- "media": (
- media_path.name,
- media_file,
- mimetypes.guess_type(media_path.name)[0] or "application/octet-stream",
- )
- }
- resp = requests.post(url, headers=headers, files=files, data=data, timeout=60)
-
- if resp.status_code >= 300:
- raise RuntimeError(
- f"Rev.ai job submission failed ({resp.status_code}): {resp.text}"
- )
-
- return resp.json()["id"]
-
-
-def get_job(token: str, job_id: str) -> dict:
- """Get job status from Rev.ai.
-
- Args:
- token: API access token
- job_id: Job ID string
-
- Returns:
- Job status dict
-
- Raises:
- RuntimeError: If request fails
- """
- url = f"{API_BASE}/jobs/{job_id}"
- headers = {"Authorization": f"Bearer {token}"}
- resp = requests.get(url, headers=headers, timeout=30)
- if resp.status_code >= 300:
- raise RuntimeError(f"Rev.ai get job failed ({resp.status_code}): {resp.text}")
- return resp.json()
-
-
-def get_transcript_json(token: str, job_id: str) -> dict:
- """Get transcript JSON from completed job.
-
- Args:
- token: API access token
- job_id: Job ID string
-
- Returns:
- Transcript dict with monologues
-
- Raises:
- RuntimeError: If request fails
- """
- url = f"{API_BASE}/jobs/{job_id}/transcript"
- headers = {
- "Authorization": f"Bearer {token}",
- "Accept": "application/vnd.rev.transcript.v1.0+json",
- }
- resp = requests.get(url, headers=headers, timeout=60)
- if resp.status_code >= 300:
- raise RuntimeError(
- f"Rev.ai get transcript failed ({resp.status_code}): {resp.text}"
- )
- return resp.json()
-
-
-def transcribe_file(media_path: Path, config: dict | None = None) -> dict:
- """Transcribe a file using Rev.ai API and return raw JSON.
-
- This is the low-level function that handles the full API flow:
- submit job → poll for completion → fetch transcript.
-
- Args:
- media_path: Path to audio/video file
- config: Optional configuration dict
-
- Returns:
- Raw Rev.ai transcript JSON (monologues format)
-
- Raises:
- ValueError: If API token not found
- RuntimeError: If job fails
- TimeoutError: If job times out
- """
- config = config or {}
- token = _get_token()
-
- poll_interval = config.get("poll_interval", DEFAULT_POLL_INTERVAL)
- timeout = config.get("timeout", DEFAULT_TIMEOUT)
-
- # Submit job
- job_id = submit_job(token, media_path, config)
- logging.info("Rev.ai job submitted: %s", job_id)
-
- # Poll for completion
- start = time.time()
- status = None
- while True:
- job = get_job(token, job_id)
- new_status = job.get("status")
- if new_status != status:
- logging.info("Rev.ai status: %s", new_status)
- status = new_status
-
- if new_status in ("transcribed", "completed"):
- break
- if new_status in ("failed", "error"):
- raise RuntimeError(f"Rev.ai job failed: {json.dumps(job, indent=2)}")
-
- if time.time() - start > timeout:
- raise TimeoutError("Rev.ai transcription timed out")
-
- time.sleep(poll_interval)
-
- # Fetch and return transcript
- return get_transcript_json(token, job_id)
-
-
-def convert_to_statements(revai_json: dict) -> list[dict]:
- """Convert Rev.ai transcript to standard statement format.
-
- This produces per-speaker statements (one statement per monologue),
- preserving speaker attribution and word-level data.
-
- Args:
- revai_json: Raw Rev.ai transcript dict with monologues
-
- Returns:
- List of statement dicts with id, start, end, text, speaker, words
- """
- statements = []
-
- if "monologues" not in revai_json:
- return statements
-
- for monologue in revai_json["monologues"]:
- # Rev uses 0-based speakers, we use 1-based
- speaker = monologue.get("speaker", 0) + 1
- elements = monologue.get("elements", [])
-
- if not elements:
- continue
-
- # Build text and collect word data
- text_parts = []
- words = []
- start_ts = None
- end_ts = None
- confidences = []
-
- for elem in elements:
- if elem["type"] == "text":
- value = elem.get("value", "")
- text_parts.append(value)
-
- # Track timestamps
- ts = elem.get("ts")
- if ts is not None:
- if start_ts is None:
- start_ts = ts
- end_ts = elem.get("end_ts", ts)
-
- # Build word entry
- word_entry = {"word": value}
- if ts is not None:
- word_entry["start"] = ts
- if elem.get("end_ts") is not None:
- word_entry["end"] = elem["end_ts"]
- if elem.get("confidence") is not None:
- word_entry["probability"] = elem["confidence"]
- confidences.append(elem["confidence"])
- words.append(word_entry)
-
- elif elem["type"] == "punct":
- # Append punctuation to text
- text_parts.append(elem.get("value", ""))
-
- text = "".join(text_parts).strip()
- if not text:
- continue
-
- # Build statement
- statement = {
- "id": len(statements) + 1,
- "start": start_ts if start_ts is not None else 0.0,
- "end": end_ts if end_ts is not None else 0.0,
- "text": text,
- "speaker": speaker,
- "words": words if words else None,
- }
-
- # Add average confidence if available
- if confidences:
- statement["confidence"] = sum(confidences) / len(confidences)
-
- statements.append(statement)
-
- return statements
-
-
-def transcribe(
- audio: np.ndarray,
- sample_rate: int,
- config: dict,
-) -> list[dict]:
- """Transcribe audio using Rev.ai API.
-
- This is the standard backend interface. It writes the audio to a temp file,
- submits to Rev.ai, polls for completion, and returns normalized statements.
-
- Args:
- audio: Audio buffer (float32, mono)
- sample_rate: Sample rate in Hz (typically 16000)
- config: Backend configuration dict
-
- Returns:
- List of per-speaker statements with id, start, end, text, speaker, words
- """
- temp_path = None
- try:
- # Write audio to temp file for upload
- with tempfile.NamedTemporaryFile(suffix=".flac", delete=False) as f:
- temp_path = Path(f.name)
-
- # Convert to int16 for FLAC encoding
- audio_int16 = (np.clip(audio, -1.0, 1.0) * 32767).astype(np.int16)
- sf.write(temp_path, audio_int16, sample_rate, format="FLAC")
-
- logging.info(
- "Transcribing audio with Rev.ai (%.1fs)...", len(audio) / sample_rate
- )
-
- # Get raw transcript
- revai_json = transcribe_file(temp_path, config)
-
- # Convert to standard statement format
- statements = convert_to_statements(revai_json)
-
- logging.info(" Rev.ai returned %d speaker statements", len(statements))
-
- return statements
-
- finally:
- # Clean up temp file
- if temp_path and temp_path.exists():
- temp_path.unlink()
-
-
-def get_model_info(config: dict) -> dict:
- """Get model configuration info for metadata.
-
- Args:
- config: Backend configuration dict
-
- Returns:
- Dict with model info for JSONL metadata
- """
- model = config.get("model", DEFAULT_MODEL)
- if model not in VALID_MODELS:
- model = DEFAULT_MODEL
-
- return {
- "model": f"revai-{model}",
- "device": "cloud",
- "compute_type": "api",
- "diarization": config.get("diarization_type", DEFAULT_DIARIZATION),
- }
diff --git a/solstone/talent/journal/references/captures.md b/solstone/talent/journal/references/captures.md
index d25207f8d..8ef114495 100644
--- a/solstone/talent/journal/references/captures.md
+++ b/solstone/talent/journal/references/captures.md
@@ -169,11 +169,14 @@ Example transcript file:
**Metadata line (first line):**
- `raw` – path to processed audio file (required)
-- `backend` – STT backend used (e.g., "parakeet", "revai")
-- `model` – model used for transcription (e.g., "medium.en", "revai-fusion")
-- `device` – device used for inference (e.g., "cuda", "cpu", "cloud")
-- `compute_type` – compute precision used (e.g., "float16", "int8", "api")
+- `backend` – STT backend used (e.g., "parakeet", "parakeet-cpp")
+- `model` – model used for transcription (e.g., "parakeet-tdt-0.6b-v3")
+- `device` – device used for inference (e.g., "cuda", "cpu", "coreml")
+- `compute_type` – compute precision used (e.g., "float16", "int8")
- `observer` – observer name if transcribed from an observer source (optional)
+- `topics` – legacy enrichment topics, still present in some existing journals
+- `setting` – legacy enrichment setting, still present in some existing journals
+- `warning` – legacy enrichment warning, still present in some existing journals
- `imported` – object with import metadata for external files (optional):
- `id` – unique import identifier
- `facet` – facet name for entity extraction
@@ -183,9 +186,9 @@ Example transcript file:
- `start` – timestamp in HH:MM:SS format (required)
- `text` – transcribed text (required)
- `source` – audio source: "mic" or "sys" (optional)
-- `speaker` – speaker identifier, numeric or string (optional, not currently populated)
-- `corrected` – LLM-corrected version of text (optional, added during enrichment)
-- `description` – tone or delivery description, e.g., "enthusiastic", "questioning" (optional, added during enrichment)
+- `speaker` – speaker identifier, numeric or string (optional, from local diarization)
+- `corrected` – legacy LLM-corrected version of text, still present in some existing journals
+- `description` – legacy tone or delivery description, e.g., "enthusiastic", "questioning", still present in some existing journals
### Screen frame extracts
diff --git a/solstone/talent/journal/references/config.md b/solstone/talent/journal/references/config.md
index 797ff4589..df939d0a2 100644
--- a/solstone/talent/journal/references/config.md
+++ b/solstone/talent/journal/references/config.md
@@ -107,7 +107,6 @@ The `env` block stores configuration as environment variables that solstone load
"GOOGLE_API_KEY": "your-google-api-key",
"ANTHROPIC_API_KEY": "your-anthropic-api-key",
"OPENAI_API_KEY": "your-openai-api-key",
- "REVAI_ACCESS_TOKEN": "your-revai-token",
"PLAUD_ACCESS_TOKEN": "your-plaud-token"
}
}
@@ -115,7 +114,7 @@ The `env` block stores configuration as environment variables that solstone load
**Managed provider keys are journal-config-exclusive.** For the managed provider API keys — `GOOGLE_API_KEY`, `OPENAI_API_KEY`, and `ANTHROPIC_API_KEY` — the journal config `env` section is the authoritative and exclusive source. At CLI startup, solstone loads the `env` block into the environment and then strips any of these managed keys that is *not* set in journal config, so a value set only in the shell is never used. This keeps the journal config the single, predictable place that decides which provider keys are in effect (useful when the journal is synced across machines).
-Other variables declared in the `env` block (for example `REVAI_ACCESS_TOKEN`, `PLAUD_ACCESS_TOKEN`) are loaded into the environment at startup as well.
+Other variables declared in the `env` block (for example `PLAUD_ACCESS_TOKEN`) are loaded into the environment at startup as well.
### Template usage examples
@@ -139,28 +138,21 @@ The `transcribe` block configures audio transcription settings for `journal tran
{
"transcribe": {
"backend": "parakeet",
- "enrich": true,
"preserve_all": false,
"confidential_audio": true,
- "noise_upgrade_min_speech_ratio": 0.3,
"parakeet": {
"model_version": "v3",
"device": "auto",
"timeout_sec": 120.0
- },
- "revai": {
- "model": "fusion"
}
}
}
```
**Top-level fields:**
-- `backend` (string) – STT backend to use: `"parakeet"` (default local processing), `"parakeet-cpp"` (Linux-only local processing via a supervised parakeet.cpp server), `"confidential"` (operated attested STT when the confidential lane is active), `"revai"` (cloud with speaker diarization), or `"gemini"` (cloud with speaker diarization). Default: `"parakeet"`.
-- `enrich` (boolean) – Enable LLM enrichment for topic extraction and transcript correction. Default: `true`.
+- `backend` (string) – STT backend to use: `"parakeet"` (default local processing), `"parakeet-cpp"` (Linux-only local processing via a supervised parakeet.cpp server), or `"confidential"` (operated attested STT when the confidential lane is active). Default: `"parakeet"`.
- `preserve_all` (boolean) – Keep audio files even when no speech is detected. When `false`, silent recordings are deleted to save disk space. Default: `false`.
- `confidential_audio` (boolean) – Allow confidential hosted STT when the confidential lane is active. Absent means `true`; set to `false` to keep STT on local placement.
-- `noise_upgrade_min_speech_ratio` (number) – Min speech/loud ratio required for noisy upgrade (default: `0.3`). Filters out music and other non-speech noise.
**Parakeet backend settings** (`transcribe.parakeet`):
- `model_version` (string) – Parakeet model version: `"v3"`. Default: `"v3"`.
@@ -170,9 +162,6 @@ The `transcribe` block configures audio transcription settings for `journal tran
**Parakeet.cpp backend settings** (`transcribe.parakeet-cpp`):
- `device` (string) – Runtime preference for the parakeet.cpp server: `"auto"` (use GPU if available, else CPU) or `"cpu"`. Default: `"auto"`.
-**Rev.ai backend settings** (`transcribe.revai`):
-- `model` (string) – Rev.ai transcriber model: `"fusion"` (best quality), `"machine"` (fast automated), or `"low_cost"`. Default: `"fusion"`.
-
Voice embeddings (wespeaker-resnet34) use CoreML with CPU fallback on Darwin and CPU-only elsewhere.
CLI flags can override settings: `--backend` selects the backend.
diff --git a/solstone/think/entities/loading.py b/solstone/think/entities/loading.py
index cf1545579..c76966417 100644
--- a/solstone/think/entities/loading.py
+++ b/solstone/think/entities/loading.py
@@ -6,7 +6,7 @@
This module handles loading entities from storage:
- load_entities: Load attached or detected entities for a facet
- load_all_attached_entities: Load from all facets with deduplication
-- load_entity_names / load_recent_entity_names: For transcription context
+- load_entity_names / load_recent_entity_names: Speech-friendly names for prompts
"""
import json
@@ -273,7 +273,7 @@ def _is_speakable(name: str) -> bool:
"""Check if a name is suitable for speech recognition vocabularies.
Allows letters, digits, spaces, periods, hyphens, and apostrophes.
- Must contain at least one letter (Rev.ai requirement).
+ Must contain at least one letter for recognizer vocabulary quality.
Rejects underscores and other programming symbols.
Args:
@@ -420,7 +420,7 @@ def load_entity_names(
def load_recent_entity_names(*, limit: int = 20) -> list[str] | None:
- """Load recently active entity names for transcription context.
+ """Load recently active entity names for prompt context.
Returns spoken-form names from the most recently seen entities across all
facets. Caller is responsible for formatting the list as needed.
diff --git a/solstone/think/models.py b/solstone/think/models.py
index 79c000e04..ad2eaa7b8 100644
--- a/solstone/think/models.py
+++ b/solstone/think/models.py
@@ -298,7 +298,7 @@ def _confidential_attestation_verifier() -> Callable[[dict[str, Any]], None]:
#
# Examples:
# - observe.describe.frame -> observe module, describe feature, frame operation
-# - observe.enrich -> observe module, enrich feature (no sub-operation)
+# - observe.extract -> observe module, extract feature (no sub-operation)
# - talent.system.meetings -> talent module, system source, meetings config
# - talent.entities.observer -> talent module, entities app, observer config
# - app.chat.title -> apps module, chat app, title operation
@@ -318,9 +318,7 @@ def _confidential_attestation_verifier() -> Callable[[dict[str, Any]], None]:
# Each must have: context, label, group in YAML frontmatter.
PROMPT_PATHS: List[str] = [
"observe/describe.md",
- "observe/enrich.md",
"observe/extract.md",
- "observe/transcribe/gemini.md",
"think/detect_created.md",
"think/detect_transcript_segment.md",
"think/detect_transcript_json.md",
@@ -342,7 +340,7 @@ def _discover_prompt_contexts() -> Dict[str, Dict[str, Any]]:
"""Load context metadata from prompt files listed in PROMPT_PATHS.
Each file must have YAML frontmatter with:
- - context: The context string (e.g., "observe.enrich")
+ - context: The context string (e.g., "observe.extract")
- label: Human-readable name
- group: Settings UI category
diff --git a/solstone/think/providers/shared.py b/solstone/think/providers/shared.py
index 8eb6c2918..cf148c410 100644
--- a/solstone/think/providers/shared.py
+++ b/solstone/think/providers/shared.py
@@ -176,15 +176,6 @@ def _status_code(exc: BaseException) -> int | None:
return value if isinstance(value, int) else None
-def _status_text(exc: BaseException) -> str:
- return str(
- getattr(exc, "status", "")
- or getattr(exc, "_status", "")
- or getattr(exc, "_status_text", "")
- or ""
- ).upper()
-
-
def _contains_any(text: str, patterns: tuple[str, ...]) -> bool:
return any(pattern in text for pattern in patterns)
@@ -260,10 +251,8 @@ def classify_provider_error(exc: BaseException, provider: str) -> str:
is_anthropic = _module_matches(exc_module, "anthropic")
is_openai = _module_matches(exc_module, "openai")
- is_google = _module_matches(exc_module, "google.genai")
is_httpx = _module_matches(exc_module, "httpx")
status_code = _status_code(exc)
- status_text = _status_text(exc)
if (is_anthropic or is_openai) and _exception_name_matches(
exc_name,
@@ -271,23 +260,11 @@ def classify_provider_error(exc: BaseException, provider: str) -> str:
("AuthenticationError", "PermissionDeniedError"),
):
return "provider_key_invalid"
- if (
- is_google
- and _exception_name_matches(exc_name, exc_qualname, ("ClientError",))
- and status_code in (401, 403)
- ):
- return "provider_key_invalid"
if (is_anthropic or is_openai) and _exception_name_matches(
exc_name, exc_qualname, ("RateLimitError",)
):
return "provider_quota_exceeded"
- if (
- is_google
- and _exception_name_matches(exc_name, exc_qualname, ("ClientError",))
- and (status_code == 429 or status_text == "RESOURCE_EXHAUSTED")
- ):
- return "provider_quota_exceeded"
if (is_anthropic or is_openai) and _exception_name_matches(
exc_name, exc_qualname, ("APITimeoutError",)
@@ -330,10 +307,6 @@ def classify_provider_error(exc: BaseException, provider: str) -> str:
exc_name, exc_qualname, ("InternalServerError",)
):
return "provider_unavailable"
- if is_google and _exception_name_matches(
- exc_name, exc_qualname, ("ServerError",)
- ):
- return "provider_unavailable"
if (
(
(is_anthropic or is_openai)
@@ -348,11 +321,6 @@ def classify_provider_error(exc: BaseException, provider: str) -> str:
) and (status_code or 0) >= 500:
return "provider_unavailable"
- if is_google and _exception_name_matches(
- exc_name, exc_qualname, ("UnknownApiResponseError",)
- ):
- return "provider_response_invalid"
-
if isinstance(exc, RuntimeError):
if _contains_any(message_lower, _CLI_UNAVAILABLE_PATTERNS):
return "provider_unavailable"
diff --git a/solstone/think/supervisor.py b/solstone/think/supervisor.py
index 6b5cb0bb1..dabdb113f 100644
--- a/solstone/think/supervisor.py
+++ b/solstone/think/supervisor.py
@@ -157,7 +157,6 @@ def linux_stt_uses_parakeet_cpp() -> bool:
selected = resolve_stt_backend_choice(
backend if isinstance(backend, str) else None,
read_available_bytes(),
- google_key_present=bool(os.getenv("GOOGLE_API_KEY")),
floor_bytes=stt_local_floor_bytes(),
local_backend=local_stt_backend(),
confidential_lane_active=confidential,
diff --git a/tests/baselines/api/settings/config.json b/tests/baselines/api/settings/config.json
index d02fe7c90..46514dc65 100644
--- a/tests/baselines/api/settings/config.json
+++ b/tests/baselines/api/settings/config.json
@@ -36,23 +36,17 @@
"timezone": "America/Denver"
},
"runtime_env": {
- "PLAUD_ACCESS_TOKEN": false,
- "REVAI_ACCESS_TOKEN": false
+ "PLAUD_ACCESS_TOKEN": false
},
"setup": {
"completed_at": 1700000000000
},
"transcribe": {
"backend": "parakeet",
- "enrich": true,
- "noise_upgrade": true,
"parakeet": {
"device": "auto",
"model_version": "v3",
"timeout_sec": 120.0
- },
- "revai": {
- "model": "fusion"
}
}
}
diff --git a/tests/baselines/api/settings/transcribe.json b/tests/baselines/api/settings/transcribe.json
index 0817968f3..a0f1e7ac6 100644
--- a/tests/baselines/api/settings/transcribe.json
+++ b/tests/baselines/api/settings/transcribe.json
@@ -1,27 +1,9 @@
{
"api_keys": {
- "gemini": false,
"parakeet": false,
- "parakeet-cpp": false,
- "revai": false
+ "parakeet-cpp": false
},
"backends": [
- {
- "description": "Cloud-based transcription with speaker identification",
- "env_key": "GOOGLE_API_KEY",
- "label": "Gemini - Cloud with speaker diarization",
- "name": "gemini",
- "settings": []
- },
- {
- "description": "Cloud-based transcription with speaker identification",
- "env_key": "REVAI_ACCESS_TOKEN",
- "label": "Rev.ai - Cloud with speaker diarization",
- "name": "revai",
- "settings": [
- "model"
- ]
- },
{
"description": "On-device speech recognition via Parakeet TDT; macOS uses a FluidAudio/CoreML helper, Linux uses the supervised parakeet.cpp server. Requires `make install`.",
"env_key": null,
@@ -46,27 +28,20 @@
"config": {
"backend": "parakeet",
"confidential_audio": true,
- "enrich": true,
- "noise_upgrade": true,
"parakeet": {
"device": "auto",
"model_version": "v3",
"timeout_sec": 120.0
- },
- "revai": {
- "model": "fusion"
}
},
+ "parakeet_uses_cpp": true,
"resource": {
- "auto_switched": "",
"available_memory_gb": "",
"detected": "",
- "force_local_hint": "",
"min_ram_gb": "",
"needs_setup": "",
"notice": "",
"requirement": ""
},
- "parakeet_uses_cpp": true,
"runtime_label": "Linux parakeet.cpp"
}
diff --git a/tests/conftest.py b/tests/conftest.py
index 49da051c8..01a8348aa 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -318,52 +318,6 @@ def add_module_stubs(monkeypatch):
sys.modules["gi.repository"] = repo
sys.modules["Gdk"] = repo.Gdk
sys.modules["Gtk"] = repo.Gtk
- google_mod = sys.modules.get("google", types.ModuleType("google"))
- genai_mod = types.ModuleType("google.genai")
-
- class DummyModels:
- def generate_content(self, *, model, contents, config=None):
- return types.SimpleNamespace(text="[]", candidates=[], usage_metadata=None)
-
- class DummyClient:
- def __init__(self, *a, **k):
- self.models = DummyModels()
-
- genai_mod.Client = DummyClient
-
- # Mock Content type for type hints
- class MockContent:
- pass
-
- # Mock config builders
- class MockHttpOptions:
- def __init__(self, **k):
- self.timeout = k.get("timeout")
- self.retry_options = k.get("retry_options")
-
- class MockThinkingConfig:
- def __init__(self, **k):
- self.thinking_budget = k.get("thinking_budget")
-
- class MockGenerateContentConfig:
- def __init__(self, **k):
- for key, value in k.items():
- setattr(self, key, value)
-
- class MockHttpRetryOptions:
- def __init__(self, **k):
- self.attempts = k.get("attempts")
-
- genai_mod.types = types.SimpleNamespace(
- GenerateContentConfig=MockGenerateContentConfig,
- Content=MockContent,
- HttpOptions=MockHttpOptions,
- HttpRetryOptions=MockHttpRetryOptions,
- ThinkingConfig=MockThinkingConfig,
- )
- google_mod.genai = genai_mod
- sys.modules["google"] = google_mod
- sys.modules["google.genai"] = genai_mod
if "cv2" not in sys.modules:
cv2_mod = types.ModuleType("cv2")
cv2_mod.__spec__ = importlib.machinery.ModuleSpec("cv2", loader=None)
@@ -483,109 +437,3 @@ def mock_callosum(monkeypatch):
monkeypatch.setattr(
"solstone.think.supervisor.CallosumConnection", MockCallosumConnection
)
-
-
-def setup_google_genai_stub(monkeypatch, *, with_thinking=False):
- """Set up a complete Google GenAI stub for testing.
-
- Args:
- monkeypatch: pytest monkeypatch fixture
- with_thinking: If True, mock responses include thinking parts
-
- Returns:
- The DummyChat class for inspection if needed
- """
- from types import SimpleNamespace
-
- google_mod = types.ModuleType("google")
- genai_mod = types.ModuleType("google.genai")
- errors_mod = types.ModuleType("google.genai.errors")
-
- # Error classes matching actual SDK structure
- class APIError(Exception):
- pass
-
- class ServerError(APIError):
- pass
-
- class ClientError(APIError):
- pass
-
- errors_mod.APIError = APIError
- errors_mod.ServerError = ServerError
- errors_mod.ClientError = ClientError
-
- class DummyChat:
- """Mock chat that optionally returns thinking parts."""
-
- kwargs = None # Class var to capture last call for inspection
-
- def __init__(self, model, history=None, config=None):
- self.model = model
- self.history = list(history or [])
- self.config = config
-
- def get_history(self):
- return list(self.history)
-
- def record_history(self, content):
- self.history.append(content)
-
- async def send_message(self, message, config=None):
- DummyChat.kwargs = {
- "message": message,
- "config": config,
- "model": self.model,
- }
- if with_thinking:
- # Response with thinking parts matching actual SDK structure
- thinking_part = SimpleNamespace(
- thought=True,
- text="I need to analyze this step by step.",
- )
- answer_part = SimpleNamespace(
- thought=False,
- text="ok",
- )
- candidate = SimpleNamespace(
- content=SimpleNamespace(parts=[thinking_part, answer_part]),
- )
- return SimpleNamespace(text="ok", candidates=[candidate])
- else:
- # Simple response without thinking
- return SimpleNamespace(text="ok")
-
- class DummyChats:
- def create(self, *, model, config=None, history=None):
- return DummyChat(model, history=history, config=config)
-
- class DummyModels:
- """Mock for client.models.generate_content (non-chat generate API)."""
-
- def generate_content(self, *, model, contents, config=None):
- return SimpleNamespace(text="[]", candidates=[], usage_metadata=None)
-
- class DummyClient:
- def __init__(self, *a, **k):
- self.chats = DummyChats()
- self.models = DummyModels()
- self.aio = SimpleNamespace(chats=DummyChats(), models=DummyModels())
-
- genai_mod.Client = DummyClient
- genai_mod.errors = errors_mod
- genai_mod.types = SimpleNamespace(
- GenerateContentConfig=lambda **k: SimpleNamespace(**k),
- ToolConfig=lambda **k: SimpleNamespace(**k),
- FunctionCallingConfig=lambda **k: SimpleNamespace(**k),
- ThinkingConfig=lambda **k: SimpleNamespace(**k),
- Content=lambda **k: SimpleNamespace(**k),
- Part=lambda **k: SimpleNamespace(**k),
- HttpOptions=lambda **k: SimpleNamespace(**k),
- HttpRetryOptions=lambda **k: SimpleNamespace(**k),
- )
- google_mod.genai = genai_mod
- monkeypatch.setitem(sys.modules, "google", google_mod)
- monkeypatch.setitem(sys.modules, "google.genai", genai_mod)
- monkeypatch.setitem(sys.modules, "google.genai.errors", errors_mod)
-
- return DummyChat
diff --git a/tests/test_bad_media_corpus.py b/tests/test_bad_media_corpus.py
index 609c20db6..6daa647c2 100644
--- a/tests/test_bad_media_corpus.py
+++ b/tests/test_bad_media_corpus.py
@@ -324,7 +324,6 @@ def _drive_transcribe(
argparse.Namespace(backend=None, cpu=False, model=None, redo=False),
{"preserve_all": preserve_all},
"parakeet",
- [],
)
jsonl_path = audio_path.with_suffix(".jsonl")
@@ -563,7 +562,6 @@ def test_corrupt_audio_decode_records_failed_without_vad_or_stt(
argparse.Namespace(backend=None, cpu=False, model=None, redo=False),
{"preserve_all": True},
"parakeet",
- [],
)
jsonl_path = audio_path.with_suffix(".jsonl")
@@ -589,7 +587,6 @@ def test_corrupt_audio_decode_records_failed_without_vad_or_stt(
argparse.Namespace(backend=None, cpu=False, model=None, redo=False),
{"preserve_all": True},
"parakeet",
- [],
)
assert load_audio_spy.call_count == 0
diff --git a/tests/test_check_convey_bind_imports_clean.py b/tests/test_check_convey_bind_imports_clean.py
index 568dc02db..acc176f96 100644
--- a/tests/test_check_convey_bind_imports_clean.py
+++ b/tests/test_check_convey_bind_imports_clean.py
@@ -26,7 +26,6 @@ EXPECTED_HEAVY = {
"faster_whisper",
"torch",
"pandas",
- "google.genai",
"huggingface_hub",
"litellm",
}
@@ -63,5 +62,5 @@ def test_injected_heavy_import_goes_red_and_names_offender() -> None:
def test_heavy_constant_is_single_source_of_truth() -> None:
module = _load_script_module()
- assert len(module.HEAVY) == 16
+ assert len(module.HEAVY) == 15
assert set(module.HEAVY) == EXPECTED_HEAVY
diff --git a/tests/test_convey_lazy_imports.py b/tests/test_convey_lazy_imports.py
index 0b28c0cdf..5f634b9f0 100644
--- a/tests/test_convey_lazy_imports.py
+++ b/tests/test_convey_lazy_imports.py
@@ -20,7 +20,6 @@ FORBIDDEN = {
"solstone.apps",
"solstone.convey.apps",
"solstone.convey.provider_readiness",
- "google.genai",
"openai",
"anthropic",
}
diff --git a/tests/test_doctor.py b/tests/test_doctor.py
index ec7a1e3c9..4287dcee9 100644
--- a/tests/test_doctor.py
+++ b/tests/test_doctor.py
@@ -285,7 +285,7 @@ class TestDefaultSttReady:
config_path = journal / "config" / "journal.json"
config_path.parent.mkdir(parents=True)
config_path.write_text(
- json.dumps({"transcribe": {"backend": "gemini"}}),
+ json.dumps({"transcribe": {"backend": "confidential"}}),
encoding="utf-8",
)
monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal))
@@ -298,7 +298,7 @@ class TestDefaultSttReady:
result = doctor.default_stt_ready_check(args(doctor))
assert result.status == "skip"
- assert "gemini" in result.detail
+ assert "confidential" in result.detail
def test_journal_less_host_defaults_to_parakeet_without_creating_journal(
self, doctor, monkeypatch, tmp_path
diff --git a/tests/test_enrich.py b/tests/test_enrich.py
deleted file mode 100644
index 81402da32..000000000
--- a/tests/test_enrich.py
+++ /dev/null
@@ -1,382 +0,0 @@
-# SPDX-License-Identifier: AGPL-3.0-only
-# Copyright (c) 2026 sol pbc
-
-"""Tests for observe.enrich module."""
-
-import io
-import json
-from unittest.mock import patch
-
-import numpy as np
-import soundfile as sf
-
-from solstone.observe.enrich import _statement_to_flac_bytes
-
-
-class TestStatementToFlacBytes:
- """Test audio statement extraction and encoding."""
-
- def test_extracts_statement_to_flac(self):
- """Should extract statement and encode as FLAC bytes."""
- # Create 2 seconds of 440Hz sine wave
- sample_rate = 16000
- duration = 2.0
- t = np.linspace(0, duration, int(sample_rate * duration), dtype=np.float32)
- wav = 0.5 * np.sin(2 * np.pi * 440 * t)
-
- # Extract 0.5s to 1.5s
- flac_bytes = _statement_to_flac_bytes(wav, 0.5, 1.5, sample_rate)
-
- # Should return non-empty bytes
- assert isinstance(flac_bytes, bytes)
- assert len(flac_bytes) > 0
-
- # Should be valid FLAC that can be decoded
- buf = io.BytesIO(flac_bytes)
- data, sr = sf.read(buf)
- assert sr == sample_rate
- # 1 second at 16kHz = 16000 samples
- assert len(data) == 16000
-
- def test_handles_statement_at_end(self):
- """Should handle statement near end of audio."""
- sample_rate = 16000
- wav = np.zeros(sample_rate, dtype=np.float32) # 1 second
-
- # Extract last 0.5s
- flac_bytes = _statement_to_flac_bytes(wav, 0.5, 1.0, sample_rate)
-
- buf = io.BytesIO(flac_bytes)
- data, _ = sf.read(buf)
- assert len(data) == sample_rate // 2
-
- def test_handles_empty_statement(self):
- """Should handle zero-length statement."""
- sample_rate = 16000
- wav = np.zeros(sample_rate, dtype=np.float32)
-
- # Extract 0-length statement
- flac_bytes = _statement_to_flac_bytes(wav, 0.5, 0.5, sample_rate)
-
- # Should still return valid (empty) FLAC
- assert isinstance(flac_bytes, bytes)
-
-
-class TestEnrichTranscript:
- """Test the main enrichment function."""
-
- @patch("solstone.observe.enrich.generate")
- def test_returns_enrichment_data(self, mock_generate):
- """Should return enrichment dict on success."""
- from solstone.observe.enrich import enrich_transcript
-
- # Create audio buffer directly
- sample_rate = 16000
- wav = np.zeros(sample_rate * 10, dtype=np.float32) # 10 seconds
-
- # Mock Gemini response with statements array
- mock_response = json.dumps(
- {
- "statements": [
- {"corrected": "Hello world.", "emotion": "calm tone"},
- {"corrected": "This is a test.", "emotion": "excited voice"},
- ],
- "topics": "testing, software",
- "setting": "workplace",
- }
- )
- mock_generate.return_value = mock_response
-
- statements = [
- {"id": 1, "start": 0.0, "end": 2.0, "text": "Hello world."},
- {"id": 2, "start": 5.0, "end": 7.0, "text": "This is a test."},
- ]
-
- result = enrich_transcript(wav, sample_rate, statements)
-
- assert result is not None
- assert "statements" in result
- assert "topics" in result
- assert "setting" in result
- assert len(result["statements"]) == 2
- assert result["statements"][0]["corrected"] == "Hello world."
- assert result["statements"][0]["emotion"] == "calm tone"
- assert result["topics"] == "testing, software"
- assert result["setting"] == "workplace"
-
- @patch("solstone.observe.enrich.generate")
- def test_returns_none_on_api_error(self, mock_generate):
- """Should return None if Gemini call fails."""
- from solstone.observe.enrich import enrich_transcript
-
- wav = np.zeros(16000 * 10, dtype=np.float32)
- mock_generate.side_effect = Exception("API error")
-
- statements = [{"id": 1, "start": 0.0, "end": 2.0, "text": "Hello."}]
-
- result = enrich_transcript(wav, 16000, statements)
-
- assert result is None
-
- @patch("solstone.observe.enrich.generate")
- def test_returns_none_on_invalid_response(self, mock_generate):
- """Should return None if response missing required fields."""
- from solstone.observe.enrich import enrich_transcript
-
- wav = np.zeros(16000 * 10, dtype=np.float32)
- # Missing 'statements' field
- mock_generate.return_value = json.dumps({"topics": "test"})
-
- statements = [{"id": 1, "start": 0.0, "end": 2.0, "text": "Hello."}]
-
- result = enrich_transcript(wav, 16000, statements)
-
- assert result is None
-
- @patch("solstone.observe.enrich.generate")
- def test_bare_list_response_returns_none(self, mock_generate):
- """Should return None when response is a bare list (schema rejection)."""
- from solstone.observe.enrich import enrich_transcript
-
- wav = np.zeros(16000 * 10, dtype=np.float32)
- # Gemini returns bare list instead of {"statements": [...], "topics": ...}
- mock_generate.return_value = json.dumps(
- [
- {"corrected": "Hello world.", "emotion": "calm"},
- ]
- )
-
- statements = [{"id": 1, "start": 0.0, "end": 2.0, "text": "Hello world."}]
-
- result = enrich_transcript(wav, 16000, statements)
-
- assert result is None
-
- def test_returns_none_for_empty_statements(self):
- """Should return None for empty statement list."""
- from solstone.observe.enrich import enrich_transcript
-
- wav = np.zeros(16000, dtype=np.float32)
- result = enrich_transcript(wav, 16000, [])
-
- assert result is None
-
- @patch("solstone.observe.enrich.generate")
- def test_builds_interleaved_content(self, mock_generate):
- """Should send numbered text labels and audio clips interleaved."""
- from solstone.observe.enrich import enrich_transcript
-
- sample_rate = 16000
- wav = np.zeros(sample_rate * 10, dtype=np.float32) # 10 seconds
-
- mock_response = json.dumps(
- {
- "statements": [{"corrected": "Hello world.", "emotion": "neutral"}],
- "topics": "test",
- "setting": "other",
- }
- )
- mock_generate.return_value = mock_response
-
- statements = [
- {"id": 1, "start": 0.0, "end": 2.0, "text": "Hello world."},
- ]
-
- # Pass entity names explicitly
- enrich_transcript(wav, sample_rate, statements, entity_names=["Alice", "Bob"])
-
- # Verify generate was called
- assert mock_generate.called
- call_kwargs = mock_generate.call_args.kwargs
- contents = call_kwargs.get("contents") or mock_generate.call_args.args[0]
-
- # Should have: prompt + (text label + audio clip) for each statement
- # = 1 + 2 * num_statements = 3 for 1 statement
- assert len(contents) == 3
-
- # First should be prompt text
- assert isinstance(contents[0], str)
- assert "corrected" in contents[0].lower()
- assert "Alice, Bob" in contents[0] # Entity names should be in prompt
-
- # Second should be numbered text label
- assert "Statement 1:" in contents[1]
- assert "Hello world." in contents[1]
-
- # Third should be audio Part (check it's not a string)
- assert not isinstance(contents[2], str)
- # Check it has the Part's data attribute
- assert hasattr(contents[2], "inline_data") or hasattr(contents[2], "_pb")
-
-
-class TestStatementsToJsonl:
- """Test JSONL output formatting with enrichment."""
-
- def test_statements_to_jsonl_without_enrichment(self):
- """_statements_to_jsonl should work without enrichment."""
- import datetime
-
- from solstone.observe.transcribe.main import _statements_to_jsonl
-
- statements = [{"id": 1, "start": 0.0, "end": 2.0, "text": "Hello."}]
- base_dt = datetime.datetime(2026, 1, 10, 14, 30, 0)
- model_info = {"model": "medium.en", "device": "cpu", "compute_type": "int8"}
-
- lines = _statements_to_jsonl(statements, "audio.flac", base_dt, model_info)
-
- assert len(lines) == 2
- metadata = json.loads(lines[0])
- assert metadata["raw"] == "audio.flac"
- assert "topics" not in metadata
- assert "setting" not in metadata
-
- entry = json.loads(lines[1])
- assert entry["start"] == "14:30:00"
- assert entry["text"] == "Hello."
- assert "emotion" not in entry
- assert "corrected" not in entry
-
- def test_statements_to_jsonl_with_enrichment(self):
- """_statements_to_jsonl should include enrichment data."""
- import datetime
-
- from solstone.observe.transcribe.main import _statements_to_jsonl
-
- statements = [
- {"id": 1, "start": 0.0, "end": 2.0, "text": "Hello."},
- {"id": 2, "start": 5.0, "end": 7.0, "text": "World."},
- ]
- base_dt = datetime.datetime(2026, 1, 10, 14, 30, 0)
- model_info = {"model": "medium.en", "device": "cpu", "compute_type": "int8"}
-
- # Enrichment with statements array (corrected + emotion)
- enrichment = {
- "statements": [
- {"corrected": "Hello!", "emotion": "friendly tone"},
- {"corrected": "World.", "emotion": "excited"},
- ],
- "topics": "greetings, testing",
- "setting": "personal",
- }
-
- lines = _statements_to_jsonl(
- statements, "audio.flac", base_dt, model_info, enrichment=enrichment
- )
-
- assert len(lines) == 3
-
- # Check metadata has topics and setting
- metadata = json.loads(lines[0])
- assert metadata["topics"] == "greetings, testing"
- assert metadata["setting"] == "personal"
-
- # Check entries have corrected text and emotions
- entry1 = json.loads(lines[1])
- assert entry1["emotion"] == "friendly tone"
- assert entry1["corrected"] == "Hello!" # Different from original
- assert entry1["text"] == "Hello." # Original preserved
-
- entry2 = json.loads(lines[2])
- assert entry2["emotion"] == "excited"
- assert "corrected" not in entry2 # Same as original, not included
-
- def test_statements_to_jsonl_corrected_same_as_original(self):
- """_statements_to_jsonl should not include corrected if same as original."""
- import datetime
-
- from solstone.observe.transcribe.main import _statements_to_jsonl
-
- statements = [{"id": 1, "start": 0.0, "end": 2.0, "text": "Hello."}]
- base_dt = datetime.datetime(2026, 1, 10, 14, 30, 0)
- model_info = {"model": "medium.en", "device": "cpu", "compute_type": "int8"}
-
- # Corrected text same as original
- enrichment = {
- "statements": [{"corrected": "Hello.", "emotion": "calm"}],
- "topics": "test",
- "setting": "other",
- }
-
- lines = _statements_to_jsonl(
- statements, "audio.flac", base_dt, model_info, enrichment=enrichment
- )
-
- entry = json.loads(lines[1])
- assert entry["text"] == "Hello."
- assert "corrected" not in entry # Not included since same as original
- assert entry["emotion"] == "calm"
-
- def test_statements_to_jsonl_partial_enrichment(self):
- """_statements_to_jsonl should handle partial enrichment."""
- import datetime
-
- from solstone.observe.transcribe.main import _statements_to_jsonl
-
- statements = [
- {"id": 1, "start": 0.0, "end": 2.0, "text": "Hello."},
- {"id": 2, "start": 5.0, "end": 7.0, "text": "World."},
- ]
- base_dt = datetime.datetime(2026, 1, 10, 14, 30, 0)
- model_info = {"model": "medium.en", "device": "cpu", "compute_type": "int8"}
-
- # Enrichment only has one statement (fewer than input statements)
- enrichment = {
- "statements": [{"corrected": "Hello!", "emotion": "friendly tone"}],
- "topics": "test",
- "setting": "other",
- }
-
- lines = _statements_to_jsonl(
- statements, "audio.flac", base_dt, model_info, enrichment=enrichment
- )
-
- entry1 = json.loads(lines[1])
- assert "emotion" in entry1
- assert entry1["emotion"] == "friendly tone"
- assert entry1["corrected"] == "Hello!"
-
- entry2 = json.loads(lines[2])
- assert "emotion" not in entry2
- assert "corrected" not in entry2
-
-
-class TestFormatAudioCorrectedText:
- """Test that format_audio prefers corrected text."""
-
- def test_prefers_corrected_over_text(self):
- """format_audio should display corrected text when available."""
- from solstone.observe.hear import format_audio
-
- entries = [
- {"raw": "audio.flac"},
- {
- "start": "10:00:00",
- "text": "Hello wrold.",
- "corrected": "Hello world.",
- "emotion": "calm",
- },
- ]
-
- chunks, meta = format_audio(entries)
-
- assert len(chunks) == 1
- # Should use corrected text, not original
- assert "Hello world." in chunks[0]["markdown"]
- assert "Hello wrold." not in chunks[0]["markdown"]
- # Description should still be appended
- assert "(calm)" in chunks[0]["markdown"]
-
- def test_falls_back_to_text_without_corrected(self):
- """format_audio should use text when corrected is not present."""
- from solstone.observe.hear import format_audio
-
- entries = [
- {"raw": "audio.flac"},
- {"start": "10:00:00", "text": "Hello world.", "emotion": "calm"},
- ]
-
- chunks, meta = format_audio(entries)
-
- assert len(chunks) == 1
- assert "Hello world." in chunks[0]["markdown"]
- assert "(calm)" in chunks[0]["markdown"]
diff --git a/tests/test_enrich_schema.py b/tests/test_enrich_schema.py
deleted file mode 100644
index 6356f4333..000000000
--- a/tests/test_enrich_schema.py
+++ /dev/null
@@ -1,175 +0,0 @@
-# SPDX-License-Identifier: AGPL-3.0-only
-# Copyright (c) 2026 sol pbc
-
-import importlib
-import json
-from pathlib import Path
-
-from jsonschema import Draft202012Validator
-
-import solstone.think.models as models
-
-enrich_mod = importlib.import_module("solstone.observe.enrich")
-
-_SCHEMA = json.loads(
- (
- Path(__file__).resolve().parents[1]
- / "solstone"
- / "observe"
- / "enrich.schema.json"
- ).read_text(encoding="utf-8")
-)
-
-
-def test_enrich_schema_file_is_valid_draft_2020_12():
- Draft202012Validator.check_schema(_SCHEMA)
-
-
-def test_enrich_schema_accepts_and_rejects_expected_values():
- validator = Draft202012Validator(_SCHEMA)
-
- assert validator.is_valid(
- {
- "statements": [{"corrected": "Hello world.", "emotion": "calm"}],
- "topics": "",
- "setting": "",
- "warning": "",
- }
- )
- assert validator.is_valid(
- {
- "statements": [],
- "topics": "planning, testing",
- "setting": "work",
- "warning": "",
- }
- )
- assert validator.is_valid(
- {
- "statements": [],
- "topics": "",
- "setting": "",
- "warning": "",
- }
- )
- assert not validator.is_valid([{"corrected": "x", "emotion": "y"}])
- assert not validator.is_valid(
- {
- "statements": [{"corrected": "Hello world.", "emotion": "calm"}],
- "setting": "",
- "warning": "",
- }
- )
- assert not validator.is_valid(
- {
- "statements": [{"corrected": "Hello world.", "emotion": "calm"}],
- "topics": "",
- "warning": "",
- }
- )
- assert not validator.is_valid(
- {
- "statements": [{"corrected": "Hello world.", "emotion": "calm"}],
- "topics": "",
- "setting": "",
- }
- )
- assert not validator.is_valid(
- {
- "topics": "",
- "setting": "",
- "warning": "",
- }
- )
- assert not validator.is_valid(
- {
- "statements": [{"corrected": "Hello world.", "emotion": "calm"}],
- "topics": "",
- "setting": "",
- "warning": "",
- "extra": "nope",
- }
- )
- assert not validator.is_valid(
- {
- "statements": [{"emotion": "calm"}],
- "topics": "",
- "setting": "",
- "warning": "",
- }
- )
- assert not validator.is_valid(
- {
- "statements": [{"corrected": "Hello world."}],
- "topics": "",
- "setting": "",
- "warning": "",
- }
- )
- assert not validator.is_valid(
- {
- "statements": [
- {
- "corrected": "Hello world.",
- "emotion": "calm",
- "extra": "nope",
- }
- ],
- "topics": "",
- "setting": "",
- "warning": "",
- }
- )
-
-
-def test_enrich_transcript_passes_schema_to_generate(monkeypatch):
- captured = {}
-
- def fake_generate(**kwargs):
- captured.update(kwargs)
- return json.dumps(
- {
- "statements": [{"corrected": "Hello world.", "emotion": "neutral"}],
- "topics": "",
- "setting": "",
- "warning": "",
- }
- )
-
- monkeypatch.setattr(enrich_mod, "generate", fake_generate)
-
- import numpy as np
-
- result = enrich_mod.enrich_transcript(
- np.zeros(16000, dtype=np.float32),
- 16000,
- [{"id": 1, "start": 0.0, "end": 1.0, "text": "Hello world."}],
- )
-
- assert captured["json_schema"] is enrich_mod._SCHEMA
- assert result == {
- "statements": [{"corrected": "Hello world.", "emotion": "neutral"}],
- "topics": "",
- "setting": "",
- "warning": "",
- }
-
-
-def test_enrich_transcript_schema_validation_error_returns_none(monkeypatch):
- def fake_generate(**kwargs):
- raise models.SchemaValidationError(
- [{"path": "", "constraint": "json_parse", "message": "empty"}],
- "",
- )
-
- monkeypatch.setattr(enrich_mod, "generate", fake_generate)
-
- import numpy as np
-
- result = enrich_mod.enrich_transcript(
- np.zeros(16000, dtype=np.float32),
- 16000,
- [{"id": 1, "start": 0.0, "end": 1.0, "text": "Hello world."}],
- )
-
- assert result is None
diff --git a/tests/test_models.py b/tests/test_models.py
index 6312a47f2..bb6adffff 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -363,8 +363,6 @@ def test_prompt_contexts_in_registry():
assert "observe.describe.frame" in registry
assert registry["observe.describe.frame"]["group"] == "Observe"
- assert "observe.enrich" in registry
-
assert "detect.created" in registry
diff --git a/tests/test_no_implicit_cloud.py b/tests/test_no_implicit_cloud.py
index 66a37d066..876f5a552 100644
--- a/tests/test_no_implicit_cloud.py
+++ b/tests/test_no_implicit_cloud.py
@@ -189,20 +189,10 @@ def _stt_audio() -> np.ndarray:
def _install_stt_backend_mocks(
monkeypatch: pytest.MonkeyPatch,
*,
- gemini_result=AssertionError("gemini audio egress attempted"),
- revai_result=AssertionError("revai audio egress attempted"),
parakeet_result=AssertionError("parakeet dispatch attempted"),
confidential_result=AssertionError("confidential dispatch attempted"),
) -> dict[str, Mock]:
targets = {
- "gemini": (
- "solstone.observe.transcribe.gemini.transcribe",
- gemini_result,
- ),
- "revai": (
- "solstone.observe.transcribe.revai.transcribe",
- revai_result,
- ),
"parakeet": (
"solstone.observe.transcribe.parakeet.transcribe",
parakeet_result,
@@ -603,21 +593,15 @@ def test_confidential_stt_attestation_failure_blocks_remote_audio_egress(
transcribe("confidential", _stt_audio(), 16000, {})
assert confidential_exc.value.reason_code == "attestation_unreachable"
- with pytest.raises(ConfidentialAudioEgressError):
- transcribe("gemini", _stt_audio(), 16000, {})
- with pytest.raises(ConfidentialAudioEgressError):
- transcribe("revai", _stt_audio(), 16000, {})
monkeypatch.setitem(
BACKEND_REGISTRY,
"future-remote",
- "solstone.observe.transcribe.gemini",
+ "solstone.observe.transcribe.parakeet",
)
with pytest.raises(ConfidentialAudioEgressError):
transcribe("future-remote", _stt_audio(), 16000, {})
assert transcribe("parakeet", _stt_audio(), 16000, {}) == []
- mocks["gemini"].assert_not_called()
- mocks["revai"].assert_not_called()
mocks["parakeet"].assert_called_once()
httpx_post.assert_not_called()
establish.assert_called_once()
@@ -644,8 +628,6 @@ def test_confidential_stt_stale_session_defers_before_egress(tmp_path, monkeypat
transcribe("confidential", _stt_audio(), 16000, {})
assert exc_info.value.reason_code == "attestation_stale"
- mocks["gemini"].assert_not_called()
- mocks["revai"].assert_not_called()
mocks["parakeet"].assert_not_called()
httpx_post.assert_not_called()
@@ -664,7 +646,6 @@ def test_confidential_stt_setting_off_gate_blocks_confidential_only(
monkeypatch.setattr("httpx.post", httpx_post)
from solstone.observe.transcribe import (
- ConfidentialAudioEgressError,
ConfidentialTranscribeDeferral,
transcribe,
)
@@ -672,48 +653,18 @@ def test_confidential_stt_setting_off_gate_blocks_confidential_only(
with pytest.raises(ConfidentialTranscribeDeferral) as exc_info:
transcribe("confidential", _stt_audio(), 16000, {})
assert exc_info.value.reason_code == "confidential_audio_disabled"
- with pytest.raises(ConfidentialAudioEgressError):
- transcribe("gemini", _stt_audio(), 16000, {})
- with pytest.raises(ConfidentialAudioEgressError):
- transcribe("revai", _stt_audio(), 16000, {})
assert transcribe("parakeet", _stt_audio(), 16000, {}) == []
mocks["confidential"].assert_not_called()
- mocks["gemini"].assert_not_called()
- mocks["revai"].assert_not_called()
mocks["parakeet"].assert_called_once()
httpx_post.assert_not_called()
-def test_confidential_stt_lane_inactive_refuses_confidential_without_passthrough(
- tmp_path,
- monkeypatch,
-):
- _empty_journal(tmp_path, monkeypatch)
- _write_journal_config(
- tmp_path,
- {"env": {"GOOGLE_API_KEY": "test-google-key", "REVAI_ACCESS_TOKEN": "revai"}},
- )
- mocks = _install_stt_backend_mocks(
- monkeypatch,
- gemini_result=["gemini-dispatched"],
- revai_result=["revai-dispatched"],
- )
- httpx_post = Mock(side_effect=AssertionError("passthrough URL posted to"))
- monkeypatch.setattr("httpx.post", httpx_post)
-
- from solstone.observe.transcribe import ConfidentialTranscribeDeferral, transcribe
-
- with pytest.raises(ConfidentialTranscribeDeferral) as exc_info:
- transcribe("confidential", _stt_audio(), 16000, {})
- assert exc_info.value.reason_code == "confidential_lane_inactive"
- assert transcribe("gemini", _stt_audio(), 16000, {}) == ["gemini-dispatched"]
- assert transcribe("revai", _stt_audio(), 16000, {}) == ["revai-dispatched"]
+def test_stt_registry_has_no_owner_selectable_cloud_backends():
+ from solstone.observe.transcribe import BACKEND_METADATA, BACKEND_REGISTRY
- mocks["confidential"].assert_not_called()
- mocks["gemini"].assert_called_once()
- mocks["revai"].assert_called_once()
- httpx_post.assert_not_called()
+ for name in BACKEND_REGISTRY:
+ assert BACKEND_METADATA[name]["local"] is True or name == "confidential"
def test_confidential_stt_posts_only_to_verified_forwarder(tmp_path, monkeypatch):
diff --git a/tests/test_planner.py b/tests/test_planner.py
index 4d452c83e..550b9c3ac 100644
--- a/tests/test_planner.py
+++ b/tests/test_planner.py
@@ -3,34 +3,6 @@
import importlib
import sys
-from types import SimpleNamespace
-
-
-def _setup_genai(monkeypatch):
- import types
-
- google_mod = types.ModuleType("google")
- genai_mod = types.ModuleType("google.genai")
-
- class DummyModels:
- def generate_content(self, *a, **k):
- DummyModels.kwargs = {"args": a, "kwargs": k}
- return SimpleNamespace(text="plan")
-
- class DummyClient:
- def __init__(self, *a, **k):
- self.models = SimpleNamespace(
- generate_content=DummyModels().generate_content
- )
-
- genai_mod.Client = DummyClient
- genai_mod.types = types.SimpleNamespace(
- GenerateContentConfig=lambda **k: SimpleNamespace(**k),
- ThinkingConfig=lambda **k: SimpleNamespace(**k),
- )
- google_mod.genai = genai_mod
- monkeypatch.setitem(sys.modules, "google", google_mod)
- monkeypatch.setitem(sys.modules, "google.genai", genai_mod)
def test_generate_plan(monkeypatch):
diff --git a/tests/test_provider_error_classification.py b/tests/test_provider_error_classification.py
index 3642cc919..7d38353fe 100644
--- a/tests/test_provider_error_classification.py
+++ b/tests/test_provider_error_classification.py
@@ -188,28 +188,6 @@ def test_classifies_openai_sdk_rate_timeout_network_and_5xx_errors():
assert classify_provider_error(server_exc, "openai") == "provider_unavailable"
-def test_classifies_google_sdk_errors():
- errors = pytest.importorskip("google.genai.errors")
-
- auth_exc = errors.ClientError(
- 401,
- {"error": {"status": "UNAUTHENTICATED", "message": "bad key"}},
- )
- assert classify_provider_error(auth_exc, "google") == "provider_key_invalid"
-
- rate_exc = errors.ClientError(
- 429,
- {"error": {"status": "RESOURCE_EXHAUSTED", "message": "quota"}},
- )
- assert classify_provider_error(rate_exc, "google") == "provider_quota_exceeded"
-
- server_exc = errors.ServerError(
- 503,
- {"error": {"status": "UNAVAILABLE", "message": "down"}},
- )
- assert classify_provider_error(server_exc, "google") == "provider_unavailable"
-
-
def test_classifies_httpx_errors():
httpx = pytest.importorskip("httpx")
request = httpx.Request("GET", "http://localhost:11434")
diff --git a/tests/test_provider_state.py b/tests/test_provider_state.py
index 3ae2765a4..59fd83592 100644
--- a/tests/test_provider_state.py
+++ b/tests/test_provider_state.py
@@ -168,12 +168,12 @@ def test_classify_provider_error_matches_provider_exception_names():
),
(
_provider_exc(
- "google.genai.errors",
+ "provider_sdk.errors",
"ClientError",
attrs={"_status_code": 403},
),
"google",
- "provider_key_invalid",
+ "unknown",
),
(
_provider_exc("anthropic", "RateLimitError"),
@@ -182,21 +182,21 @@ def test_classify_provider_error_matches_provider_exception_names():
),
(
_provider_exc(
- "google.genai.errors",
+ "provider_sdk.errors",
"ClientError",
attrs={"_status_code": 429},
),
"google",
- "provider_quota_exceeded",
+ "unknown",
),
(
_provider_exc(
- "google.genai.errors",
+ "provider_sdk.errors",
"ClientError",
attrs={"_status_text": "RESOURCE_EXHAUSTED"},
),
"google",
- "provider_quota_exceeded",
+ "unknown",
),
(_provider_exc("openai", "APITimeoutError"), "openai", "chat_timeout"),
(_provider_exc("httpx", "TimeoutException"), "openai", "chat_timeout"),
@@ -212,7 +212,7 @@ def test_classify_provider_error_matches_provider_exception_names():
"provider_unavailable",
),
(
- _provider_exc("google.genai.errors", "ServerError"),
+ _provider_exc("provider_sdk.errors", "ServerError"),
"google",
"provider_unavailable",
),
@@ -231,7 +231,7 @@ def test_classify_provider_error_matches_provider_exception_names():
"provider_unavailable",
),
(
- _provider_exc("google.genai.errors", "UnknownApiResponseError"),
+ _provider_exc("provider_sdk.errors", "UnknownApiResponseError"),
"google",
"provider_response_invalid",
),
diff --git a/tests/test_settings_call_parity.py b/tests/test_settings_call_parity.py
index 69f71060a..54733848f 100644
--- a/tests/test_settings_call_parity.py
+++ b/tests/test_settings_call_parity.py
@@ -23,7 +23,6 @@ API_ENV_KEYS = (
"GOOGLE_API_KEY",
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
- "REVAI_ACCESS_TOKEN",
"PLAUD_ACCESS_TOKEN",
"GOOGLE_APPLICATION_CREDENTIALS",
)
@@ -74,9 +73,6 @@ def fake_validators(monkeypatch: pytest.MonkeyPatch) -> None:
return {"valid": True, "token": token[-4:]}
monkeypatch.setattr(settings_routes, "datetime", _FixedDateTime)
- monkeypatch.setattr(
- "solstone.observe.transcribe.revai.validate_token", validate_token
- )
monkeypatch.setattr("solstone.think.importers.plaud.validate_token", validate_token)
@@ -114,7 +110,6 @@ def test_show_and_read_verbs_select_http_fields(journal_copy: Path) -> None:
]
assert show_payload["identity"]["name"] == "Test User"
assert list(show_payload["keys"]) == [
- "REVAI_ACCESS_TOKEN",
"PLAUD_ACCESS_TOKEN",
]
@@ -131,7 +126,6 @@ def test_show_and_read_verbs_select_http_fields(journal_copy: Path) -> None:
def test_settings_config_projects_service_validation_only(journal_copy: Path) -> None:
config = _read_config(journal_copy)
config["service_key_validation"] = {
- "revai": {"valid": True, "timestamp": "2026-01-01T00:00:00+00:00"},
"plaud": {"valid": False, "error": "bad token"},
}
_write_config(journal_copy, config)
@@ -140,7 +134,6 @@ def test_settings_config_projects_service_validation_only(journal_copy: Path) ->
assert "providers" not in payload
assert payload["key_validation"] == {
- "revai": {"valid": True, "timestamp": "2026-01-01T00:00:00+00:00"},
"plaud": {"valid": False, "error": "bad token"},
}
@@ -152,8 +145,7 @@ def test_keys_set_clear_validate_and_invalid_env(
invalid = runner.invoke(settings_call.app, ["keys", "set", "BOGUS", "value"])
assert invalid.exit_code == 1
assert invalid.stderr == (
- "Invalid env var: BOGUS. Must be one of: "
- "REVAI_ACCESS_TOKEN, PLAUD_ACCESS_TOKEN\n"
+ "Invalid env var: BOGUS. Must be one of: PLAUD_ACCESS_TOKEN\n"
)
ai_key = runner.invoke(
@@ -166,11 +158,11 @@ def test_keys_set_clear_validate_and_invalid_env(
service_set = runner.invoke(
settings_call.app,
- ["keys", "set", "REVAI_ACCESS_TOKEN", "revai-token"],
+ ["keys", "set", "PLAUD_ACCESS_TOKEN", "plaud-token"],
)
assert service_set.exit_code == 0
assert json.loads(service_set.stdout) == {
- "env_var": "REVAI_ACCESS_TOKEN",
+ "env_var": "PLAUD_ACCESS_TOKEN",
"set": True,
"validation": {
"valid": True,
@@ -178,14 +170,14 @@ def test_keys_set_clear_validate_and_invalid_env(
"timestamp": "2026-04-17T12:00:00+00:00",
},
}
- assert _read_config(journal_copy)["service_key_validation"]["revai"]["valid"]
+ assert _read_config(journal_copy)["service_key_validation"]["plaud"]["valid"]
keys_shown = runner.invoke(settings_call.app, ["keys", "show"])
assert keys_shown.exit_code == 0
- assert "revai-token" not in keys_shown.stdout
+ assert "plaud-token" not in keys_shown.stdout
- cleared = runner.invoke(settings_call.app, ["keys", "clear", "REVAI_ACCESS_TOKEN"])
- _assert_json(cleared, {"env_var": "REVAI_ACCESS_TOKEN", "cleared": True})
- assert _read_config(journal_copy)["env"]["REVAI_ACCESS_TOKEN"] == ""
+ cleared = runner.invoke(settings_call.app, ["keys", "clear", "PLAUD_ACCESS_TOKEN"])
+ _assert_json(cleared, {"env_var": "PLAUD_ACCESS_TOKEN", "cleared": True})
+ assert _read_config(journal_copy)["env"]["PLAUD_ACCESS_TOKEN"] == ""
before = (journal_copy / "config" / "journal.json").read_text(encoding="utf-8")
validate = runner.invoke(settings_call.app, ["keys", "validate"])
@@ -210,25 +202,16 @@ def test_transcribe_setters(journal_copy: Path) -> None:
)
assert transcribe_bad.exit_code == 1
assert transcribe_bad.stderr == (
- "Invalid backend: invalid. Must be one of: gemini, parakeet, parakeet-cpp, revai\n"
+ "Invalid backend: invalid. Must be one of: parakeet, parakeet-cpp\n"
)
transcribe_set = runner.invoke(
settings_call.app,
- ["transcribe", "set-backend", "gemini"],
+ ["transcribe", "set-backend", "parakeet-cpp"],
)
assert transcribe_set.exit_code == 0
- assert json.loads(transcribe_set.stdout)["backend"] == "gemini"
- assert _read_config(journal_copy)["transcribe"]["backend"] == "gemini"
-
- options = runner.invoke(
- settings_call.app,
- ["transcribe", "set", "--no-enrich", "--no-noise-upgrade"],
- )
- assert options.exit_code == 0
- payload = json.loads(options.stdout)
- assert payload["enrich"] is False
- assert payload["noise_upgrade"] is False
+ assert json.loads(transcribe_set.stdout)["backend"] == "parakeet-cpp"
+ assert _read_config(journal_copy)["transcribe"]["backend"] == "parakeet-cpp"
def test_processing_show_uses_effective_settings_endpoint(
diff --git a/tests/test_supervisor_parakeet.py b/tests/test_supervisor_parakeet.py
index 3a11aa68e..7049fc1b9 100644
--- a/tests/test_supervisor_parakeet.py
+++ b/tests/test_supervisor_parakeet.py
@@ -421,23 +421,20 @@ def test_start_parakeet_server_explicit_cpu_skips_auto_placement(
"machine",
"backend",
"available_bytes",
- "google_key",
"confidential",
"confidential_audio",
"local_backend",
"expected",
),
[
- ("linux", "x86_64", None, 5 * 1024**3, False, False, True, "parakeet", True),
- ("linux", "x86_64", None, 3 * 1024**3, False, False, True, "parakeet", False),
- ("linux", "x86_64", None, 3 * 1024**3, True, False, True, "parakeet", False),
+ ("linux", "x86_64", None, 5 * 1024**3, False, True, "parakeet", True),
+ ("linux", "x86_64", None, 3 * 1024**3, False, True, "parakeet", False),
(
"linux",
"x86_64",
"parakeet",
3 * 1024**3,
False,
- False,
True,
"parakeet",
True,
@@ -448,48 +445,22 @@ def test_start_parakeet_server_explicit_cpu_skips_auto_placement(
"parakeet-cpp",
3 * 1024**3,
False,
- False,
True,
"parakeet",
True,
),
- (
- "linux",
- "x86_64",
- "revai",
- 5 * 1024**3,
- False,
- False,
- True,
- "parakeet",
- False,
- ),
- (
- "linux",
- "x86_64",
- "gemini",
- 5 * 1024**3,
- False,
- False,
- True,
- "parakeet",
- False,
- ),
- ("linux", "aarch64", None, 5 * 1024**3, False, False, True, "parakeet", True),
+ ("linux", "aarch64", None, 5 * 1024**3, False, True, "parakeet", True),
(
"linux",
"aarch64",
"parakeet",
3 * 1024**3,
False,
- False,
True,
"parakeet",
True,
),
- ("darwin", "arm64", None, 5 * 1024**3, False, False, True, "parakeet", False),
- ("linux", "x86_64", "gemini", 3 * 1024**3, True, True, True, "parakeet", False),
- ("linux", "x86_64", "revai", 3 * 1024**3, False, True, True, "parakeet", False),
+ ("darwin", "arm64", None, 5 * 1024**3, False, True, "parakeet", False),
(
"linux",
"x86_64",
@@ -497,13 +468,12 @@ def test_start_parakeet_server_explicit_cpu_skips_auto_placement(
3 * 1024**3,
True,
True,
- True,
"parakeet",
True,
),
- ("linux", "x86_64", None, 3 * 1024**3, True, True, True, "parakeet", False),
- ("linux", "x86_64", None, 3 * 1024**3, True, True, False, "parakeet", True),
- ("linux", "x86_64", None, 3 * 1024**3, True, True, True, None, False),
+ ("linux", "x86_64", None, 3 * 1024**3, True, True, "parakeet", False),
+ ("linux", "x86_64", None, 3 * 1024**3, True, False, "parakeet", True),
+ ("linux", "x86_64", None, 3 * 1024**3, True, True, None, False),
],
)
def test_linux_stt_uses_parakeet_cpp_truth_table(
@@ -512,7 +482,6 @@ def test_linux_stt_uses_parakeet_cpp_truth_table(
machine: str,
backend: str | None,
available_bytes: int,
- google_key: bool,
confidential: bool,
confidential_audio: bool,
local_backend: str | None,
@@ -534,10 +503,7 @@ def test_linux_stt_uses_parakeet_cpp_truth_table(
"solstone.think.services.spp.confidential_provenance",
lambda: {"enabled_at": "2026-05-24T00:00:00Z"} if confidential else None,
)
- if google_key:
- monkeypatch.setenv("GOOGLE_API_KEY", "test-key")
- else:
- monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
+ monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
assert supervisor.linux_stt_uses_parakeet_cpp() is expected
@@ -569,7 +535,11 @@ def test_start_parakeet_server_early_returns_for_other_backend(
monkeypatch.setattr(
supervisor,
"read_journal_config",
- lambda: {"transcribe": {"backend": "gemini"}},
+ lambda: {"transcribe": {"backend": "confidential"}},
+ )
+ monkeypatch.setattr(
+ "solstone.think.services.spp.confidential_provenance",
+ lambda: {"enabled_at": "2026-05-24T00:00:00Z"},
)
assert supervisor.start_parakeet_server() is None
diff --git a/tests/test_thinking_call_parity.py b/tests/test_thinking_call_parity.py
index e5571e006..b95544022 100644
--- a/tests/test_thinking_call_parity.py
+++ b/tests/test_thinking_call_parity.py
@@ -24,7 +24,6 @@ API_ENV_KEYS = (
"GOOGLE_API_KEY",
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
- "REVAI_ACCESS_TOKEN",
"PLAUD_ACCESS_TOKEN",
)
diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py
index e446aec17..493986e92 100644
--- a/tests/test_transcribe.py
+++ b/tests/test_transcribe.py
@@ -517,7 +517,7 @@ def test_process_audio_failed_embeddings_write_emits_failed_event(tmp_path):
),
patch(
"solstone.observe.transcribe.main.get_config",
- return_value={"transcribe": {"preserve_all": False, "enrich": False}},
+ return_value={"transcribe": {"preserve_all": False}},
),
patch(
"solstone.observe.transcribe.main.stt_transcribe", return_value=statements
@@ -586,7 +586,7 @@ def test_process_audio_embeddings_write_round_trips_without_lock(tmp_path):
),
patch(
"solstone.observe.transcribe.main.get_config",
- return_value={"transcribe": {"preserve_all": False, "enrich": False}},
+ return_value={"transcribe": {"preserve_all": False}},
),
patch(
"solstone.observe.transcribe.main.stt_transcribe", return_value=statements
@@ -658,7 +658,7 @@ def test_process_audio_records_analyzed_processing(tmp_path):
),
patch(
"solstone.observe.transcribe.main.get_config",
- return_value={"transcribe": {"preserve_all": False, "enrich": False}},
+ return_value={"transcribe": {"preserve_all": False}},
),
patch(
"solstone.observe.transcribe.main.stt_transcribe", return_value=statements
@@ -728,7 +728,7 @@ def test_process_audio_silent_filtered_writes_empty_record(tmp_path):
),
patch(
"solstone.observe.transcribe.main.get_config",
- return_value={"transcribe": {"preserve_all": False, "enrich": False}},
+ return_value={"transcribe": {"preserve_all": False}},
),
patch("solstone.observe.transcribe.main.stt_transcribe", return_value=[]),
patch(
@@ -780,7 +780,7 @@ def test_process_audio_diarizer_failure_is_fail_soft(tmp_path):
),
patch(
"solstone.observe.transcribe.main.get_config",
- return_value={"transcribe": {"preserve_all": False, "enrich": False}},
+ return_value={"transcribe": {"preserve_all": False}},
),
patch(
"solstone.observe.transcribe.main.stt_transcribe", return_value=statements
@@ -810,6 +810,117 @@ def test_process_audio_diarizer_failure_is_fail_soft(tmp_path):
assert "speaker" not in json.loads(lines[1])
+def test_process_audio_diarizes_parakeet_cpp_when_overlap_meets_threshold(tmp_path):
+ from solstone.observe.transcribe.main import process_audio
+
+ raw_path = (
+ tmp_path / "chronicle" / "20260416" / "default" / "120000_300" / "audio.m4a"
+ )
+ raw_path.parent.mkdir(parents=True)
+ raw_path.touch()
+ audio_buffer = np.zeros(10 * SAMPLE_RATE, dtype=np.float32)
+ vad_result = VadResult(
+ duration=10.0,
+ speech_duration=5.0,
+ has_speech=True,
+ speech_segments=[(1.0, 6.0)],
+ )
+ statements = [{"id": 0, "start": 0.0, "end": 1.0, "text": "hi"}]
+ backend_module = MagicMock()
+ backend_module.get_model_info.return_value = {
+ "model": "unit",
+ "device": "cpu",
+ "compute_type": "int8",
+ }
+ logprobs = np.zeros((589, 7), dtype=np.float32)
+
+ with (
+ patch(
+ "solstone.observe.transcribe.main.get_journal",
+ return_value=str(raw_path.parents[4]),
+ ),
+ patch(
+ "solstone.observe.transcribe.main.get_config",
+ return_value={"transcribe": {"preserve_all": False}},
+ ),
+ patch(
+ "solstone.observe.transcribe.main.stt_transcribe", return_value=statements
+ ),
+ patch(
+ "solstone.observe.transcribe.main.get_backend", return_value=backend_module
+ ),
+ patch("solstone.observe.transcribe.main._embed_statements", return_value=None),
+ patch(
+ "solstone.observe.transcribe.overlap.compute_overlap_and_logprobs",
+ return_value=(0.5, logprobs),
+ ),
+ patch(
+ "solstone.observe.transcribe.diarize.diarize_auto_k",
+ return_value=[2],
+ ) as mock_diarize,
+ patch("solstone.observe.transcribe.main.callosum_send"),
+ ):
+ process_audio(raw_path, audio_buffer, vad_result, {}, backend="parakeet-cpp")
+
+ mock_diarize.assert_called_once()
+ kwargs = mock_diarize.call_args.kwargs
+ assert kwargs["avg_log_probs"] is logprobs
+ assert kwargs["audio"] is audio_buffer
+
+ jsonl_path = raw_path.with_suffix(".jsonl")
+ lines = jsonl_path.read_text(encoding="utf-8").splitlines()
+ assert json.loads(lines[1])["speaker"] == 2
+
+
+def test_legacy_transcript_enrichment_fields_remain_reader_compatible(tmp_path):
+ from solstone.observe.hear import load_transcript
+
+ jsonl_path = (
+ tmp_path / "chronicle" / "20260416" / "default" / "120000_300" / "audio.jsonl"
+ )
+ jsonl_path.parent.mkdir(parents=True)
+ jsonl_path.write_text(
+ "\n".join(
+ [
+ json.dumps(
+ {
+ "raw": "audio.m4a",
+ "topics": ["planning", "shipping"],
+ "setting": "office",
+ }
+ ),
+ json.dumps(
+ {
+ "start": "00:00:02",
+ "text": "raw transcript",
+ "corrected": "corrected transcript",
+ "emotion": "excited",
+ }
+ ),
+ ]
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+ metadata, entries, formatted_text = load_transcript(jsonl_path)
+
+ assert metadata["topics"] == ["planning", "shipping"]
+ assert metadata["setting"] == "office"
+ assert entries == [
+ {
+ "start": "00:00:02",
+ "text": "raw transcript",
+ "corrected": "corrected transcript",
+ "emotion": "excited",
+ }
+ ]
+ assert "Topics: planning, shipping" in formatted_text
+ assert "Setting: office" in formatted_text
+ assert "[00:00:02] corrected transcript *(excited)*" in formatted_text
+ assert "raw transcript" not in formatted_text
+
+
class TestJSONLFormat:
"""Test JSONL output format."""
diff --git a/tests/test_transcribe_cli.py b/tests/test_transcribe_cli.py
index 58fa8e045..c1f868918 100644
--- a/tests/test_transcribe_cli.py
+++ b/tests/test_transcribe_cli.py
@@ -50,7 +50,6 @@ def test_main_accepts_journal_relative_path(tmp_path, monkeypatch):
return_value="090000_300",
),
patch("solstone.observe.transcribe.main._build_base_event", return_value={}),
- patch("solstone.think.entities.load_recent_entity_names", return_value=[]),
patch(
"solstone.observe.transcribe.main.read_available_bytes",
return_value=8 * 1024**3,
@@ -147,7 +146,6 @@ def test_all_batch_processes_unprocessed_skips_transcribed(
with (
patch("solstone.observe.transcribe.main._process_one", mock_process_one),
- patch("solstone.think.entities.load_recent_entity_names", return_value=[]),
patch(
"solstone.observe.transcribe.main.read_available_bytes",
return_value=8 * 1024**3,
@@ -185,7 +183,6 @@ def test_all_redo_reprocesses_transcribed(tmp_path, monkeypatch):
with (
patch("solstone.observe.transcribe.main._process_one", mock_process_one),
- patch("solstone.think.entities.load_recent_entity_names", return_value=[]),
patch(
"solstone.observe.transcribe.main.read_available_bytes",
return_value=8 * 1024**3,
@@ -211,25 +208,24 @@ def test_all_and_audio_path_mutually_exclusive(tmp_path, monkeypatch):
monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
monkeypatch.setattr("sys.argv", ["sol transcribe", "--all", "some/audio.wav"])
- with patch("solstone.think.entities.load_recent_entity_names", return_value=[]):
- from solstone.observe.transcribe.main import main
+ from solstone.observe.transcribe.main import main
- with (
- patch(
- "solstone.observe.transcribe.main.read_available_bytes",
- return_value=8 * 1024**3,
- ),
- patch(
- "solstone.observe.transcribe.main.stt_local_floor_bytes",
- return_value=4 * 1024**3,
- ),
- patch(
- "solstone.observe.transcribe.main.local_stt_backend",
- return_value="parakeet",
- ),
- ):
- with pytest.raises(SystemExit):
- main()
+ with (
+ patch(
+ "solstone.observe.transcribe.main.read_available_bytes",
+ return_value=8 * 1024**3,
+ ),
+ patch(
+ "solstone.observe.transcribe.main.stt_local_floor_bytes",
+ return_value=4 * 1024**3,
+ ),
+ patch(
+ "solstone.observe.transcribe.main.local_stt_backend",
+ return_value="parakeet",
+ ),
+ ):
+ with pytest.raises(SystemExit):
+ main()
def test_neither_all_nor_audio_path_errors(tmp_path, monkeypatch):
@@ -237,28 +233,57 @@ def test_neither_all_nor_audio_path_errors(tmp_path, monkeypatch):
monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
monkeypatch.setattr("sys.argv", ["sol transcribe"])
- with patch("solstone.think.entities.load_recent_entity_names", return_value=[]):
- from solstone.observe.transcribe.main import main
+ from solstone.observe.transcribe.main import main
- with pytest.raises(SystemExit):
- main()
+ with pytest.raises(SystemExit):
+ main()
-def test_resolve_default_backend_auto_switches_to_gemini(monkeypatch):
- transcribe_main = importlib.import_module("solstone.observe.transcribe.main")
+def test_main_google_key_decoy_below_floor_surfaces_local_requirement(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
+) -> None:
+ journal = tmp_path / "journal"
+ audio_file = (
+ journal / "chronicle" / "20260201" / "default" / "090000_300" / "audio.wav"
+ )
+ audio_file.parent.mkdir(parents=True)
+ audio_file.write_bytes(b"audio")
+ monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal))
monkeypatch.setenv("GOOGLE_API_KEY", "test-key")
- monkeypatch.setattr(transcribe_main, "read_available_bytes", lambda: 2 * 1024**3)
- monkeypatch.setattr(transcribe_main, "stt_local_floor_bytes", lambda: 4 * 1024**3)
- monkeypatch.setattr(transcribe_main, "local_stt_backend", lambda: "parakeet")
+ monkeypatch.setattr("sys.argv", ["sol transcribe", str(audio_file)])
+
+ from solstone.observe.transcribe.main import main
+
+ with (
+ patch(
+ "solstone.observe.transcribe.main.read_available_bytes",
+ return_value=2 * 1024**3,
+ ),
+ patch(
+ "solstone.observe.transcribe.main.stt_local_floor_bytes",
+ return_value=4 * 1024**3,
+ ),
+ patch(
+ "solstone.observe.transcribe.main.local_stt_backend",
+ return_value="parakeet",
+ ),
+ patch("solstone.observe.transcribe.main._process_one") as mock_process_one,
+ ):
+ with caplog.at_level(logging.ERROR):
+ with pytest.raises(SystemExit) as exc_info:
+ main()
- assert transcribe_main.resolve_default_backend(_args(), {}) == "gemini"
+ assert exc_info.value.code == 1
+ assert audio_file.exists()
+ mock_process_one.assert_not_called()
+ assert "Local transcription needs about 4 GB" in caplog.text
def test_resolve_default_backend_auto_selects_confidential_under_lane(monkeypatch):
transcribe_main = importlib.import_module("solstone.observe.transcribe.main")
- monkeypatch.setenv("GOOGLE_API_KEY", "test-key")
+ monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
monkeypatch.setattr(transcribe_main, "read_available_bytes", lambda: 1 * 1024**3)
monkeypatch.setattr(transcribe_main, "stt_local_floor_bytes", lambda: 4 * 1024**3)
monkeypatch.setattr(transcribe_main, "local_stt_backend", lambda: "parakeet")
@@ -273,7 +298,7 @@ def test_resolve_default_backend_auto_selects_confidential_under_lane(monkeypatc
def test_resolve_default_backend_explicit_local_wins_under_lane(monkeypatch):
transcribe_main = importlib.import_module("solstone.observe.transcribe.main")
- monkeypatch.setenv("GOOGLE_API_KEY", "test-key")
+ monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
monkeypatch.setattr(transcribe_main, "read_available_bytes", lambda: 1 * 1024**3)
monkeypatch.setattr(transcribe_main, "stt_local_floor_bytes", lambda: 4 * 1024**3)
monkeypatch.setattr(transcribe_main, "local_stt_backend", lambda: "parakeet")
@@ -291,7 +316,7 @@ def test_resolve_default_backend_explicit_local_wins_under_lane(monkeypatch):
def test_resolve_default_backend_confidential_fallback_never_cloud(monkeypatch, caplog):
transcribe_main = importlib.import_module("solstone.observe.transcribe.main")
- monkeypatch.setenv("GOOGLE_API_KEY", "test-key")
+ monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
monkeypatch.setattr(transcribe_main, "read_available_bytes", lambda: 1 * 1024**3)
monkeypatch.setattr(transcribe_main, "stt_local_floor_bytes", lambda: 4 * 1024**3)
monkeypatch.setattr(transcribe_main, "local_stt_backend", lambda: "parakeet")
@@ -348,19 +373,30 @@ def test_resolve_default_backend_warns_but_honors_explicit_local(monkeypatch, ca
assert "Free memory is below 4 GB" in caplog.text
-def test_resolve_default_backend_honors_config_backend(monkeypatch):
+def test_resolve_default_backend_stale_config_routes_to_confidential_under_lane(
+ monkeypatch, caplog
+):
transcribe_main = importlib.import_module("solstone.observe.transcribe.main")
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
monkeypatch.setattr(transcribe_main, "read_available_bytes", lambda: 2 * 1024**3)
monkeypatch.setattr(transcribe_main, "stt_local_floor_bytes", lambda: 4 * 1024**3)
monkeypatch.setattr(transcribe_main, "local_stt_backend", lambda: "parakeet")
-
- assert (
- transcribe_main.resolve_default_backend(_args(), {"backend": "gemini"})
- == "gemini"
+ monkeypatch.setattr(
+ "solstone.think.services.spp.confidential_provenance",
+ lambda: {"enabled_at": "2026-05-24T00:00:00Z"},
)
+ with caplog.at_level(logging.WARNING):
+ backend = transcribe_main.resolve_default_backend(
+ _args(), {"backend": "removed-stt"}
+ )
+
+ assert backend == "confidential"
+ assert caplog.messages == [
+ "Configured STT backend 'removed-stt' is unavailable; treating it as unset"
+ ]
+
def test_resolve_default_backend_uses_parakeet_when_memory_fits(monkeypatch):
transcribe_main = importlib.import_module("solstone.observe.transcribe.main")
@@ -379,29 +415,26 @@ def test_resolve_default_backend_falls_back_when_configured_backend_is_removed(
transcribe_main = importlib.import_module("solstone.observe.transcribe.main")
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
- monkeypatch.setattr(transcribe_main, "read_available_bytes", lambda: 3 * 1024**3)
+ monkeypatch.setattr(transcribe_main, "read_available_bytes", lambda: 5 * 1024**3)
monkeypatch.setattr(transcribe_main, "stt_local_floor_bytes", lambda: 4 * 1024**3)
monkeypatch.setattr(transcribe_main, "local_stt_backend", lambda: "parakeet")
- assert (
- transcribe_main.resolve_default_backend(_args(), {"backend": "removed-local"})
- == "parakeet"
- )
- assert "unavailable" in caplog.text
+ with caplog.at_level(logging.WARNING):
+ backend = transcribe_main.resolve_default_backend(
+ _args(), {"backend": "removed-local"}
+ )
+
+ assert backend == "parakeet"
+ assert caplog.messages == [
+ "Configured STT backend 'removed-local' is unavailable; treating it as unset"
+ ]
def test_all_batch_reads_memory_once_and_reuses_default_backend(tmp_path, monkeypatch):
journal = _make_batch_journal(tmp_path)
config_dir = journal / "config"
config_dir.mkdir()
- (config_dir / "journal.json").write_text(
- json.dumps(
- {
- "identity": {"name": "Test"},
- "env": {"GOOGLE_API_KEY": "test-key"},
- }
- )
- )
+ (config_dir / "journal.json").write_text(json.dumps({"identity": {"name": "Test"}}))
monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal))
monkeypatch.setattr("sys.argv", ["sol transcribe", "--all", "--redo"])
calls = 0
@@ -409,13 +442,12 @@ def test_all_batch_reads_memory_once_and_reuses_default_backend(tmp_path, monkey
def fake_read_available_bytes():
nonlocal calls
calls += 1
- return 2 * 1024**3
+ return 5 * 1024**3
mock_process_one = MagicMock()
with (
patch("solstone.observe.transcribe.main._process_one", mock_process_one),
- patch("solstone.think.entities.load_recent_entity_names", return_value=[]),
patch(
"solstone.observe.transcribe.main.read_available_bytes",
fake_read_available_bytes,
@@ -435,4 +467,4 @@ def test_all_batch_reads_memory_once_and_reuses_default_backend(tmp_path, monkey
assert calls == 1
assert mock_process_one.call_count == 2
- assert {call.args[3] for call in mock_process_one.call_args_list} == {"gemini"}
+ assert {call.args[3] for call in mock_process_one.call_args_list} == {"parakeet"}
diff --git a/tests/test_transcribe_confidential.py b/tests/test_transcribe_confidential.py
index 89af334bb..00673477a 100644
--- a/tests/test_transcribe_confidential.py
+++ b/tests/test_transcribe_confidential.py
@@ -470,7 +470,6 @@ def test_process_one_builds_confidential_backend_config(tmp_path: Path) -> None:
argparse.Namespace(backend=None, cpu=False, model=None, redo=False),
{"backend": "confidential"},
"confidential",
- [],
)
assert captured == {"backend": "confidential", "backend_config": {}}
@@ -489,7 +488,7 @@ def test_process_one_routes_over_cap_confidential_audio_to_local(
int((CONFIDENTIAL_STT_MAX_AUDIO_SECONDS + 1) * SAMPLE_RATE),
dtype=np.float32,
)
- monkeypatch.setenv("GOOGLE_API_KEY", "test-key")
+ monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
def fake_process_audio(
_audio_path,
@@ -522,7 +521,6 @@ def test_process_one_routes_over_cap_confidential_audio_to_local(
argparse.Namespace(backend=None, cpu=False, model=None, redo=False),
{},
"confidential",
- [],
)
assert captured == {"backend": "parakeet", "backend_config": {}}
diff --git a/tests/test_transcribe_empty_result.py b/tests/test_transcribe_empty_result.py
index 036c523f7..fafbc2c9f 100644
--- a/tests/test_transcribe_empty_result.py
+++ b/tests/test_transcribe_empty_result.py
@@ -121,7 +121,7 @@ def test_process_audio_speech_writes_sound_tags_and_keeps_audio(
with (
patch(
"solstone.observe.transcribe.main.get_config",
- return_value={"transcribe": {"preserve_all": False, "enrich": False}},
+ return_value={"transcribe": {"preserve_all": False}},
),
patch(
"solstone.observe.transcribe.main.get_journal",
@@ -372,7 +372,6 @@ def test_vad_no_speech_preserve_path_writes_empty_record(
args,
{"preserve_all": True},
"parakeet",
- [],
)
assert raw_path.exists()
@@ -411,7 +410,6 @@ def test_vad_no_speech_with_tags_writes_empty_jsonl_then_deletes_audio(
args,
{"preserve_all": False},
"parakeet",
- [],
)
jsonl_path = raw_path.with_suffix(".jsonl")
@@ -446,7 +444,6 @@ def test_vad_no_speech_non_salient_writes_empty_record_then_deletes_audio(
args,
{"preserve_all": False},
"parakeet",
- [],
)
assert not raw_path.exists()
@@ -485,7 +482,6 @@ def test_vad_no_speech_write_failure_preserves_audio(
args,
{"preserve_all": False},
"parakeet",
- [],
)
assert raw_path.exists()
@@ -519,7 +515,6 @@ def test_vad_no_speech_tagger_raise_writes_empty_record_without_tags(
args,
{"preserve_all": False},
"parakeet",
- [],
)
assert not raw_path.exists()
@@ -605,7 +600,6 @@ def test_filtered_raw_deletion_leaves_terminal_processing_record(
args,
{"preserve_all": False},
"parakeet",
- [],
)
jsonl_path = raw_path.with_suffix(".jsonl")
diff --git a/tests/test_transcribe_gemini.py b/tests/test_transcribe_gemini.py
deleted file mode 100644
index 02b55dede..000000000
--- a/tests/test_transcribe_gemini.py
+++ /dev/null
@@ -1,560 +0,0 @@
-# SPDX-License-Identifier: AGPL-3.0-only
-# Copyright (c) 2026 sol pbc
-
-"""Tests for the Gemini STT backend."""
-
-import json
-from unittest.mock import Mock, patch
-
-import numpy as np
-import pytest
-
-from solstone.observe.transcribe.gemini import (
- _build_chunk_contents,
- _extract_segments,
- _find_segment_for_timestamp,
- _format_timestamp,
- _normalize_chunked_segments,
- _parse_speaker,
- _parse_timestamp,
- get_model_info,
- transcribe,
-)
-from solstone.observe.utils import SAMPLE_RATE
-from solstone.observe.vad import VadResult
-from solstone.think.models import DEFAULT_PROVIDER_TIMEOUT_S, IncompleteJSONError
-
-
-class TestFormatTimestamp:
- """Tests for _format_timestamp function."""
-
- def test_basic_formatting(self):
- """Basic timestamp formatting."""
- assert _format_timestamp(0) == "00:00"
- assert _format_timestamp(5) == "00:05"
- assert _format_timestamp(65) == "01:05"
- assert _format_timestamp(3600) == "60:00"
-
- def test_minutes_and_seconds(self):
- """Minutes and seconds formatting."""
- assert _format_timestamp(90) == "01:30"
- assert _format_timestamp(125) == "02:05"
- assert _format_timestamp(599) == "09:59"
-
-
-class TestParseTimestamp:
- """Tests for _parse_timestamp function."""
-
- def test_mm_ss_format(self):
- """MM:SS format."""
- assert _parse_timestamp("01:23") == 83.0
- assert _parse_timestamp("0:05") == 5.0
- assert _parse_timestamp("10:30") == 630.0
-
- def test_just_seconds(self):
- """Just seconds."""
- assert _parse_timestamp("5") == 5.0
- assert _parse_timestamp("123") == 123.0
-
- def test_invalid_returns_none(self):
- """Invalid timestamps return None."""
- assert _parse_timestamp("") is None
- assert _parse_timestamp(None) is None
- assert _parse_timestamp("invalid") is None
-
- def test_whitespace_stripped(self):
- """Whitespace is stripped."""
- assert _parse_timestamp(" 01:23 ") == 83.0
-
-
-class TestParseSpeaker:
- """Tests for _parse_speaker function."""
-
- def test_speaker_n_format(self):
- """Speaker N format."""
- assert _parse_speaker("Speaker 1") == 1
- assert _parse_speaker("Speaker 2") == 2
- assert _parse_speaker("speaker 3") == 3 # Case insensitive
-
- def test_just_number(self):
- """Just a number."""
- assert _parse_speaker("1") == 1
- assert _parse_speaker("2") == 2
-
- def test_integer_input(self):
- """Integer input."""
- assert _parse_speaker(1) == 1
- assert _parse_speaker(2) == 2
-
- def test_zero_and_negative_invalid(self):
- """Zero and negative speaker IDs are invalid."""
- assert _parse_speaker(0) is None
- assert _parse_speaker(-1) is None
- assert _parse_speaker("0") is None
-
- def test_none_returns_none(self):
- """None input returns None."""
- assert _parse_speaker(None) is None
-
- def test_unparseable_returns_none(self):
- """Unparseable strings return None."""
- assert _parse_speaker("John") is None
- assert _parse_speaker("unknown") is None
- assert _parse_speaker("") is None
-
-
-class TestFindSegmentForTimestamp:
- """Tests for _find_segment_for_timestamp function."""
-
- def test_timestamp_inside_segment(self):
- """Timestamp inside a segment returns that segment."""
- segments = [(0.0, 10.0), (15.0, 25.0), (30.0, 40.0)]
- assert _find_segment_for_timestamp(5.0, segments) == (0.0, 10.0)
- assert _find_segment_for_timestamp(20.0, segments) == (15.0, 25.0)
- assert _find_segment_for_timestamp(35.0, segments) == (30.0, 40.0)
-
- def test_timestamp_at_boundary(self):
- """Timestamp at segment boundary returns that segment."""
- segments = [(0.0, 10.0), (15.0, 25.0)]
- assert _find_segment_for_timestamp(0.0, segments) == (0.0, 10.0)
- assert _find_segment_for_timestamp(10.0, segments) == (0.0, 10.0)
- assert _find_segment_for_timestamp(15.0, segments) == (15.0, 25.0)
-
- def test_timestamp_in_gap_returns_nearest(self):
- """Timestamp in gap returns nearest segment."""
- segments = [(0.0, 10.0), (20.0, 30.0)]
- # 12 is closer to segment ending at 10 than starting at 20
- assert _find_segment_for_timestamp(12.0, segments) == (0.0, 10.0)
- # 18 is closer to segment starting at 20
- assert _find_segment_for_timestamp(18.0, segments) == (20.0, 30.0)
-
- def test_timestamp_before_first(self):
- """Timestamp before first segment returns first."""
- segments = [(10.0, 20.0), (30.0, 40.0)]
- assert _find_segment_for_timestamp(5.0, segments) == (10.0, 20.0)
-
- def test_timestamp_after_last(self):
- """Timestamp after last segment returns last."""
- segments = [(0.0, 10.0), (15.0, 25.0)]
- assert _find_segment_for_timestamp(50.0, segments) == (15.0, 25.0)
-
-
-class TestNormalizeChunkedSegments:
- """Tests for _normalize_chunked_segments function."""
-
- def test_parses_mm_ss_timestamps(self):
- """Parses MM:SS timestamps from Gemini output."""
- segments = [
- {"start": "00:05", "speaker": "Speaker 1", "text": "Hello"},
- {"start": "00:12", "speaker": "Speaker 2", "text": "Hi there"},
- ]
- speech_segments = [(0.0, 10.0), (10.0, 20.0)]
-
- statements = _normalize_chunked_segments(segments, speech_segments)
-
- assert len(statements) == 2
- assert statements[0]["start"] == 5.0
- assert statements[0]["text"] == "Hello"
- assert statements[0]["speaker"] == 1
- assert statements[1]["start"] == 12.0
-
- def test_clamps_timestamp_to_valid_range(self):
- """Clamps timestamps to valid range."""
- segments = [
- {"start": "10:00", "speaker": "Speaker 1", "text": "Way too late"},
- ]
- speech_segments = [(0.0, 10.0), (15.0, 25.0)]
-
- statements = _normalize_chunked_segments(segments, speech_segments)
-
- # Should clamp to max_time (25.0)
- assert statements[0]["start"] == 25.0
-
- def test_fallback_on_invalid_timestamp(self):
- """Falls back to first segment on invalid timestamp."""
- segments = [
- {"start": "invalid", "speaker": "Speaker 1", "text": "Test"},
- ]
- speech_segments = [(5.0, 15.0), (20.0, 30.0)]
-
- statements = _normalize_chunked_segments(segments, speech_segments)
-
- # Falls back to first segment start
- assert statements[0]["start"] == 5.0
-
- def test_assigns_end_from_containing_segment(self):
- """End time comes from the segment containing the start."""
- segments = [
- {"start": "00:22", "speaker": "Speaker 1", "text": "In second segment"},
- ]
- speech_segments = [(0.0, 10.0), (20.0, 30.0)]
-
- statements = _normalize_chunked_segments(segments, speech_segments)
-
- assert statements[0]["start"] == 22.0
- assert statements[0]["end"] == 30.0 # End of containing segment
-
- def test_empty_text_dropped(self):
- """Segments with empty text are dropped."""
- segments = [
- {"start": "00:05", "text": "First"},
- {"start": "00:10", "text": ""},
- {"start": "00:15", "text": " "},
- {"start": "00:20", "text": "Last"},
- ]
- speech_segments = [(0.0, 30.0)]
-
- statements = _normalize_chunked_segments(segments, speech_segments)
-
- assert len(statements) == 2
- assert statements[0]["text"] == "First"
- assert statements[1]["text"] == "Last"
-
- def test_sequential_ids(self):
- """Statements get sequential IDs."""
- segments = [
- {"start": "00:05", "text": "First"},
- {"start": "00:10", "text": "Second"},
- {"start": "00:15", "text": "Third"},
- ]
- speech_segments = [(0.0, 30.0)]
-
- statements = _normalize_chunked_segments(segments, speech_segments)
-
- assert [s["id"] for s in statements] == [1, 2, 3]
-
- def test_empty_segments(self):
- """Empty segments list."""
- statements = _normalize_chunked_segments([], [(0.0, 10.0)])
- assert statements == []
-
-
-class TestBuildChunkContents:
- """Tests for _build_chunk_contents function."""
-
- def test_basic_chunking(self):
- """Basic chunk content building."""
- audio = np.zeros(16000 * 30, dtype=np.float32) # 30s of audio
- speech_segments = [(0.0, 10.0), (15.0, 25.0)]
-
- contents = _build_chunk_contents(audio, 16000, speech_segments, "Test prompt")
-
- # Should have: prompt + (label + audio) * 2 = 5 items
- assert len(contents) == 5
- assert contents[0] == "Test prompt"
- assert contents[1] == "Clip starting at 00:00 (10s):"
- assert contents[3] == "Clip starting at 00:15 (10s):"
-
- def test_duration_in_label(self):
- """Label includes duration."""
- audio = np.zeros(16000 * 30, dtype=np.float32)
- speech_segments = [(5.0, 12.0)] # 7 second clip
-
- contents = _build_chunk_contents(audio, 16000, speech_segments, "Prompt")
-
- assert contents[1] == "Clip starting at 00:05 (7s):"
-
- def test_skips_empty_chunks(self):
- """Empty audio chunks are skipped."""
- audio = np.zeros(16000 * 10, dtype=np.float32)
- # Second segment has start == end (empty)
- speech_segments = [(0.0, 5.0), (5.0, 5.0), (7.0, 10.0)]
-
- contents = _build_chunk_contents(audio, 16000, speech_segments, "Prompt")
-
- # Should have: prompt + 2 valid chunks * 2 = 5 items
- assert len(contents) == 5
-
-
-class TestExtractSegments:
- """Tests for _extract_segments strict wrapper parsing."""
-
- def test_expected_dict_wrapper(self):
- """Standard {"segments": [...]} response."""
- segs = [{"start": "00:00", "speaker": "Speaker 1", "text": "Hi"}]
- assert _extract_segments({"segments": segs}) == segs
-
- def test_bare_list_raises(self):
- """Bare list is rejected."""
- segs = [{"start": "00:00", "speaker": "Speaker 1", "text": "Hi"}]
- with pytest.raises(RuntimeError):
- _extract_segments(segs)
-
- def test_alternate_key_raises(self):
- """Alternate wrapper key is rejected."""
- segs = [{"start": "00:00", "text": "Hi"}]
- with pytest.raises(RuntimeError):
- _extract_segments({"transcript": segs})
-
- def test_array_wrapped_dict_raises(self):
- """Array-wrapped dict is rejected."""
- segs = [{"start": "00:00", "speaker": "Speaker 1", "text": "Hi"}]
- with pytest.raises(RuntimeError):
- _extract_segments([{"segments": segs}])
-
- def test_empty_segments(self):
- """Empty segments list in dict."""
- assert _extract_segments({"segments": []}) == []
-
- def test_empty_bare_list_raises(self):
- """Empty bare list is rejected."""
- with pytest.raises(RuntimeError):
- _extract_segments([])
-
- def test_non_list_segments_value_raises(self):
- """Non-list segments value is rejected."""
- with pytest.raises(RuntimeError):
- _extract_segments({"segments": "not a list"})
-
- def test_unexpected_type_raises(self):
- """Unexpected type is rejected."""
- with pytest.raises(RuntimeError):
- _extract_segments("unexpected")
-
- def test_dict_with_no_segments_key_raises(self):
- """Dict without segments key is rejected."""
- with pytest.raises(RuntimeError):
- _extract_segments({"other": 1})
-
-
-class TestSplitRetry:
- def _statement(self, statement_id: int, start: float) -> dict:
- return {
- "id": statement_id,
- "start": start,
- "end": start + 1.0,
- "text": f"statement {statement_id}",
- "words": [],
- "speaker": "A",
- }
-
- def test_split_retry_on_truncation_returns_time_sorted_statements(
- self, monkeypatch
- ):
- audio = np.zeros(16000, dtype=np.float32)
- speech_segments = [(0.0, 1.0), (1.0, 2.0), (2.0, 3.0), (3.0, 4.0)]
- original_error = IncompleteJSONError("MAX_TOKENS", "partial")
- mock_once = Mock(
- side_effect=[
- original_error,
- [self._statement(7, 10.0)],
- [self._statement(3, 2.0)],
- ]
- )
- monkeypatch.setattr(
- "solstone.observe.transcribe.gemini._transcribe_once", mock_once
- )
-
- statements = transcribe(audio, 16000, {}, speech_segments)
-
- assert [s["start"] for s in statements] == [2.0, 10.0]
- assert [s["id"] for s in statements] == [1, 2]
-
- def test_split_retry_only_attempts_once_total_three_calls(self, monkeypatch):
- audio = np.zeros(16000, dtype=np.float32)
- speech_segments = [(0.0, 1.0), (1.0, 2.0)]
- original_error = IncompleteJSONError("MAX_TOKENS", "partial")
- mock_once = Mock(
- side_effect=[
- original_error,
- [self._statement(1, 0.0)],
- [self._statement(2, 1.0)],
- ]
- )
- monkeypatch.setattr(
- "solstone.observe.transcribe.gemini._transcribe_once", mock_once
- )
-
- transcribe(audio, 16000, {}, speech_segments)
-
- assert mock_once.call_count == 3
-
- def test_split_retry_generate_calls_keep_default_timeout(self, monkeypatch):
- audio = np.zeros(16000, dtype=np.float32)
- speech_segments = [(0.0, 1.0), (1.0, 2.0)]
- timeouts: list[float | None] = []
- response = json.dumps(
- {
- "segments": [
- {
- "start": "00:00",
- "speaker": "Speaker 1",
- "text": "ok",
- }
- ]
- }
- )
-
- def fake_generate(**kwargs):
- timeouts.append(kwargs.get("timeout_s"))
- if len(timeouts) == 1:
- raise IncompleteJSONError("MAX_TOKENS", "partial")
- return response
-
- monkeypatch.setattr(
- "solstone.observe.transcribe.gemini._build_chunk_contents",
- lambda *_args: ["contents"],
- )
- monkeypatch.setattr(
- "solstone.observe.transcribe.gemini.generate",
- fake_generate,
- )
-
- transcribe(audio, 16000, {}, speech_segments)
-
- assert timeouts == [
- DEFAULT_PROVIDER_TIMEOUT_S,
- DEFAULT_PROVIDER_TIMEOUT_S,
- DEFAULT_PROVIDER_TIMEOUT_S,
- ]
-
- def test_split_retry_half_b_failure_raises_original(self, monkeypatch):
- audio = np.zeros(16000, dtype=np.float32)
- speech_segments = [(0.0, 1.0), (1.0, 2.0)]
- original_error = IncompleteJSONError("MAX_TOKENS", "partial")
- mock_once = Mock(
- side_effect=[
- original_error,
- [self._statement(1, 0.0)],
- RuntimeError("half b failed"),
- ]
- )
- monkeypatch.setattr(
- "solstone.observe.transcribe.gemini._transcribe_once", mock_once
- )
-
- with pytest.raises(IncompleteJSONError) as exc_info:
- transcribe(audio, 16000, {}, speech_segments)
-
- assert exc_info.value is original_error
- assert mock_once.call_count == 3
-
- def test_split_retry_half_b_truncation_raises_original(self, monkeypatch):
- audio = np.zeros(16000, dtype=np.float32)
- speech_segments = [(0.0, 1.0), (1.0, 2.0)]
- original_error = IncompleteJSONError("MAX_TOKENS", "partial")
- half_error = IncompleteJSONError("MAX_TOKENS", "half")
- mock_once = Mock(
- side_effect=[
- original_error,
- [self._statement(1, 0.0)],
- half_error,
- ]
- )
- monkeypatch.setattr(
- "solstone.observe.transcribe.gemini._transcribe_once", mock_once
- )
-
- with pytest.raises(IncompleteJSONError) as exc_info:
- transcribe(audio, 16000, {}, speech_segments)
-
- assert exc_info.value is original_error
- assert mock_once.call_count == 3
-
- def test_no_split_when_fewer_than_two_chunks(self, monkeypatch):
- audio = np.zeros(16000, dtype=np.float32)
- speech_segments = [(0.0, 1.0)]
- original_error = IncompleteJSONError("MAX_TOKENS", "partial")
- mock_once = Mock(side_effect=[original_error])
- monkeypatch.setattr(
- "solstone.observe.transcribe.gemini._transcribe_once", mock_once
- )
-
- with pytest.raises(IncompleteJSONError) as exc_info:
- transcribe(audio, 16000, {}, speech_segments)
-
- assert exc_info.value is original_error
- assert mock_once.call_count == 1
-
- def test_happy_path_no_retry(self, monkeypatch):
- audio = np.zeros(16000, dtype=np.float32)
- speech_segments = [(0.0, 1.0), (1.0, 2.0)]
- expected = [self._statement(1, 0.0)]
- mock_once = Mock(return_value=expected)
- monkeypatch.setattr(
- "solstone.observe.transcribe.gemini._transcribe_once", mock_once
- )
-
- statements = transcribe(audio, 16000, {}, speech_segments)
-
- assert statements == expected
- assert mock_once.call_count == 1
-
-
-class TestGetModelInfo:
- """Tests for get_model_info function."""
-
- def test_returns_expected_format(self):
- """Returns expected metadata format."""
- info = get_model_info({})
-
- assert info["model"] == "gemini"
- assert info["device"] == "cloud"
- assert info["compute_type"] == "api"
-
-
-class TestBackendRegistry:
- """Tests for backend registry integration."""
-
- def test_gemini_registered(self):
- """Gemini backend is registered."""
- from solstone.observe.transcribe import BACKEND_REGISTRY
-
- assert "gemini" in BACKEND_REGISTRY
-
- def test_get_backend(self):
- """Can get Gemini backend module."""
- from solstone.observe.transcribe import get_backend
-
- backend = get_backend("gemini")
- assert hasattr(backend, "transcribe")
- assert hasattr(backend, "get_model_info")
-
-
-def test_process_audio_gemini_backend_uses_vad_chunk_path(tmp_path):
- from solstone.observe.transcribe.main import process_audio
-
- raw_path = (
- tmp_path / "chronicle" / "20260416" / "default" / "120000_300" / "audio.m4a"
- )
- raw_path.parent.mkdir(parents=True)
- raw_path.touch()
- audio = np.zeros(10 * SAMPLE_RATE, dtype=np.float32)
- vad_result = VadResult(
- duration=10.0,
- speech_duration=5.0,
- has_speech=True,
- speech_segments=[(1.0, 3.0), (5.0, 7.0)],
- )
- backend_module = Mock()
- backend_module.get_model_info.return_value = {
- "model": "gemini",
- "device": "cloud",
- "compute_type": "api",
- }
-
- with (
- patch(
- "solstone.observe.transcribe.main.stt_transcribe", return_value=[]
- ) as mock_stt_transcribe,
- patch(
- "solstone.observe.transcribe.main.get_backend",
- return_value=backend_module,
- ),
- patch(
- "solstone.observe.transcribe.main.get_config",
- return_value={"transcribe": {"preserve_all": True}},
- ),
- patch(
- "solstone.observe.transcribe.main.get_journal",
- return_value=str(tmp_path),
- ),
- patch("solstone.observe.transcribe.main.callosum_send"),
- ):
- process_audio(raw_path, audio, vad_result, {}, backend="gemini")
-
- assert mock_stt_transcribe.call_args.args[0] == "gemini"
- assert (
- mock_stt_transcribe.call_args.kwargs["speech_segments"]
- == vad_result.speech_segments
- )
diff --git a/tests/test_transcribe_gemini_schema.py b/tests/test_transcribe_gemini_schema.py
deleted file mode 100644
index d1d55fcc5..000000000
--- a/tests/test_transcribe_gemini_schema.py
+++ /dev/null
@@ -1,146 +0,0 @@
-# SPDX-License-Identifier: AGPL-3.0-only
-# Copyright (c) 2026 sol pbc
-
-import json
-from pathlib import Path
-from types import SimpleNamespace
-
-import numpy as np
-import pytest
-from jsonschema import Draft202012Validator
-
-import solstone.observe.transcribe.gemini as gemini_mod
-
-
-def _load_schema() -> dict:
- with (
- Path(__file__).resolve().parents[1]
- / "solstone"
- / "observe"
- / "transcribe"
- / "gemini.schema.json"
- ).open(encoding="utf-8") as f:
- return json.load(f)
-
-
-def test_gemini_schema_file_is_valid_draft_2020_12():
- Draft202012Validator.check_schema(_load_schema())
-
-
-def test_gemini_schema_accepts_and_rejects_expected_values():
- validator = Draft202012Validator(_load_schema())
-
- assert validator.is_valid({"segments": []})
- assert validator.is_valid(
- {"segments": [{"start": "01:23", "speaker": "Speaker 1", "text": "hi"}]}
- )
- assert validator.is_valid(
- {
- "segments": [
- {"start": "00:00", "speaker": "Speaker 1", "text": "hello"},
- {"start": "00:05", "speaker": "Speaker 2", "text": "hi back"},
- ]
- }
- )
- assert not validator.is_valid(
- [{"start": "01:23", "speaker": "Speaker 1", "text": "hi"}]
- )
- assert not validator.is_valid(
- {"transcript": [{"start": "01:23", "speaker": "Speaker 1", "text": "hi"}]}
- )
- assert not validator.is_valid({"segments": [], "extra": 1})
- assert not validator.is_valid(
- {"segments": [{"speaker": "Speaker 1", "text": "hi"}]}
- )
- assert not validator.is_valid({"segments": [{"start": "01:23", "text": "hi"}]})
- assert not validator.is_valid(
- {"segments": [{"start": "01:23", "speaker": "Speaker 1"}]}
- )
- assert not validator.is_valid(
- {
- "segments": [
- {
- "start": "01:23",
- "speaker": "s",
- "text": "t",
- "confidence": 0.9,
- }
- ]
- }
- )
- assert not validator.is_valid(
- {"segments": [{"start": "01:23", "speaker": "Speaker 1", "text": 7}]}
- )
- assert not validator.is_valid(
- {"segments": [{"start": "01:23", "speaker": 7, "text": "hi"}]}
- )
- assert not validator.is_valid(
- {"segments": [{"start": "1:23", "speaker": "Speaker 1", "text": "hi"}]}
- )
- assert not validator.is_valid(
- {"segments": [{"start": "01:23:45", "speaker": "Speaker 1", "text": "hi"}]}
- )
- assert not validator.is_valid(
- {"segments": [{"start": "01-23", "speaker": "Speaker 1", "text": "hi"}]}
- )
- assert not validator.is_valid(
- {"segments": [{"start": 83, "speaker": "Speaker 1", "text": "hi"}]}
- )
-
-
-def test_transcribe_passes_schema_to_generate(monkeypatch):
- captured = {}
-
- def fake_generate(**kwargs):
- captured.update(kwargs)
- return json.dumps(
- {"segments": [{"start": "00:00", "speaker": "Speaker 1", "text": "hello"}]}
- )
-
- monkeypatch.setattr(gemini_mod, "generate", fake_generate)
- monkeypatch.setattr(gemini_mod, "audio_to_flac_bytes", lambda *_args: b"flac")
- monkeypatch.setattr(
- gemini_mod.types.Part,
- "from_bytes",
- staticmethod(lambda data, mime_type: {"data": data, "mime_type": mime_type}),
- )
- monkeypatch.setattr(
- gemini_mod,
- "load_prompt",
- lambda *_args, **_kwargs: SimpleNamespace(text="Prompt"),
- )
-
- gemini_mod.transcribe(
- np.zeros(16000, dtype=np.float32),
- 16000,
- {},
- [(0.0, 1.0)],
- )
-
- assert captured["json_schema"] is gemini_mod._SCHEMA
-
-
-def test_transcribe_schema_validation_error_raises_runtime_error(monkeypatch):
- def fake_generate(**kwargs):
- raise gemini_mod.SchemaValidationError(
- [{"path": "", "constraint": "json_parse", "message": "empty"}],
- "",
- )
-
- monkeypatch.setattr(gemini_mod, "generate", fake_generate)
- monkeypatch.setattr(
- gemini_mod, "_build_chunk_contents", lambda *_args: ["contents"]
- )
- monkeypatch.setattr(
- gemini_mod,
- "load_prompt",
- lambda *_args, **_kwargs: SimpleNamespace(text="Prompt"),
- )
-
- with pytest.raises(RuntimeError, match="Gemini response failed schema validation"):
- gemini_mod.transcribe(
- np.zeros(16000, dtype=np.float32),
- 16000,
- {},
- [(0.0, 1.0)],
- )
diff --git a/tests/test_transcribe_noise_upgrade.py b/tests/test_transcribe_noise_upgrade.py
deleted file mode 100644
index 7c95c3f57..000000000
--- a/tests/test_transcribe_noise_upgrade.py
+++ /dev/null
@@ -1,473 +0,0 @@
-# SPDX-License-Identifier: AGPL-3.0-only
-# Copyright (c) 2026 sol pbc
-
-"""Tests for noise upgrade feature in transcription."""
-
-import argparse
-from unittest.mock import patch
-
-import numpy as np
-import pytest
-
-from solstone.observe.utils import SAMPLE_RATE
-from solstone.observe.vad import VadResult
-
-
-@pytest.fixture
-def audio_path(tmp_path):
- path = tmp_path / "chronicle" / "20260416" / "default" / "120000_300" / "audio.m4a"
- path.parent.mkdir(parents=True)
- path.touch()
- return path
-
-
-@pytest.fixture
-def args():
- return argparse.Namespace(backend=None, cpu=False, model=None, redo=False)
-
-
-@pytest.fixture
-def audio_buffer():
- return np.zeros(10 * SAMPLE_RATE, dtype=np.float32)
-
-
-class TestHasToken:
- """Tests for revai.has_token() function."""
-
- def test_has_token_when_present(self):
- """has_token() returns True when token is configured."""
- from solstone.observe.transcribe.revai import has_token
-
- with patch.dict("os.environ", {"REVAI_ACCESS_TOKEN": "test-token"}):
- assert has_token() is True
-
- def test_has_token_when_missing(self):
- """has_token() returns False when token is not configured."""
- from solstone.observe.transcribe.revai import has_token
-
- with patch.dict("os.environ", {}, clear=True):
- assert has_token() is False
-
- def test_has_token_with_alternate_env_var(self):
- """has_token() returns True with REV_ACCESS_TOKEN."""
- from solstone.observe.transcribe.revai import has_token
-
- with patch.dict("os.environ", {"REV_ACCESS_TOKEN": "test-token"}):
- assert has_token() is True
-
-
-class TestNoisyHighSpeechSkipsReduction:
- """Tests for skipping audio reduction on noisy clips with high speech ratio."""
-
- def test_noisy_high_speech_skips_reduction(self):
- """Noisy clip with >=70% speech should skip audio reduction."""
- vad = VadResult(
- duration=10.0,
- speech_duration=8.0, # 80% speech
- has_speech=True,
- speech_segments=[(0.0, 3.0), (4.0, 9.0)],
- noisy_rms=0.02,
- )
- assert vad.is_noisy()
- assert vad.speech_ratio >= 0.7
-
- def test_noisy_low_speech_does_not_skip(self):
- """Noisy clip with <70% speech should still reduce audio."""
- vad = VadResult(
- duration=10.0,
- speech_duration=5.0, # 50% speech
- has_speech=True,
- speech_segments=[(1.0, 6.0)],
- noisy_rms=0.02,
- )
- assert vad.is_noisy()
- assert vad.speech_ratio < 0.7
-
- def test_quiet_high_speech_does_not_skip(self):
- """Quiet clip with high speech should still reduce audio."""
- vad = VadResult(
- duration=10.0,
- speech_duration=8.0, # 80% speech
- has_speech=True,
- speech_segments=[(0.0, 3.0), (4.0, 9.0)],
- noisy_rms=0.005, # Below noise threshold
- )
- assert not vad.is_noisy()
- assert vad.speech_ratio >= 0.7
-
-
-class TestNoiseUpgradeLogic:
- """Tests for noise upgrade decision logic."""
-
- def _make_vad_result(self, noisy_rms: float | None = None) -> VadResult:
- """Create a VadResult with specified noise level."""
- return VadResult(
- duration=10.0,
- speech_duration=5.0,
- has_speech=True,
- speech_segments=[(1.0, 6.0)],
- noisy_rms=noisy_rms,
- noisy_s=3.0 if noisy_rms else 0.0,
- )
-
- def test_is_noisy_threshold(self):
- """VadResult.is_noisy() uses 0.01 RMS threshold."""
- # Below threshold
- vad_quiet = self._make_vad_result(noisy_rms=0.005)
- assert vad_quiet.is_noisy() is False
-
- # Above threshold
- vad_noisy = self._make_vad_result(noisy_rms=0.015)
- assert vad_noisy.is_noisy() is True
-
- # Exactly at threshold (should be False, > not >=)
- vad_edge = self._make_vad_result(noisy_rms=0.01)
- assert vad_edge.is_noisy() is False
-
- def test_is_noisy_none_rms(self):
- """VadResult.is_noisy() returns False when RMS is None."""
- vad = self._make_vad_result(noisy_rms=None)
- assert vad.is_noisy() is False
-
-
-class TestNoiseUpgradeConfig:
- """Tests for noise_upgrade config handling."""
-
- def test_noise_upgrade_defaults_to_true(self):
- """noise_upgrade should default to True when not in config."""
- config = {}
- noise_upgrade = config.get("noise_upgrade", True)
- assert noise_upgrade is True
-
- def test_noise_upgrade_explicit_true(self):
- """noise_upgrade can be explicitly set to True."""
- config = {"noise_upgrade": True}
- noise_upgrade = config.get("noise_upgrade", True)
- assert noise_upgrade is True
-
- def test_noise_upgrade_explicit_false(self):
- """noise_upgrade can be disabled."""
- config = {"noise_upgrade": False}
- noise_upgrade = config.get("noise_upgrade", True)
- assert noise_upgrade is False
-
-
-class TestBackendMetadata:
- """Tests for backend field in JSONL metadata."""
-
- def test_backend_field_in_metadata(self):
- """_statements_to_jsonl includes backend field in metadata."""
- import datetime
- import json
-
- from solstone.observe.transcribe.main import _statements_to_jsonl
-
- statements = [{"id": 1, "start": 0.0, "end": 1.0, "text": "Hello"}]
- model_info = {"model": "medium.en", "device": "cpu", "compute_type": "int8"}
- base_dt = datetime.datetime(2025, 1, 15, 14, 30, 0)
-
- # Test with parakeet backend
- lines = _statements_to_jsonl(
- statements, "audio.flac", base_dt, model_info, backend="parakeet"
- )
- metadata = json.loads(lines[0])
- assert metadata["backend"] == "parakeet"
-
- # Test with revai backend
- lines = _statements_to_jsonl(
- statements, "audio.flac", base_dt, model_info, backend="revai"
- )
- metadata = json.loads(lines[0])
- assert metadata["backend"] == "revai"
-
- def test_backend_field_defaults_to_unknown(self):
- """backend field defaults to 'unknown' when not provided."""
- import datetime
- import json
-
- from solstone.observe.transcribe.main import _statements_to_jsonl
-
- statements = [{"id": 1, "start": 0.0, "end": 1.0, "text": "Hello"}]
- model_info = {"model": "medium.en", "device": "cpu", "compute_type": "int8"}
- base_dt = datetime.datetime(2025, 1, 15, 14, 30, 0)
-
- lines = _statements_to_jsonl(statements, "audio.flac", base_dt, model_info)
- metadata = json.loads(lines[0])
- assert metadata["backend"] == "unknown"
-
-
-class TestNoiseUpgradeGate:
- def test_gate_blocks_on_low_ratio(self, audio_path, args, audio_buffer):
- from solstone.observe.transcribe.main import _process_one
-
- vad = VadResult(
- duration=10.0,
- speech_duration=5.0,
- has_speech=True,
- speech_segments=[(1.0, 6.0)],
- noisy_rms=0.02,
- noisy_s=3.0,
- loud_windows=200,
- speech_loud_windows=10,
- )
- transcribe_config = {
- "noise_upgrade": True,
- "noise_upgrade_min_speech_ratio": 0.3,
- "parakeet": {},
- }
-
- with (
- patch(
- "solstone.observe.transcribe.main.load_audio", return_value=audio_buffer
- ),
- patch("solstone.observe.vad.run_vad", return_value=vad),
- patch(
- "solstone.observe.vad.reduce_audio",
- return_value=(None, None),
- ),
- patch(
- "solstone.observe.transcribe.main.process_audio"
- ) as mock_process_audio,
- patch("solstone.observe.transcribe.revai.has_token", return_value=True),
- ):
- _process_one(audio_path, args, transcribe_config, "parakeet", [])
-
- assert mock_process_audio.call_args.kwargs["backend"] == "parakeet"
-
- def test_gate_admits_on_high_ratio(self, audio_path, args, audio_buffer):
- from solstone.observe.transcribe.main import _process_one
-
- vad = VadResult(
- duration=10.0,
- speech_duration=5.0,
- has_speech=True,
- speech_segments=[(1.0, 6.0)],
- noisy_rms=0.02,
- noisy_s=3.0,
- loud_windows=100,
- speech_loud_windows=90,
- )
- transcribe_config = {
- "noise_upgrade": True,
- "noise_upgrade_min_speech_ratio": 0.3,
- "parakeet": {},
- }
-
- with (
- patch(
- "solstone.observe.transcribe.main.load_audio", return_value=audio_buffer
- ),
- patch("solstone.observe.vad.run_vad", return_value=vad),
- patch(
- "solstone.observe.vad.reduce_audio",
- return_value=(None, None),
- ),
- patch(
- "solstone.observe.transcribe.main.process_audio"
- ) as mock_process_audio,
- patch("solstone.observe.transcribe.revai.has_token", return_value=True),
- ):
- _process_one(audio_path, args, transcribe_config, "parakeet", [])
-
- assert mock_process_audio.call_args.kwargs["backend"] == "revai"
-
- def test_confidential_lane_blocks_revai_upgrade(
- self, audio_path, args, audio_buffer
- ):
- from solstone.observe.transcribe.main import _process_one
-
- vad = VadResult(
- duration=10.0,
- speech_duration=5.0,
- has_speech=True,
- speech_segments=[(1.0, 6.0)],
- noisy_rms=0.02,
- noisy_s=3.0,
- loud_windows=100,
- speech_loud_windows=90,
- )
- transcribe_config = {
- "noise_upgrade": True,
- "noise_upgrade_min_speech_ratio": 0.3,
- "parakeet": {},
- }
-
- with (
- patch(
- "solstone.observe.transcribe.main.load_audio", return_value=audio_buffer
- ),
- patch("solstone.observe.vad.run_vad", return_value=vad),
- patch(
- "solstone.observe.vad.reduce_audio",
- return_value=(None, None),
- ),
- patch(
- "solstone.observe.transcribe.main.process_audio"
- ) as mock_process_audio,
- patch("solstone.observe.transcribe.revai.has_token", return_value=True),
- patch(
- "solstone.think.services.spp.confidential_provenance",
- return_value={"enabled_at": "2026-05-24T00:00:00Z"},
- ),
- ):
- _process_one(audio_path, args, transcribe_config, "parakeet", [])
-
- assert mock_process_audio.call_args.kwargs["backend"] == "parakeet"
-
- def test_gate_fallback_when_ratio_none(self, audio_path, args, audio_buffer):
- from solstone.observe.transcribe.main import _process_one
-
- vad = VadResult(
- duration=10.0,
- speech_duration=5.0,
- has_speech=True,
- speech_segments=[(1.0, 6.0)],
- noisy_rms=0.02,
- noisy_s=3.0,
- loud_windows=0,
- speech_loud_windows=0,
- )
- transcribe_config = {
- "noise_upgrade": True,
- "noise_upgrade_min_speech_ratio": 0.3,
- "parakeet": {},
- }
-
- with (
- patch(
- "solstone.observe.transcribe.main.load_audio", return_value=audio_buffer
- ),
- patch("solstone.observe.vad.run_vad", return_value=vad),
- patch(
- "solstone.observe.vad.reduce_audio",
- return_value=(None, None),
- ),
- patch(
- "solstone.observe.transcribe.main.process_audio"
- ) as mock_process_audio,
- patch("solstone.observe.transcribe.revai.has_token", return_value=True),
- ):
- _process_one(audio_path, args, transcribe_config, "parakeet", [])
-
- assert mock_process_audio.call_args.kwargs["backend"] == "revai"
-
- def test_gate_blocks_when_not_noisy(self, audio_path, args, audio_buffer):
- from solstone.observe.transcribe.main import _process_one
-
- vad = VadResult(
- duration=10.0,
- speech_duration=5.0,
- has_speech=True,
- speech_segments=[(1.0, 6.0)],
- noisy_rms=0.005,
- noisy_s=3.0,
- loud_windows=100,
- speech_loud_windows=90,
- )
- transcribe_config = {
- "noise_upgrade": True,
- "noise_upgrade_min_speech_ratio": 0.3,
- "parakeet": {},
- }
-
- with (
- patch(
- "solstone.observe.transcribe.main.load_audio", return_value=audio_buffer
- ),
- patch("solstone.observe.vad.run_vad", return_value=vad),
- patch(
- "solstone.observe.vad.reduce_audio",
- return_value=(None, None),
- ),
- patch(
- "solstone.observe.transcribe.main.process_audio"
- ) as mock_process_audio,
- patch("solstone.observe.transcribe.revai.has_token", return_value=True),
- ):
- _process_one(audio_path, args, transcribe_config, "parakeet", [])
-
- assert mock_process_audio.call_args.kwargs["backend"] == "parakeet"
-
- @pytest.mark.parametrize(
- ("vad", "has_token", "expected_backend"),
- [
- (
- VadResult(
- duration=10.0,
- speech_duration=5.0,
- has_speech=True,
- speech_segments=[(1.0, 6.0)],
- noisy_rms=0.005,
- noisy_s=3.0,
- loud_windows=100,
- speech_loud_windows=90,
- ),
- True,
- "parakeet",
- ),
- (
- VadResult(
- duration=10.0,
- speech_duration=5.0,
- has_speech=True,
- speech_segments=[(1.0, 6.0)],
- noisy_rms=0.02,
- noisy_s=3.0,
- loud_windows=100,
- speech_loud_windows=90,
- ),
- True,
- "revai",
- ),
- (
- VadResult(
- duration=10.0,
- speech_duration=5.0,
- has_speech=True,
- speech_segments=[(1.0, 6.0)],
- noisy_rms=0.02,
- noisy_s=3.0,
- loud_windows=100,
- speech_loud_windows=90,
- ),
- False,
- "parakeet",
- ),
- ],
- )
- def test_parakeet_default_upgrade_path(
- self,
- audio_path,
- args,
- audio_buffer,
- vad,
- has_token,
- expected_backend,
- ):
- from solstone.observe.transcribe.main import _process_one
-
- transcribe_config = {
- "noise_upgrade": True,
- "noise_upgrade_min_speech_ratio": 0.3,
- "parakeet": {},
- }
-
- with (
- patch(
- "solstone.observe.transcribe.main.load_audio", return_value=audio_buffer
- ),
- patch("solstone.observe.vad.run_vad", return_value=vad),
- patch(
- "solstone.observe.vad.reduce_audio",
- return_value=(None, None),
- ),
- patch(
- "solstone.observe.transcribe.main.process_audio"
- ) as mock_process_audio,
- patch(
- "solstone.observe.transcribe.revai.has_token", return_value=has_token
- ),
- ):
- _process_one(audio_path, args, transcribe_config, "parakeet", [])
-
- assert mock_process_audio.call_args.kwargs["backend"] == expected_backend
diff --git a/tests/test_transcribe_parakeet_cpp_retry.py b/tests/test_transcribe_parakeet_cpp_retry.py
index d2d05ca86..173395a73 100644
--- a/tests/test_transcribe_parakeet_cpp_retry.py
+++ b/tests/test_transcribe_parakeet_cpp_retry.py
@@ -89,7 +89,9 @@ def test_process_audio_confidential_cloud_refusal_defers_honestly(
patch("solstone.observe.transcribe.main.callosum_send") as mock_send,
):
with pytest.raises(SystemExit) as exc_info:
- process_audio(raw_path, audio_buffer, vad_result, {}, backend="gemini")
+ process_audio(
+ raw_path, audio_buffer, vad_result, {}, backend="confidential"
+ )
assert exc_info.value.code == EXIT_PROVIDER_BLOCKED
assert raw_path.exists()
@@ -98,7 +100,7 @@ def test_process_audio_confidential_cloud_refusal_defers_honestly(
kwargs = mock_send.call_args.kwargs
assert kwargs["outcome"] == "deferred"
assert kwargs["reason"] == "confidential_egress_blocked"
- assert kwargs["backend"] == "gemini"
+ assert kwargs["backend"] == "confidential"
def test_deferred_event_fires_on_every_attempt(
@@ -200,10 +202,6 @@ def test_batch_all_continues_past_a_deferred_file(
"solstone.observe.transcribe.main.resolve_default_backend",
return_value="parakeet-cpp",
),
- patch(
- "solstone.think.entities.load_recent_entity_names",
- return_value=[],
- ),
):
main()
@@ -265,7 +263,7 @@ def test_process_one_builds_parakeet_cpp_backend_config(
side_effect=fake_process_audio,
),
):
- _process_one(audio_path, args, transcribe_config, "parakeet-cpp", [])
+ _process_one(audio_path, args, transcribe_config, "parakeet-cpp")
assert captured == {
"backend": "parakeet-cpp",
diff --git a/tests/test_transcribe_resource.py b/tests/test_transcribe_resource.py
index 1dfa89624..e3e8550e0 100644
--- a/tests/test_transcribe_resource.py
+++ b/tests/test_transcribe_resource.py
@@ -61,7 +61,6 @@ def test_local_stt_backend_platform_mapping(
(
"explicit_backend",
"available_bytes",
- "google_key_present",
"floor_bytes",
"local_backend",
"confidential_lane_active",
@@ -69,76 +68,76 @@ def test_local_stt_backend_platform_mapping(
"expected",
),
[
- ("parakeet", 1, True, 4 * 1024**3, "parakeet", True, True, "parakeet"),
+ ("parakeet", 1, 4 * 1024**3, "parakeet", True, True, "parakeet"),
(
"parakeet-cpp",
1,
- True,
4 * 1024**3,
"parakeet",
True,
True,
"parakeet-cpp",
),
- ("confidential", 1, True, 4 * 1024**3, "parakeet", True, True, "confidential"),
- ("confidential", 1, True, 4 * 1024**3, "parakeet", True, False, "parakeet"),
+ ("confidential", 1, 4 * 1024**3, "parakeet", True, True, "confidential"),
+ ("confidential", 1, 4 * 1024**3, "parakeet", True, False, "parakeet"),
(
"confidential",
1,
- True,
4 * 1024**3,
None,
True,
False,
resource.STT_SURFACE,
),
- ("confidential", 1, True, 4 * 1024**3, "parakeet", False, True, "parakeet"),
+ ("confidential", 1, 4 * 1024**3, "parakeet", False, True, "parakeet"),
(
"confidential",
1,
- True,
4 * 1024**3,
None,
False,
True,
resource.STT_SURFACE,
),
- ("gemini", 1, False, 4 * 1024**3, "parakeet", True, True, "gemini"),
- ("revai", 1, False, 4 * 1024**3, "parakeet", True, True, "revai"),
- (None, 1, False, 4 * 1024**3, "parakeet", True, True, "confidential"),
- (None, 1, True, 4 * 1024**3, "parakeet", True, False, "parakeet"),
+ (None, 1, 4 * 1024**3, "parakeet", True, True, "confidential"),
+ (None, 1, 4 * 1024**3, "parakeet", True, False, "parakeet"),
(
None,
1,
- True,
4 * 1024**3,
None,
True,
False,
resource.STT_SURFACE,
),
- (None, 4 * 1024**3, False, 4 * 1024**3, "parakeet", False, True, "parakeet"),
- (None, 5 * 1024**3, True, 4 * 1024**3, "parakeet", False, True, "parakeet"),
- (None, 3 * 1024**3, True, 4 * 1024**3, "parakeet", False, True, "gemini"),
- (None, None, True, 4 * 1024**3, "parakeet", False, True, "gemini"),
- (None, 3 * 1024**3, True, None, None, False, True, "gemini"),
+ (None, 4 * 1024**3, 4 * 1024**3, "parakeet", False, True, "parakeet"),
+ (None, 5 * 1024**3, 4 * 1024**3, "parakeet", False, True, "parakeet"),
(
None,
- None,
+ 3 * 1024**3,
+ 4 * 1024**3,
+ "parakeet",
False,
+ True,
+ resource.STT_SURFACE,
+ ),
+ (None, None, 4 * 1024**3, "parakeet", False, True, resource.STT_SURFACE),
+ (None, 3 * 1024**3, None, None, False, True, resource.STT_SURFACE),
+ (
+ None,
+ None,
4 * 1024**3,
"parakeet",
False,
True,
resource.STT_SURFACE,
),
- (None, 3 * 1024**3, False, None, None, False, True, resource.STT_SURFACE),
+ (None, 3 * 1024**3, None, None, False, True, resource.STT_SURFACE),
],
)
def test_resolve_stt_backend_choice_matrix(
explicit_backend: str | None,
available_bytes: int | None,
- google_key_present: bool,
floor_bytes: int | None,
local_backend: str | None,
confidential_lane_active: bool,
@@ -149,7 +148,6 @@ def test_resolve_stt_backend_choice_matrix(
resource.resolve_stt_backend_choice(
explicit_backend,
available_bytes,
- google_key_present=google_key_present,
floor_bytes=floor_bytes,
local_backend=local_backend,
confidential_lane_active=confidential_lane_active,
@@ -163,7 +161,6 @@ def test_resolve_stt_backend_choice_is_deterministic() -> None:
args = {
"explicit_backend": None,
"available_bytes": 3 * 1024**3,
- "google_key_present": True,
"floor_bytes": 4 * 1024**3,
"local_backend": "parakeet",
"confidential_lane_active": False,
diff --git a/tests/test_transcribe_revai.py b/tests/test_transcribe_revai.py
deleted file mode 100644
index fbc68d019..000000000
--- a/tests/test_transcribe_revai.py
+++ /dev/null
@@ -1,317 +0,0 @@
-# SPDX-License-Identifier: AGPL-3.0-only
-# Copyright (c) 2026 sol pbc
-
-"""Tests for the Rev.ai STT backend."""
-
-import json
-
-import pytest
-
-from solstone.observe.transcribe.revai import convert_to_statements
-
-
-class TestConvertToStatements:
- """Tests for convert_to_statements function."""
-
- def test_empty_json(self):
- """Empty JSON returns empty list."""
- assert convert_to_statements({}) == []
- assert convert_to_statements({"monologues": []}) == []
-
- def test_single_speaker_monologue(self):
- """Single speaker monologue produces one statement."""
- revai_json = {
- "monologues": [
- {
- "speaker": 0,
- "elements": [
- {
- "type": "text",
- "value": "Hello",
- "ts": 1.0,
- "end_ts": 1.5,
- "confidence": 0.95,
- },
- {"type": "punct", "value": " "},
- {
- "type": "text",
- "value": "world",
- "ts": 1.6,
- "end_ts": 2.0,
- "confidence": 0.98,
- },
- {"type": "punct", "value": "."},
- ],
- }
- ]
- }
-
- statements = convert_to_statements(revai_json)
-
- assert len(statements) == 1
- stmt = statements[0]
- assert stmt["id"] == 1
- assert stmt["start"] == 1.0
- assert stmt["end"] == 2.0
- assert stmt["text"] == "Hello world."
- assert stmt["speaker"] == 1 # 0-indexed to 1-indexed
- assert stmt["confidence"] == pytest.approx(0.965, rel=0.01)
- assert stmt["words"] is not None
- assert len(stmt["words"]) == 2
-
- def test_multiple_speakers(self):
- """Multiple speakers produce separate statements with correct speaker IDs."""
- revai_json = {
- "monologues": [
- {
- "speaker": 0,
- "elements": [
- {
- "type": "text",
- "value": "Hi",
- "ts": 0.0,
- "end_ts": 0.5,
- "confidence": 0.9,
- },
- {"type": "punct", "value": "."},
- ],
- },
- {
- "speaker": 1,
- "elements": [
- {
- "type": "text",
- "value": "Hello",
- "ts": 1.0,
- "end_ts": 1.5,
- "confidence": 0.95,
- },
- {"type": "punct", "value": "."},
- ],
- },
- {
- "speaker": 0,
- "elements": [
- {
- "type": "text",
- "value": "Bye",
- "ts": 2.0,
- "end_ts": 2.5,
- "confidence": 0.85,
- },
- {"type": "punct", "value": "."},
- ],
- },
- ]
- }
-
- statements = convert_to_statements(revai_json)
-
- assert len(statements) == 3
- assert statements[0]["speaker"] == 1
- assert statements[0]["text"] == "Hi."
- assert statements[1]["speaker"] == 2
- assert statements[1]["text"] == "Hello."
- assert statements[2]["speaker"] == 1
- assert statements[2]["text"] == "Bye."
-
- def test_sequential_ids(self):
- """Statement IDs are sequential starting from 1."""
- revai_json = {
- "monologues": [
- {
- "speaker": 0,
- "elements": [
- {"type": "text", "value": "A", "ts": 0.0, "end_ts": 0.5}
- ],
- },
- {
- "speaker": 1,
- "elements": [
- {"type": "text", "value": "B", "ts": 1.0, "end_ts": 1.5}
- ],
- },
- {
- "speaker": 2,
- "elements": [
- {"type": "text", "value": "C", "ts": 2.0, "end_ts": 2.5}
- ],
- },
- ]
- }
-
- statements = convert_to_statements(revai_json)
-
- assert [s["id"] for s in statements] == [1, 2, 3]
-
- def test_word_data_preserved(self):
- """Word-level data is preserved in statements."""
- revai_json = {
- "monologues": [
- {
- "speaker": 0,
- "elements": [
- {
- "type": "text",
- "value": "Test",
- "ts": 0.0,
- "end_ts": 0.5,
- "confidence": 0.9,
- },
- {"type": "punct", "value": " "},
- {
- "type": "text",
- "value": "data",
- "ts": 0.6,
- "end_ts": 1.0,
- "confidence": 0.95,
- },
- ],
- }
- ]
- }
-
- statements = convert_to_statements(revai_json)
-
- assert len(statements) == 1
- words = statements[0]["words"]
- assert len(words) == 2
- assert words[0]["word"] == "Test"
- assert words[0]["start"] == 0.0
- assert words[0]["end"] == 0.5
- assert words[0]["probability"] == 0.9
- assert words[1]["word"] == "data"
-
- def test_missing_timestamps(self):
- """Elements without timestamps are handled gracefully."""
- revai_json = {
- "monologues": [
- {
- "speaker": 0,
- "elements": [
- {"type": "text", "value": "No", "confidence": 0.9},
- {"type": "text", "value": " timestamps"},
- ],
- }
- ]
- }
-
- statements = convert_to_statements(revai_json)
-
- assert len(statements) == 1
- assert statements[0]["text"] == "No timestamps"
- assert statements[0]["start"] == 0.0
- assert statements[0]["end"] == 0.0
-
- def test_empty_monologue_skipped(self):
- """Empty monologues are skipped."""
- revai_json = {
- "monologues": [
- {"speaker": 0, "elements": []},
- {
- "speaker": 1,
- "elements": [
- {"type": "text", "value": "Hello", "ts": 0.0, "end_ts": 0.5}
- ],
- },
- ]
- }
-
- statements = convert_to_statements(revai_json)
-
- assert len(statements) == 1
- assert statements[0]["speaker"] == 2
-
- def test_whitespace_only_skipped(self):
- """Monologues with only whitespace are skipped."""
- revai_json = {
- "monologues": [
- {
- "speaker": 0,
- "elements": [
- {"type": "punct", "value": " "},
- {"type": "punct", "value": " "},
- ],
- },
- {
- "speaker": 1,
- "elements": [
- {"type": "text", "value": "Real", "ts": 0.0, "end_ts": 0.5}
- ],
- },
- ]
- }
-
- statements = convert_to_statements(revai_json)
-
- # Whitespace-only monologue strips to empty and is skipped
- assert len(statements) == 1
- assert statements[0]["text"] == "Real"
-
- def test_fixture_data(self):
- """Test with actual fixture data from tests/fixtures/revai.json."""
- from pathlib import Path
-
- fixture_path = Path(__file__).parent / "fixtures" / "revai.json"
- if not fixture_path.exists():
- pytest.skip("Fixture file not found")
-
- with open(fixture_path) as f:
- revai_json = json.load(f)
-
- statements = convert_to_statements(revai_json)
-
- # Should have 2 monologues -> 2 statements
- assert len(statements) == 2
-
- # First statement from speaker 0 (becomes 1)
- assert statements[0]["speaker"] == 1
- assert "Okay" in statements[0]["text"]
- assert statements[0]["start"] == pytest.approx(0.395, rel=0.01)
-
- # Second statement from speaker 2 (becomes 3)
- assert statements[1]["speaker"] == 3
- assert "lunch" in statements[1]["text"]
-
-
-class TestBackendRegistry:
- """Tests for backend registry integration."""
-
- def test_revai_registered(self):
- """Rev.ai backend is registered."""
- from solstone.observe.transcribe import BACKEND_REGISTRY
-
- assert "revai" in BACKEND_REGISTRY
-
- def test_get_backend(self):
- """Can get Rev.ai backend module."""
- from solstone.observe.transcribe import get_backend
-
- backend = get_backend("revai")
- assert hasattr(backend, "transcribe")
- assert hasattr(backend, "convert_to_statements")
- assert hasattr(backend, "get_model_info")
-
-
-class TestGetModelInfo:
- """Tests for get_model_info function."""
-
- def test_default_config(self):
- """Default config produces expected metadata."""
- from solstone.observe.transcribe.revai import get_model_info
-
- info = get_model_info({})
-
- assert info["model"] == "revai-fusion"
- assert info["device"] == "cloud"
- assert info["compute_type"] == "api"
- assert info["diarization"] == "premium"
-
- def test_custom_config(self):
- """Custom config is reflected in metadata."""
- from solstone.observe.transcribe.revai import get_model_info
-
- info = get_model_info({"model": "machine", "diarization_type": "standard"})
-
- assert info["model"] == "revai-machine"
- assert info["diarization"] == "standard"
diff --git a/tests/test_transcribe_telemetry.py b/tests/test_transcribe_telemetry.py
index 2803e583e..872073ac6 100644
--- a/tests/test_transcribe_telemetry.py
+++ b/tests/test_transcribe_telemetry.py
@@ -75,7 +75,7 @@ def _run_success(
with (
patch(
"solstone.observe.transcribe.main.get_config",
- return_value={"transcribe": {"preserve_all": False, "enrich": False}},
+ return_value={"transcribe": {"preserve_all": False}},
),
patch(
"solstone.observe.transcribe.main.get_journal",
@@ -145,7 +145,6 @@ def _run_parakeet_cpp_process_one_event(
args,
{"backend": "parakeet-cpp", "parakeet-cpp": parakeet_cpp_config},
"parakeet-cpp",
- [],
)
assert exc_info.value.code == expected_exit
@@ -161,10 +160,9 @@ def test_success_event_carries_stage_timings_and_envelope(
assert kwargs["outcome"] == "transcribed"
timings = kwargs["timings"]
# Stages that ran inside process_audio. decode/vad/reduce are measured in
- # _process_one, which this test calls past; enrich is disabled by config and
- # diarization is skipped for parakeet-cpp -- so neither may appear.
+ # _process_one, which this test calls past; diarization is skipped because
+ # the mocked overlap is below threshold.
assert {"asr_ms", "embed_ms", "overlap_ms", "write_ms"} <= set(timings)
- assert "enrich_ms" not in timings
assert "diarize_ms" not in timings
assert all(isinstance(v, int) and v >= 0 for v in timings.values())
@@ -187,7 +185,7 @@ def test_success_event_is_content_free(
serialized = json.dumps(kwargs, default=str)
assert TRANSCRIPT_SENTINEL not in serialized
- # And none of the enrichment-derived content fields ride along either.
+ # Content fields stay out of the event envelope.
for banned in ("text", "words", "statements", "topics", "setting", "emotions"):
assert banned not in kwargs
@@ -321,15 +319,12 @@ def test_failed_event_is_content_free_even_when_the_exception_message_is_not(
) -> None:
"""The failed path must not put an exception *message* on the bus.
- Real exception messages can embed model output: SchemaValidationError carries a
- preview of the raw response, and transcribe/gemini.py interpolates it into its
- own message. So the event carries the exception *type*, never the message.
+ Real exception messages can embed model output from provider wrappers. So the
+ event carries the exception *type*, never the message.
"""
from solstone.observe.transcribe.main import process_audio
- leaky = RuntimeError(
- f"Gemini response failed schema validation: preview={TRANSCRIPT_SENTINEL!r}"
- )
+ leaky = RuntimeError(f"Provider response included preview={TRANSCRIPT_SENTINEL!r}")
with (
patch("solstone.observe.transcribe.main.stt_transcribe", side_effect=leaky),
@@ -340,7 +335,7 @@ def test_failed_event_is_content_free_even_when_the_exception_message_is_not(
patch("solstone.observe.transcribe.main.callosum_send") as mock_send,
):
with pytest.raises(SystemExit) as exc_info:
- process_audio(raw_path, audio_buffer, vad_result, {}, backend="gemini")
+ process_audio(raw_path, audio_buffer, vad_result, {}, backend="parakeet")
assert exc_info.value.code == 1
kwargs = mock_send.call_args.kwargs
diff --git a/tests/verify_api.py b/tests/verify_api.py
index 5f8756a21..f497ef20f 100644
--- a/tests/verify_api.py
+++ b/tests/verify_api.py
@@ -472,6 +472,16 @@ def normalize(data: Any, journal_path: str) -> Any:
for k in result:
if isinstance(result[k], bool):
result[k] = False
+ if key == "resource" and {
+ "available_memory_gb",
+ "detected",
+ "min_ram_gb",
+ "needs_setup",
+ "notice",
+ "requirement",
+ } <= set(result):
+ for item_key in result:
+ result[item_key] = ""
return result
if isinstance(value, list):
diff --git a/uv.lock b/uv.lock
index 2985aad3a..09748db47 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1198,46 +1198,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/98/66a06b82a5c840f896490d5ef9c7691776b147589f2e8d2fa66c67a3db9c/genai_prices-0.0.55-py3-none-any.whl", hash = "sha256:ccd795c90c926b3c71066bf5656f14c67fc11fdba6d71e072c7fb4fa311e1b12", size = 62603, upload-time = "2026-02-26T17:56:40.502Z" },
]
-[[package]]
-name = "google-auth"
-version = "2.48.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cryptography" },
- { name = "pyasn1-modules" },
- { name = "rsa" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" },
-]
-
-[package.optional-dependencies]
-requests = [
- { name = "requests" },
-]
-
-[[package]]
-name = "google-genai"
-version = "1.62.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "distro" },
- { name = "google-auth", extra = ["requests"] },
- { name = "httpx" },
- { name = "pydantic" },
- { name = "requests" },
- { name = "sniffio" },
- { name = "tenacity" },
- { name = "typing-extensions" },
- { name = "websockets" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/94/4c/71b32b5c8db420cf2fd0d5ef8a672adbde97d85e5d44a0b4fca712264ef1/google_genai-1.62.0.tar.gz", hash = "sha256:709468a14c739a080bc240a4f3191df597bf64485b1ca3728e0fb67517774c18", size = 490888, upload-time = "2026-02-04T22:48:41.989Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/09/5f/4645d8a28c6e431d0dd6011003a852563f3da7037d36af53154925b099fd/google_genai-1.62.0-py3-none-any.whl", hash = "sha256:4c3daeff3d05fafee4b9a1a31f9c07f01bc22051081aa58b4d61f58d16d1bcc0", size = 724166, upload-time = "2026-02-04T22:48:39.956Z" },
-]
-
[[package]]
name = "googleapis-common-protos"
version = "1.75.0"
@@ -3238,27 +3198,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" },
]
-[[package]]
-name = "pyasn1"
-version = "0.6.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
-]
-
-[[package]]
-name = "pyasn1-modules"
-version = "0.4.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pyasn1" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
-]
-
[[package]]
name = "pycparser"
version = "3.0"
@@ -3993,18 +3932,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
]
-[[package]]
-name = "rsa"
-version = "4.9.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pyasn1" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
-]
-
[[package]]
name = "ruff"
version = "0.15.2"
@@ -4301,7 +4228,6 @@ journal-host = [
{ name = "blessed" },
{ name = "flask", extra = ["async"] },
{ name = "genai-prices" },
- { name = "google-genai" },
{ name = "h2" },
{ name = "httpx" },
{ name = "huggingface-hub" },
@@ -4381,7 +4307,6 @@ requires-dist = [
{ name = "cryptography", specifier = ">=42,<47" },
{ name = "flask", extras = ["async"], marker = "extra == 'journal-host'" },
{ name = "genai-prices", marker = "extra == 'journal-host'" },
- { name = "google-genai", marker = "extra == 'journal-host'", specifier = ">=1.62.0" },
{ name = "h2", marker = "extra == 'journal-host'" },
{ name = "httpx", marker = "extra == 'journal-host'" },
{ name = "huggingface-hub", marker = "extra == 'journal-host'", specifier = ">=0.24" },