feat(tts): Phase 10C — TTS generation via OpenRouter /audio/speech#22
Conversation
- Add generate_tts LangGraph tool + node (features/audio/)
- service.py: POST to openrouter /audio/speech with gpt-4o-mini-tts,
upload mp3 to Supabase Storage, return 7-day signed URL
- tool.py: extract text arg from supervisor tool_call, call service,
return ToolMessage with markdown audio link
- schemas.py: AudioGenerateResult Pydantic model
- Wire generate_tts into supervisor + orchestrator
- supervisor.py: bind generate_tts tool, add TTS section to system prompt,
detect text-format tool calls (DeepSeek compat) and convert to structured
- orchestrator.py: add generate_tts node, conditional edge from supervisor
- Config: tts_model=openai/gpt-4o-mini-tts-2025-12-15, tts_voice, audio_bucket
- State: add pending_audio field for cross-turn save confirmation (Phase 10D)
- Emit Generating audio... SSE tool_use event from chat service
- Frontend: inline <audio> player for .mp3 links in MessageBubble
- Frontend: purple Volume2 icon in ChatWindow during TTS tool_use status
- Supabase migration: audio bucket with RLS policies scoped to {user_id}/*
|
Caution Review failedPull request was closed or merged during review WalkthroughSupervisor can request TTS via a new generate_tts tool; orchestrator routes to a generate_tts node that calls OpenRouter, uploads MP3 to Supabase, stores pending_audio in state, and frontend renders audio players and a TTS status icon. ChangesText-to-Speech Feature
Sequence DiagramsequenceDiagram
participant User
participant Frontend
participant Supervisor
participant Orchestrator
participant generate_tts_node
participant AudioService
participant SupabaseStorage
User->>Frontend: send message / request TTS
Frontend->>Supervisor: user input
Supervisor->>Orchestrator: supervisor emits tool_call generate_tts(text)
Orchestrator->>generate_tts_node: route to "generate_tts"
generate_tts_node->>AudioService: generate_tts(text, user_id) -> mp3 bytes + filename
AudioService->>SupabaseStorage: upload mp3 -> signed URL
SupabaseStorage-->>AudioService: signed URL
AudioService-->>generate_tts_node: (signed_url, filename)
generate_tts_node-->>Orchestrator: ToolMessage + pending_audio payload
Orchestrator-->>Supervisor: return flow to supervisor for final response
Supervisor-->>Frontend: final message (includes pending_audio metadata)
Frontend-->>User: render audio player with signed URL
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/app/features/audio/schemas.py`:
- Around line 9-11: The text_preview field in the schema lacks length
constraints; update the schema in schemas.py to import pydantic.Field and change
text_preview: str to text_preview: str = Field(..., min_length=1,
max_length=1024) (choose an appropriate max if 1024 is too large) so
user-derived content is bounded; ensure the Field import is added and leave
signed_url / filename unchanged unless they are also user-input — in that case
apply similar Field(..., min_length=1, max_length=N) constraints to those
symbols.
In `@backend/app/features/audio/service.py`:
- Around line 68-73: Wrap the HTTP call using httpx.AsyncClient (the block that
posts to f"{settings.openrouter_base_url}/audio/speech" with json=payload and
headers=headers) in a try/except that catches httpx.RequestError and
httpx.HTTPStatusError (or Exception), obtain a module logger via
logging.getLogger(__name__), log the exception with context (include URL,
payload summary or headers as appropriate), and then convert or re-raise into
the service-level error contract (e.g., raise a custom exception or return an
error result) so callers receive a consistent error; update the async function
containing this request to use that try/except around the client.post call and
ensure response handling occurs only on success.
- Around line 57-62: The payload currently sends raw user `text`; call the
privacy masker and send the masked value instead: import and use mask_pii(text)
(from core/privacy.py) before building the `payload`, assign the result to a new
variable (e.g., `masked_text`) and replace `input: text` with `input:
masked_text`; ensure any downstream uses in the same function (e.g., in the TTS
call that builds `payload`) reference the masked variable so no raw user input
is forwarded to OpenRouter.
In `@backend/app/features/audio/tool.py`:
- Around line 91-95: The success log in generate_tts_node currently logs a raw
user identifier via logger.info("[generate_tts_node] audio ready filename=%s
user=%s", filename, user_id); remove or redact the sensitive user_id from the
log (e.g., log only filename and a non-identifying status or a hashed/obfuscated
id) so that generate_tts_node no longer emits raw PII; update the logger.info
call to exclude user_id (or replace with a safe token) and ensure any callers or
tests expecting the old message are adjusted accordingly.
In `@supabase/migrations/20260512100000_create_audio_bucket_and_policies.sql`:
- Line 20: Update the RLS policy predicates to use a subquery form of auth.uid()
instead of the bare function: replace occurrences of (auth.uid()) with (select
auth.uid()) in the policy expressions (e.g., the clause comparing
(storage.foldername(name))[1] to auth.uid()). Specifically modify the
predicate(s) involving storage.foldername(name) and auth.uid() so they read like
(storage.foldername(name))[1] = (select auth.uid())::text; apply the same
replacement at the other affected predicates referenced in the comment.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: bfbcd21f-1fe5-41ad-b29f-a8ee3147a095
📒 Files selected for processing (12)
backend/app/agents/orchestrator.pybackend/app/agents/state.pybackend/app/agents/supervisor.pybackend/app/core/config.pybackend/app/features/audio/__init__.pybackend/app/features/audio/schemas.pybackend/app/features/audio/service.pybackend/app/features/audio/tool.pybackend/app/features/chat/service.pyfrontend/features/chat/components/ChatWindow.tsxfrontend/features/chat/components/MessageBubble.tsxsupabase/migrations/20260512100000_create_audio_bucket_and_policies.sql
- schemas.py: add Field(min_length=1, max_length=1024) to text_preview - service.py: mask_pii(text) before sending to OpenRouter TTS endpoint - service.py: wrap httpx call in try/except HTTPStatusError+RequestError with structured logging; use response.raise_for_status() on success path - tool.py: remove raw user_id from logger.info (PII leak) - migration: replace auth.uid() with (select auth.uid()) in all three RLS policy predicates to avoid per-row re-evaluation (Supabase best practice)
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/app/features/audio/service.py`:
- Around line 92-95: The log statements currently emit the constructed filename
which includes raw user_id (see the filename variable and logger.info calls in
generate_tts and related places), leaking PII; change these logger.info calls to
omit or redact user_id — e.g., log only the file size and the generated uuid (or
a redacted filename like "<redacted_user>/{uuid}.mp3" or just the uuid string
from uuid4()), and ensure any other logger.info calls that reference filename or
user_id (the occurrences around the other noted lines) are updated similarly to
avoid logging raw user identifiers.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 9b22c5e9-f675-4f0e-bb97-97a74a4ba310
📒 Files selected for processing (4)
backend/app/features/audio/schemas.pybackend/app/features/audio/service.pybackend/app/features/audio/tool.pysupabase/migrations/20260512100000_create_audio_bucket_and_policies.sql
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
backend/app/features/audio/service.py (1)
123-137:⚠️ Potential issue | 🟠 Major | ⚡ Quick winPII leakage in storage logs (
filenamecontainsuser_id)Line 123, Line 133, and Line 137 still log
filename, which includes rawuser_id({user_id}/{uuid}.mp3).Suggested fix
async def upload_audio(audio_bytes: bytes, filename: str) -> str: @@ - try: + object_name = filename.rsplit("/", 1)[-1] + try: await client.storage.from_(bucket).upload( path=filename, file=audio_bytes, file_options={"content-type": "audio/mpeg"}, ) except Exception as exc: - logger.exception("[upload_audio] Storage upload failed filename=%s", filename) + logger.exception("[upload_audio] Storage upload failed object=%s", object_name) raise RuntimeError("Failed to upload audio to storage.") from exc @@ except Exception as exc: logger.exception( - "[upload_audio] Failed to create signed URL filename=%s", filename + "[upload_audio] Failed to create signed URL object=%s", object_name ) raise RuntimeError("Audio uploaded but could not generate a signed URL.") from exc - logger.info("[upload_audio] signed URL created filename=%s", filename) + logger.info("[upload_audio] signed URL created object=%s", object_name) return signed_urlAs per coding guidelines,
backend/**/*.py:Never log sensitive data (tokens, passwords, PII).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/app/features/audio/service.py` around lines 123 - 137, The logs in upload_audio (the logger.exception calls around the storage upload and create_signed_url and the logger.info after signed URL creation) currently include filename which embeds user_id (PII); remove or replace raw filename in those log calls with a non-PII value — e.g., compute a safe identifier (use only the basename/UUID part or replace the leading user_id segment with "<redacted>") and log that instead, or omit the filename entirely in the logger.exception and logger.info calls for create_signed_url and storage upload so no user_id is emitted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/app/features/audio/service.py`:
- Around line 90-97: The code assigns response.content to audio_bytes and
returns it without validation; update the block around response, audio_bytes and
filename (where uuid4() and logger.info are used) to validate the TTS payload:
ensure response.status_code is 200, response.content is non-empty, and
Content-Type indicates audio (e.g., startswith "audio/") and/or verify MP3 magic
bytes (ID3 or 0xFF 0xFB) before constructing filename and returning; on
validation failure log a descriptive error with response.status_code and headers
(using logger.error) and raise or return an explicit error/None instead of
returning/uploading a broken file.
---
Duplicate comments:
In `@backend/app/features/audio/service.py`:
- Around line 123-137: The logs in upload_audio (the logger.exception calls
around the storage upload and create_signed_url and the logger.info after signed
URL creation) currently include filename which embeds user_id (PII); remove or
replace raw filename in those log calls with a non-PII value — e.g., compute a
safe identifier (use only the basename/UUID part or replace the leading user_id
segment with "<redacted>") and log that instead, or omit the filename entirely
in the logger.exception and logger.info calls for create_signed_url and storage
upload so no user_id is emitted.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 7196585c-9bb5-4525-adc4-7265a3e19b3e
📒 Files selected for processing (1)
backend/app/features/audio/service.py
…udio logs - Validate response content: check non-empty + content-type starts with 'audio/' or MP3 magic bytes (ID3 / 0xFF sync word); raise RuntimeError with status/content-type/size logged on failure - Replace raw filename (embeds user_id) in all upload_audio logger calls with safe_log_name (UUID basename only)
Add generate_tts LangGraph tool + node (features/audio/)
Wire generate_tts into supervisor + orchestrator
Config: tts_model=openai/gpt-4o-mini-tts-2025-12-15, tts_voice, audio_bucket
State: add pending_audio field for cross-turn save confirmation (Phase 10D)
Emit Generating audio... SSE tool_use event from chat service
Frontend: inline player for .mp3 links in MessageBubble
Frontend: purple Volume2 icon in ChatWindow during TTS tool_use status
Supabase migration: audio bucket with RLS policies scoped to {user_id}/*
Summary by CodeRabbit
New Features
Improvements