Files

183 lines
5.5 KiB
Python

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"
TRANSCRIPTION_MODELS = [
"openai/gpt-4o-mini-transcribe",
"openai/whisper-large-v3",
]
def _build_transcription_payload(audio_path: str, model: str) -> dict[str, Any]:
with open(audio_path, "rb") as audio_file:
encoded = base64.b64encode(audio_file.read()).decode("ascii")
return {
"model": model,
"input_audio": {
"data": encoded,
"format": _audio_format(audio_path),
},
}
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 STT models and return transcript text."""
headers = _auth_headers()
headers["Content-Type"] = "application/json"
headers["X-OpenRouter-Title"] = "discord-meeting-bot"
normalized_path = await _normalize_audio_for_transcription(audio_path)
failures: list[str] = []
try:
async with httpx.AsyncClient(timeout=300) as client:
for model in TRANSCRIPTION_MODELS:
resp = await client.post(
f"{OPENROUTER_BASE}/audio/transcriptions",
headers=headers,
json=_build_transcription_payload(normalized_path, model),
)
try:
resp.raise_for_status()
except httpx.HTTPStatusError:
detail = summarize_error(_safe_json(resp), fallback=resp.text)
generation_id = resp.headers.get("x-generation-id")
suffix = f"; generation_id={generation_id}" if generation_id else ""
failures.append(f"{model}: HTTP {resp.status_code}: {detail}{suffix}")
continue
data = resp.json()
text = data.get("text", "")
if text.strip():
return text.strip()
failures.append(f"{model}: returned empty text")
raise RuntimeError(
"OpenRouter transcription failed across all models: " + " | ".join(failures)
)
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