- v6.8.0 Sites & Forms publics (migrations 24)
- v6.9.0 Recherche sémantique hybride + Ask AI (migration 25)
- v7.0.0 Automations v2 multi-étapes + Workers sandboxés (migration 26)
- v7.1.0 Calendar sync Google/CalDAV + Meeting Notes (migration 27)
- v7.2.0 Enterprise : SCIM 2.0, 2FA TOTP/passkeys, audit UI, agent approvals (migration 28)
- v7.3.0 Wiki/Teamspaces, verified pages, collab polish, charts, unfurl (migration 29)
- docs V68→V73, ROADMAP/CHANGELOG/WORKLOAD à jour, VERSION 7.3.0
- A9 : flowdeck.db, flowdeck_dev.db, test-commit.md, upload_test.txt et e2e/{node_modules,shots,test-results} désindexés + ignorés (.gitignore/.dockerignore)
113 lines
4.6 KiB
Python
113 lines
4.6 KiB
Python
"""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.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(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
|
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"))}
|