170 lines
5.0 KiB
Python
170 lines
5.0 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"
|
|
|
|
|
|
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
|