"""FlowDeck — AI Meeting Notes v2 (v7.1.0). Upload audio → optional server transcription (``STT_COMMAND``, e.g. whisper) → AI summary (``AIWritingService.summarize``, offline-capable) → fires ``meeting.summarized`` so custom agents pick it up (Notion 07/2026 pattern). Without ``STT_COMMAND`` the server stores the audio and accepts a manual ``transcript`` (client-side transcription). Nothing here requires new pip dependencies. See ``docs/V71_Calendar_Meetings.md``. """ from __future__ import annotations import logging import os import shutil import subprocess from pathlib import Path from app.config import settings from app.db import get_conn logger = logging.getLogger(__name__) AUDIO_EXTENSIONS = {"mp3", "wav", "m4a", "ogg", "flac", "aac"} MAX_AUDIO_BYTES = 100 * 1024 * 1024 class TranscriptionUnavailable(RuntimeError): """Raised when no transcription backend is configured.""" def meetings_dir() -> Path: root = Path(settings.data_dir) d = root / "uploads" / "meetings" d.mkdir(parents=True, exist_ok=True) return d def save_transcript(page_id: int, transcript: str, language: str = "fr", audio_path: str = "") -> int: with get_conn() as conn: if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone(): raise ValueError("page not found") cur = conn.execute( """INSERT INTO meeting_transcripts (page_id, audio_path, transcript, language) VALUES (?,?,?,?)""", (page_id, audio_path, transcript or "", language or "fr")) conn.commit() return cur.lastrowid def transcribe_audio(audio_path: str, language: str = "fr") -> str: """Transcribe with ``STT_COMMAND`` (``{cmd} {file}` → stdout text). Example: ``STT_COMMAND="whisper --language fr --output_format txt --output_dir /tmp"`` (command must print or be adapted — stdout is preferred). Raises :class:`TranscriptionUnavailable` when unconfigured. """ cmd_template = os.environ.get("STT_COMMAND", "").strip() if not cmd_template: raise TranscriptionUnavailable( "no transcription backend (set STT_COMMAND or POST a manual transcript)") if shutil.which(cmd_template.split()[0]) is None: raise TranscriptionUnavailable(f"STT command not found: {cmd_template.split()[0]}") try: proc = subprocess.run(cmd_template.split() + [audio_path], # noqa: S603 — admin-configured capture_output=True, text=True, timeout=600) except subprocess.TimeoutExpired as exc: raise TranscriptionUnavailable("transcription timed out") from exc text = (proc.stdout or "").strip() if proc.returncode != 0 or not text: raise TranscriptionUnavailable( f"transcription failed: {(proc.stderr or '')[:300]}") return text async def summarize_transcript(transcript_id: int, user_id: int | None = None) -> dict: """Summarize a stored transcript + fire ``meeting.summarized``. Returns {transcript_id, summary, offline}. Emits the automation event so custom agents (update tracker, post recap, file tickets) trigger. """ from app.services.ai_writing import AIWritingService with get_conn() as conn: row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", (transcript_id,)).fetchone() if not row: raise ValueError("transcript not found") tr = dict(row) if not (tr.get("transcript") or "").strip(): raise ValueError("transcript is empty — transcribe first") svc = AIWritingService(user_id=user_id) res = await svc.run("summarize", context=tr["transcript"]) summary = (res.get("text") or "").strip() if res.get("ok") else "" if not summary: raise RuntimeError(f"summarization failed: {res.get('error', 'unknown')}") with get_conn() as conn: conn.execute("UPDATE meeting_transcripts SET summary=? WHERE id=?", (summary, transcript_id)) page = conn.execute("SELECT id FROM pages WHERE id=?", (tr["page_id"],)).fetchone() conn.commit() try: from app.services.automations import fire_event await fire_event("meeting.summarized", { "page_id": tr["page_id"] if page else 0, "transcript_id": transcript_id, "language": tr.get("language") or "fr", }) except Exception as exc: # noqa: BLE001 — summary stands even if dispatch fails logger.debug("meeting.summarized dispatch failed: %s", exc) return {"transcript_id": transcript_id, "summary": summary, "offline": bool(res.get("offline"))}