- A29 — `app/services/publish.py` : slugify titré unique (fallback aléatoire),
404 si la page n'existe pas, événements centralisés. Les 3 paires
publish/unpublish déléguent (sharing = front, board, v2) :
· board : mise à jour aveugle → 404 + contrôle de session ajouté
· board : perd `share_mode='anyone'` en bonus, v2 : perd `is_shared=1` —
le share dialog reste l'unique propriétaire de ces drapeaux
· v2 : slug fourni conservé, slug vidé aussi à la dépublication (avant : laissé)
· `/users/me` ×2 et listings collections ×3 = contrats versionnés distincts,
décision documentée (on garde)
- Byproduct sécurité — `GET /api/users/me` (v1) et le contexte de `/accounts`
faisaient `SELECT *` sur users → password_hash / login_attempts / locked_until
exposés → colonnes whitelistées (liste v2)
- A42 (partiel) — 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))`
→ `settings.data_dir` (property : lecture à chaque accès, les tests
monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à chaque
écriture. Reste : client httpx partagé (52 créations, cache par event loop)
tests : test_publish_service_shared_and_safe, test_users_me_no_secret_columns,
test_gitea_cache_evicts_expired
suite **1034/1034** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.5.0
docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
114 lines
4.6 KiB
Python
114 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.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"))}
|