from __future__ import annotations import asyncio import base64 import json import os import tempfile from pathlib import Path from typing import Any import httpx from dotenv import load_dotenv from helpers import summarize_error load_dotenv() OPENROUTER_BASE = "https://openrouter.ai/api/v1" def _api_key() -> str: return os.getenv("OPENROUTER_API_KEY", "") def _auth_headers() -> dict[str, str]: api_key = _api_key() if not api_key: raise ValueError("OPENROUTER_API_KEY not set") return { "Authorization": f"Bearer {api_key}", } def _audio_format(audio_path: str) -> str: suffix = Path(audio_path).suffix.lower().lstrip(".") return suffix or "wav" def _build_transcription_payload(audio_path: str) -> dict[str, Any]: with open(audio_path, "rb") as audio_file: encoded = base64.b64encode(audio_file.read()).decode("ascii") return { "model": "openai/whisper-large-v3", "input_audio": { "data": encoded, "format": _audio_format(audio_path), }, "language": "en", } async def _normalize_audio_for_transcription(audio_path: str) -> str: source = Path(audio_path) fd, normalized_path = tempfile.mkstemp(prefix="meeting-normalized-", suffix=".wav") os.close(fd) proc = await asyncio.create_subprocess_exec( "ffmpeg", "-y", "-i", str(source), "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", normalized_path, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, ) _, stderr = await proc.communicate() if proc.returncode != 0: try: os.remove(normalized_path) except OSError: pass raise RuntimeError( "Audio normalization failed: " + (stderr.decode("utf-8", errors="replace").strip() or f"ffmpeg exited {proc.returncode}") ) return normalized_path async def transcribe(audio_path: str) -> str: """Send audio to OpenRouter's whisper model and return transcript text.""" headers = _auth_headers() headers["Content-Type"] = "application/json" normalized_path = await _normalize_audio_for_transcription(audio_path) try: async with httpx.AsyncClient(timeout=300) as client: resp = await client.post( f"{OPENROUTER_BASE}/audio/transcriptions", headers=headers, content=json.dumps(_build_transcription_payload(normalized_path)), ) try: resp.raise_for_status() except httpx.HTTPStatusError as exc: detail = summarize_error(_safe_json(resp), fallback=resp.text) raise RuntimeError( f"OpenRouter transcription failed ({resp.status_code}): {detail}" ) from exc data = resp.json() text = data.get("text", "") if not text.strip(): raise RuntimeError("OpenRouter transcription returned empty text") return text.strip() finally: try: os.remove(normalized_path) except OSError: pass async def summarize(transcript: str) -> str: """Send transcript to DeepSeek via OpenRouter and return structured meeting summary.""" prompt = f"""You are a meeting summarizer. Given the following meeting transcript, produce a concise, well-structured summary with: 1. **Overview** — 2-3 sentences covering what was discussed 2. **Key Decisions** — decisions that were made 3. **Action Items** — who needs to do what (use bullet points) 4. **Next Steps / Deadlines** — any dates, timelines, or follow-ups mentioned If speaker labels are present, preserve them where helpful in action items. Transcript: {transcript[:16000]}""" body = { "model": "@preset/cheap-deepseek-v4-flash", "messages": [{"role": "user", "content": prompt}], "max_tokens": 1500, } headers = _auth_headers() headers["Content-Type"] = "application/json" async with httpx.AsyncClient(timeout=180) as client: resp = await client.post( f"{OPENROUTER_BASE}/chat/completions", headers=headers, json=body, ) try: resp.raise_for_status() except httpx.HTTPStatusError as exc: detail = summarize_error(_safe_json(resp), fallback=resp.text) raise RuntimeError(f"OpenRouter summarization failed: {detail}") from exc data = resp.json() try: content = data["choices"][0]["message"]["content"] except (KeyError, IndexError, TypeError) as exc: raise RuntimeError("Unexpected OpenRouter summarization response shape") from exc if not isinstance(content, str) or not content.strip(): raise RuntimeError("OpenRouter summarization returned empty content") return content.strip() def _safe_json(resp: httpx.Response) -> Any: try: return resp.json() except Exception: return None