fix: /local-workspace — chemin du header + clic sur un dossier du sidebar (v7.69.9)
Corrige deux régressions de /local-workspace :
- le chemin du header restait bloqué sur « Home / <workspace> » quel que
soit le dossier affiché : la route rend désormais breadcrumb_items
(Home / <workspace> / <dossier> / <sous-dossier>, niveaux cliquables,
collapse « … » au-delà de 4) et la navigation sans rechargement recalcule
le chemin via l'event flowdeck:breadcrumb-changed ;
- le clic sur un dossier du sidebar affichait TOUS les composants à la
fois : Alpine.data('wsInitData') retournait le même objet singleton, le
2e montage (navigation partielle) levait « Cannot redefine property:
\ » et initTree abandonnait, laissant tout le contenu au state
brut. La factory retourne désormais une enveloppe fraîche par montage
qui délègue à l'état réactif partagé. #lw-config est aussi relu à chaque
exécution (le 2e montage gardait le folder_id du 1er chargement).
Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
This commit is contained in:
+224
-23
@@ -66,28 +66,226 @@ def _block_text(b: dict) -> str:
|
||||
# ── Source resolution: read real textual content for ANY page type ──
|
||||
|
||||
# Extensions whose content is plain text / code / markdown (textual exportable).
|
||||
# Mirrors static/js/code-highlight.js EXT_TO_LANG (single source of truth for
|
||||
# the viewer lives client-side; this set gates server-side text handling:
|
||||
# file viewer, copy-to-clipboard, exports, imports).
|
||||
_TEXTUAL_EXTS = {
|
||||
"md", "markdown", "txt", "log", "text",
|
||||
"py", "js", "ts", "jsx", "tsx", "html", "htm", "css", "json", "xml",
|
||||
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "sh", "bash", "zsh",
|
||||
"ps1", "bat", "cmd", "rb", "go", "rs", "java", "c", "cpp", "h", "hpp",
|
||||
"php", "swift", "kt", "scala", "sql", "r", "vue", "svelte", "astro",
|
||||
"properties", "gitignore", "dockerfile", "makefile",
|
||||
"md", "markdown", "mkd", "mdown", "mdwn", "rmd", "qmd",
|
||||
"txt", "log", "text", "csv", "tsv", "tab", "nfo", "asc",
|
||||
"py", "pyw", "pyi", "gyp", "gypi",
|
||||
"js", "mjs", "cjs", "jsx", "ts", "mts", "cts", "tsx",
|
||||
"html", "htm", "xhtml", "xht", "xml", "xsd", "xsl", "xslt",
|
||||
"rss", "atom", "plist", "wsf", "svg", "axml",
|
||||
"vue", "svelte", "astro",
|
||||
"css", "scss", "sass", "less",
|
||||
"json", "json5", "jsonc", "geojson", "webmanifest", "har", "avsc",
|
||||
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "properties",
|
||||
"inf", "reg", "url", "prop",
|
||||
"gitconfig", "gitmodules", "editorconfig",
|
||||
"sh", "bash", "zsh", "ksh", "mksh", "yash", "fish",
|
||||
"ps1", "psm1", "psd1", "bat", "cmd",
|
||||
"rb", "gemspec", "rake", "rbw",
|
||||
"go", "rs", "java", "jsp", "kt", "kts",
|
||||
"scala", "sc", "groovy", "gvy", "gradle",
|
||||
"c", "cpp", "cc", "cxx", "c++", "hh", "hxx", "hcc", "h", "hpp",
|
||||
"cs", "csx", "cake", "ino", "pde",
|
||||
"php", "phtml", "swift", "sql", "pgsql", "psql", "mysql", "mssql",
|
||||
"r", "jl", "lua", "dart",
|
||||
"pl", "pm", "pod",
|
||||
"hs", "lhs", "ex", "exs", "eex", "heex", "leex",
|
||||
"erl", "hrl", "escript",
|
||||
"clj", "cljs", "cljc", "edn",
|
||||
"ml", "mli", "fs", "fsi", "fsx", "fsscript",
|
||||
"nim", "nims", "d", "di",
|
||||
"m", "mm", "vb", "vbs", "bas", "pas", "pp", "dpr",
|
||||
"vhd", "vhdl", "v", "vh", "sv", "svh",
|
||||
"asm", "s", "nasm", "inc",
|
||||
"ada", "ads", "adb",
|
||||
"f", "for", "f90", "f95", "f03", "f08",
|
||||
"cob", "cbl", "cpy",
|
||||
"dockerfile", "containerfile", "tf", "tfvars", "hcl",
|
||||
"cmake", "makefile", "mk", "mak", "ninja",
|
||||
"gql", "graphql", "graphqls", "proto", "thrift",
|
||||
"vim", "vimrc", "gvimrc", "awk", "gcode", "gco", "nc", "ngc",
|
||||
"http", "rest", "nginx", "apache", "htaccess", "wat",
|
||||
"diff", "patch", "rej",
|
||||
"tex", "latex", "sty", "cls", "dtx", "bib", "bbl",
|
||||
"gitignore", "gitattributes", "gitkeep", "dockerignore", "npmignore",
|
||||
"lock", "mod", "sum",
|
||||
}
|
||||
# Well-known extensionless / dotfile basenames that are textual code.
|
||||
_TEXTUAL_FILENAMES = {
|
||||
"dockerfile", "containerfile", "makefile", "gnumakefile",
|
||||
"cmakelists.txt", "rakefile", "gemfile", "vagrantfile",
|
||||
"berksfile", "brewfile", "dangerfile", "fastfile", "podfile",
|
||||
"go.mod", "go.sum",
|
||||
".bashrc", ".bash_profile", ".bash_login", ".bash_logout",
|
||||
".zshrc", ".zshenv", ".profile", ".shrc", ".kshrc",
|
||||
".vimrc", ".gvimrc", ".viminfo",
|
||||
".gitconfig", ".editorconfig", ".htaccess",
|
||||
}
|
||||
# Exact basenames → highlight.js id (checked before the extension).
|
||||
_FILENAME_LANG = {
|
||||
"dockerfile": "dockerfile", "containerfile": "dockerfile",
|
||||
"makefile": "makefile", "gnumakefile": "makefile",
|
||||
"cmakelists.txt": "cmake",
|
||||
"rakefile": "ruby", "gemfile": "ruby", "vagrantfile": "ruby",
|
||||
"berksfile": "ruby", "brewfile": "ruby", "dangerfile": "ruby",
|
||||
"fastfile": "ruby", "podfile": "ruby",
|
||||
"go.mod": "go",
|
||||
"nginx.conf": "nginx",
|
||||
".bashrc": "bash", ".bash_profile": "bash", ".bash_login": "bash",
|
||||
".bash_logout": "bash", ".zshrc": "bash", ".zshenv": "bash",
|
||||
".profile": "bash", ".shrc": "bash", ".kshrc": "bash",
|
||||
".vimrc": "vim", ".gvimrc": "vim",
|
||||
".gitconfig": "ini", ".editorconfig": "ini", ".htaccess": "apache",
|
||||
}
|
||||
_CODE_LANG = {
|
||||
"py": "python", "js": "javascript", "ts": "typescript", "jsx": "javascript",
|
||||
"tsx": "typescript", "html": "html", "htm": "html", "css": "css",
|
||||
"json": "json", "xml": "xml", "yaml": "yaml", "yml": "yaml",
|
||||
"toml": "toml", "ini": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
|
||||
"sh": "bash", "bash": "bash", "zsh": "bash", "ps1": "powershell",
|
||||
"bat": "batch", "cmd": "batch", "rb": "ruby", "go": "go", "rs": "rust",
|
||||
"java": "java", "c": "c", "cpp": "cpp", "h": "c", "hpp": "cpp",
|
||||
"php": "php", "swift": "swift", "kt": "kotlin", "scala": "scala",
|
||||
"sql": "sql", "r": "r", "vue": "html", "svelte": "html",
|
||||
"astro": "html", "properties": "ini", "md": "markdown",
|
||||
"markdown": "markdown", "txt": "plaintext", "log": "plaintext",
|
||||
"text": "plaintext",
|
||||
"py": "python", "pyw": "python", "pyi": "python",
|
||||
"gyp": "python", "gypi": "python",
|
||||
"js": "javascript", "mjs": "javascript", "cjs": "javascript",
|
||||
"jsx": "javascript",
|
||||
"ts": "typescript", "mts": "typescript", "cts": "typescript",
|
||||
"tsx": "typescript",
|
||||
"html": "xml", "htm": "xml", "xhtml": "xml", "xht": "xml",
|
||||
"xml": "xml", "xsd": "xml", "xsl": "xml", "xslt": "xml",
|
||||
"rss": "xml", "atom": "xml", "plist": "xml", "wsf": "xml",
|
||||
"svg": "xml", "axml": "xml",
|
||||
"vue": "xml", "svelte": "xml", "astro": "xml",
|
||||
"css": "css", "scss": "scss", "sass": "scss", "less": "less",
|
||||
"json": "json", "json5": "json", "jsonc": "json",
|
||||
"geojson": "json", "webmanifest": "json", "har": "json", "avsc": "json",
|
||||
"yaml": "yaml", "yml": "yaml",
|
||||
"toml": "ini", "ini": "ini", "cfg": "ini", "conf": "ini",
|
||||
"env": "ini", "properties": "ini", "prop": "ini",
|
||||
"inf": "ini", "reg": "ini", "url": "ini",
|
||||
"gitconfig": "ini", "gitmodules": "ini", "editorconfig": "ini",
|
||||
"ninja": "ini",
|
||||
"md": "markdown", "markdown": "markdown", "mkd": "markdown",
|
||||
"mdown": "markdown", "mdwn": "markdown", "rmd": "markdown",
|
||||
"qmd": "markdown",
|
||||
"tex": "latex", "latex": "latex", "sty": "latex", "cls": "latex",
|
||||
"dtx": "latex",
|
||||
"sql": "sql", "pgsql": "sql", "psql": "sql", "mysql": "sql",
|
||||
"mssql": "sql",
|
||||
"sh": "bash", "bash": "bash", "zsh": "bash", "ksh": "bash",
|
||||
"mksh": "bash", "yash": "bash",
|
||||
"fish": "shell",
|
||||
"ps1": "powershell", "psm1": "powershell", "psd1": "powershell",
|
||||
"bat": "dos", "cmd": "dos",
|
||||
"rb": "ruby", "gemspec": "ruby", "rake": "ruby", "rbw": "ruby",
|
||||
"go": "go", "rs": "rust",
|
||||
"java": "java", "jsp": "java",
|
||||
"kt": "kotlin", "kts": "kotlin",
|
||||
"scala": "scala", "sc": "scala",
|
||||
"groovy": "groovy", "gvy": "groovy", "gradle": "groovy",
|
||||
"c": "c", "h": "c",
|
||||
"cpp": "cpp", "cc": "cpp", "cxx": "cpp", "c++": "cpp",
|
||||
"hh": "cpp", "hxx": "cpp", "hcc": "cpp", "hpp": "cpp",
|
||||
"ino": "cpp", "pde": "cpp",
|
||||
"cs": "csharp", "csx": "csharp", "cake": "csharp",
|
||||
"php": "php", "phtml": "php-template",
|
||||
"swift": "swift", "r": "r", "jl": "julia", "lua": "lua",
|
||||
"dart": "dart", "pl": "perl", "pm": "perl", "pod": "perl",
|
||||
"hs": "haskell", "lhs": "haskell",
|
||||
"ex": "elixir", "exs": "elixir", "eex": "elixir",
|
||||
"heex": "elixir", "leex": "elixir",
|
||||
"erl": "erlang", "hrl": "erlang", "escript": "erlang",
|
||||
"clj": "clojure", "cljs": "clojure", "cljc": "clojure", "edn": "clojure",
|
||||
"ml": "ocaml", "mli": "ocaml",
|
||||
"fs": "fsharp", "fsi": "fsharp", "fsx": "fsharp", "fsscript": "fsharp",
|
||||
"nim": "nim", "nims": "nim",
|
||||
"d": "d", "di": "d",
|
||||
"m": "objectivec", "mm": "objectivec",
|
||||
"vb": "vbnet", "vbs": "vbnet", "bas": "vbnet",
|
||||
"pas": "delphi", "pp": "delphi", "dpr": "delphi",
|
||||
"vhd": "vhdl", "vhdl": "vhdl",
|
||||
"v": "verilog", "vh": "verilog", "sv": "verilog", "svh": "verilog",
|
||||
"asm": "x86asm", "s": "x86asm", "nasm": "x86asm", "inc": "x86asm",
|
||||
"ada": "ada", "ads": "ada", "adb": "ada",
|
||||
"f": "fortran", "for": "fortran", "f90": "fortran", "f95": "fortran",
|
||||
"f03": "fortran", "f08": "fortran",
|
||||
"cob": "plaintext", "cbl": "plaintext", "cpy": "plaintext",
|
||||
"dockerfile": "dockerfile",
|
||||
"tf": "plaintext", "tfvars": "plaintext", "hcl": "plaintext",
|
||||
"cmake": "cmake",
|
||||
"makefile": "makefile", "mk": "makefile", "mak": "makefile",
|
||||
"gql": "graphql", "graphql": "graphql", "graphqls": "graphql",
|
||||
"proto": "protobuf", "thrift": "thrift",
|
||||
"vim": "vim", "vimrc": "vim", "gvimrc": "vim",
|
||||
"awk": "awk",
|
||||
"gcode": "gcode", "gco": "gcode", "nc": "gcode", "ngc": "gcode",
|
||||
"http": "http", "rest": "http",
|
||||
"nginx": "nginx", "apache": "apache", "htaccess": "apache",
|
||||
"wat": "wasm",
|
||||
"diff": "diff", "patch": "diff", "rej": "diff",
|
||||
"bib": "plaintext", "bbl": "plaintext",
|
||||
"txt": "plaintext", "log": "plaintext", "text": "plaintext",
|
||||
"csv": "plaintext", "tsv": "plaintext", "tab": "plaintext",
|
||||
"nfo": "plaintext", "asc": "plaintext",
|
||||
"lock": "plaintext",
|
||||
"gitignore": "plaintext", "gitattributes": "plaintext",
|
||||
"gitkeep": "plaintext", "dockerignore": "plaintext",
|
||||
"npmignore": "plaintext",
|
||||
}
|
||||
# Aliases users may type in markdown fences / old block labels.
|
||||
_CODE_ALIASES = {
|
||||
"py": "python", "js": "javascript", "jsx": "javascript",
|
||||
"ts": "typescript", "tsx": "typescript",
|
||||
"html": "xml", "htm": "xml", "markup": "xml",
|
||||
"yml": "yaml", "md": "markdown",
|
||||
"sh": "bash", "zsh": "bash", "console": "shell",
|
||||
"ps1": "powershell", "ps": "powershell",
|
||||
"bat": "dos", "cmd": "dos", "batch": "dos",
|
||||
"c++": "cpp", "c#": "csharp", "cs": "csharp",
|
||||
"kt": "kotlin", "rb": "ruby", "rs": "rust", "pl": "perl",
|
||||
"toml": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
|
||||
"tex": "latex", "docker": "dockerfile", "make": "makefile",
|
||||
"plain text": "plaintext", "plain": "plaintext", "text": "plaintext",
|
||||
"none": "plaintext", "": "plaintext",
|
||||
}
|
||||
# highlight.js ids served by static/js/highlight.min.js + highlight-extra.min.js.
|
||||
_HLJS_LANGS = frozenset({
|
||||
"ada", "apache", "armasm", "awk", "bash", "c", "clojure", "cmake", "cpp",
|
||||
"csharp", "css", "d", "dart", "delphi", "diff", "dockerfile", "dos",
|
||||
"elixir", "erlang", "fsharp", "fortran", "gcode", "go",
|
||||
"graphql", "groovy", "haskell", "http", "ini", "java", "javascript",
|
||||
"json", "julia", "kotlin", "latex", "less", "lua", "makefile",
|
||||
"markdown", "matlab", "nginx", "nim", "objectivec", "ocaml", "perl",
|
||||
"php", "php-template", "plaintext", "powershell", "protobuf", "python",
|
||||
"python-repl", "r", "ruby", "rust", "scala", "scss", "shell", "sql",
|
||||
"stan", "stata", "swift", "thrift", "typescript", "vbnet", "verilog",
|
||||
"vhdl", "vim", "wasm", "x86asm", "xml", "yaml",
|
||||
})
|
||||
|
||||
|
||||
def normalize_code_lang(lang: str | None) -> str:
|
||||
"""Normalize any code language label to a bundled highlight.js id.
|
||||
|
||||
Accepts highlight.js ids, file extensions, markdown fence names and the
|
||||
legacy ``Plain Text`` label. Unknown values degrade to ``plaintext``.
|
||||
"""
|
||||
lang = (lang or "").strip().lower()
|
||||
if not lang or lang in ("plain text", "none"):
|
||||
return "plaintext"
|
||||
if lang in _CODE_ALIASES:
|
||||
lang = _CODE_ALIASES[lang]
|
||||
if lang in _CODE_LANG.values() or lang in _HLJS_LANGS:
|
||||
return lang
|
||||
return "plaintext"
|
||||
|
||||
|
||||
def code_lang_for_file(filename: str | None) -> str:
|
||||
"""Best highlight.js id for an uploaded / repo file name."""
|
||||
name = (filename or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
|
||||
if not name:
|
||||
return "plaintext"
|
||||
if name in _FILENAME_LANG:
|
||||
return _FILENAME_LANG[name]
|
||||
ext = name.rsplit(".", 1)[-1] if "." in name else ""
|
||||
return _CODE_LANG.get(ext, "plaintext")
|
||||
|
||||
|
||||
_MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream"}
|
||||
|
||||
|
||||
@@ -119,7 +317,8 @@ def _file_text(page: dict) -> str | None:
|
||||
name = (rel.rsplit("/", 1)[-1] or "").lower()
|
||||
ext = name.rsplit(".", 1)[-1] if "." in name else ""
|
||||
mime = (meta.get("mime_type") or "").lower()
|
||||
if not (ext in _TEXTUAL_EXTS or mime.startswith("text/")):
|
||||
if not (ext in _TEXTUAL_EXTS or name in _TEXTUAL_FILENAMES
|
||||
or mime.startswith("text/")):
|
||||
return None
|
||||
try:
|
||||
full = (_data_root() / rel).resolve()
|
||||
@@ -161,8 +360,7 @@ def _page_source(page: dict):
|
||||
mime = (meta.get("mime_type") or "").lower()
|
||||
if ext in ("md", "markdown") or mime in _MARKDOWN_MIMES or mime.startswith("text/markdown"):
|
||||
return "md", text
|
||||
lang = _CODE_LANG.get(ext, "plaintext")
|
||||
return "code", (text, lang)
|
||||
return "code", (text, code_lang_for_file(name))
|
||||
|
||||
# Unknown format (e.g. legacy) -> try to dump as raw text
|
||||
if content.strip():
|
||||
@@ -497,7 +695,7 @@ def blocks_to_markdown(blocks: list) -> str:
|
||||
out.append("---")
|
||||
elif t == "code":
|
||||
lang = b.get("language") or ""
|
||||
out.append(f"```{lang}\n{c}\n```")
|
||||
out.append(f"```{normalize_code_lang(lang) if lang else ''}\n{c}\n```")
|
||||
elif t == "toggle":
|
||||
out.append(f"### {c}")
|
||||
if b.get("children"):
|
||||
@@ -615,8 +813,11 @@ def blocks_to_html(blocks: list) -> str:
|
||||
parts.append("<hr>")
|
||||
elif t == "code":
|
||||
lang = b.get("language") or ""
|
||||
hljs_lang = normalize_code_lang(lang)
|
||||
label = f'<div class="code-lang">{_text(lang)}</div>' if lang else ""
|
||||
parts.append(f"<pre>{label}<code>{c}</code></pre>")
|
||||
parts.append(
|
||||
f'<pre>{label}<code class="language-{hljs_lang}">{c}</code></pre>'
|
||||
)
|
||||
elif t == "math":
|
||||
parts.append(f'<div class="math">\\[{c}\\]</div>')
|
||||
elif t == "table_of_contents":
|
||||
|
||||
@@ -7,7 +7,11 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.export import _CODE_LANG, _TEXTUAL_EXTS
|
||||
from app.services.export import (
|
||||
_TEXTUAL_EXTS,
|
||||
_TEXTUAL_FILENAMES,
|
||||
code_lang_for_file,
|
||||
)
|
||||
from app.services.importers.base import ImportPage, ImportResult
|
||||
|
||||
_MD_EXTS = {"md", "markdown"}
|
||||
@@ -17,6 +21,14 @@ def _ext(path: str) -> str:
|
||||
return Path(path).suffix.lower().lstrip(".")
|
||||
|
||||
|
||||
def _basename(path: str) -> str:
|
||||
return path.replace("\\", "/").rsplit("/", 1)[-1].lower()
|
||||
|
||||
|
||||
def _is_textual(path: str) -> bool:
|
||||
return _ext(path) in _TEXTUAL_EXTS or _basename(path) in _TEXTUAL_FILENAMES
|
||||
|
||||
|
||||
def build_repo_result(
|
||||
files: list[tuple[str, str]],
|
||||
*,
|
||||
@@ -34,7 +46,7 @@ def build_repo_result(
|
||||
if ext in _MD_EXTS:
|
||||
markdown = content
|
||||
else:
|
||||
lang = _CODE_LANG.get(ext, "")
|
||||
lang = code_lang_for_file(clean)
|
||||
markdown = f"```{lang}\n{content.rstrip()}\n```"
|
||||
parts = clean.split("/")
|
||||
result.pages.append(ImportPage(
|
||||
@@ -69,7 +81,7 @@ async def fetch_forge_repo(
|
||||
files: list[tuple[str, str]] = []
|
||||
for meta in metas:
|
||||
file_path = meta.get("path") or ""
|
||||
if _ext(file_path) not in _TEXTUAL_EXTS:
|
||||
if not _is_textual(file_path):
|
||||
continue
|
||||
if int(meta.get("size") or 0) > max_file_bytes:
|
||||
continue
|
||||
|
||||
+668
-49
@@ -1,19 +1,25 @@
|
||||
"""FlowDeck — AI Meeting Notes v2 (v7.1.0).
|
||||
"""FlowDeck — AI Meeting Notes façon Notion (v7.1.1).
|
||||
|
||||
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).
|
||||
Refonte alignée sur ``docs/architecture-meeting-notion-flowdeck.md`` (§3A/3B) :
|
||||
machine à états du bloc, révélation progressive des onglets, consentement
|
||||
bloquant journalisé, segments groupés par source, étapes Thinking persistées,
|
||||
résumé structuré avec citations obligatoires, classification de complétude,
|
||||
titre généré (diagnostic honnête si bref/incomplet), barre de partage tracée.
|
||||
|
||||
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``.
|
||||
Stockage SQLite monolithique : ``meeting_transcripts`` + colonnes JSON
|
||||
(migration 38) + ``meeting_consent_log`` / ``meeting_distributions``
|
||||
append-only. Rien n'exige de dépendance pip supplémentaire.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
@@ -24,6 +30,37 @@ logger = logging.getLogger(__name__)
|
||||
AUDIO_EXTENSIONS = {"mp3", "wav", "m4a", "ogg", "flac", "aac"}
|
||||
MAX_AUDIO_BYTES = 100 * 1024 * 1024
|
||||
|
||||
# États du bloc (§6.5) — transitions toujours via le serveur.
|
||||
BLOCK_STATES = ("idle", "recording", "paused", "processing", "done", "failed")
|
||||
CAPTURE_MODES = ("mic_only", "tab_plus_mic", "import")
|
||||
|
||||
# Instructions de résumé (§12.4) : auto + presets par type + custom libre.
|
||||
INSTRUCTIONS = {
|
||||
"auto": {"label": "Auto", "prompt": "Résumé automatique adapté au contenu détecté."},
|
||||
"standup": {"label": "Standup", "prompt": "Format standup : hier / aujourd'hui / blocages, par personne."},
|
||||
"team": {"label": "Team meeting", "prompt": "Format équipe : sujets, décisions, actions avec responsables."},
|
||||
"sales": {"label": "Sales call", "prompt": "Format commercial : besoin, objections, next steps, montant."},
|
||||
"one_to_one": {"label": "1:1", "prompt": "Format 1:1 : points du collaborateur, feedback, engagements."},
|
||||
"interview": {"label": "Interview", "prompt": "Format entretien : parcours, forces, doutes, décision."},
|
||||
}
|
||||
|
||||
# Étapes Thinking exposées (§12.7), dans l'ordre — libellés FR/EN gérés côté UI.
|
||||
PROCESSING_STEPS = (
|
||||
("reading_transcript", "Reading transcript…"),
|
||||
("analyzing_transcript", "Analyzing the transcript"),
|
||||
("resolving_references", "Thinking about how to refer to people"),
|
||||
("understanding_content", "Understanding the content"),
|
||||
("classifying_completeness", "Checking completeness"),
|
||||
("generating_summary", "Writing the summary"),
|
||||
("generating_title", "Choosing a title"),
|
||||
("validating_citations", "Checking citations"),
|
||||
)
|
||||
|
||||
CONSENT_METHODS = (
|
||||
"start_attestation", "verbal", "chat_text", "audio_message",
|
||||
"meet_addon", "workspace_enforced",
|
||||
)
|
||||
|
||||
|
||||
class TranscriptionUnavailable(RuntimeError):
|
||||
"""Raised when no transcription backend is configured."""
|
||||
@@ -36,26 +73,609 @@ def meetings_dir() -> Path:
|
||||
return d
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _loads(raw: str, default):
|
||||
try:
|
||||
v = json.loads(raw or "")
|
||||
return v if v is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def get_transcript(transcript_id: int) -> dict:
|
||||
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")
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _update(transcript_id: int, **fields) -> None:
|
||||
if not fields:
|
||||
return
|
||||
cols = ", ".join(f"{k}=?" for k in fields)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
f"UPDATE meeting_transcripts SET {cols} WHERE id=?",
|
||||
(*fields.values(), transcript_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def save_transcript(page_id: int, transcript: str, language: str = "fr",
|
||||
audio_path: str = "") -> int:
|
||||
audio_path: str = "", **kw) -> int:
|
||||
"""Crée une note de réunion (état ``idle``). Rétro-compatible v7.1.0."""
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise ValueError("page not found")
|
||||
occ = (kw.get("event_occurrence_id") or "").strip()
|
||||
if occ and conn.execute(
|
||||
"SELECT id FROM meeting_transcripts WHERE event_occurrence_id=?", (occ,)
|
||||
).fetchone():
|
||||
raise ValueError("a meeting note already exists for this calendar occurrence")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO meeting_transcripts (page_id, audio_path, transcript, language)
|
||||
VALUES (?,?,?,?)""",
|
||||
(page_id, audio_path, transcript or "", language or "fr"))
|
||||
"""INSERT INTO meeting_transcripts
|
||||
(page_id, audio_path, transcript, language, status, mode,
|
||||
instruction_id, event_occurrence_id, notes_text)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
(page_id, audio_path, transcript or "", language or "fr",
|
||||
"idle", kw.get("mode") or "mic_only",
|
||||
(kw.get("instruction_id") or "auto")[:40], occ,
|
||||
kw.get("notes_text") or ""),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def transcribe_audio(audio_path: str, language: str = "fr") -> str:
|
||||
"""Transcribe with ``STT_COMMAND`` (``{cmd} {file}` → stdout text).
|
||||
# ── Consentement (§14.1) : porte bloquante, journal append-only ──────────────
|
||||
|
||||
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.
|
||||
def record_consent(transcript_id: int, *, method: str = "start_attestation",
|
||||
message_ref: str = "", attested_by: int | None = None,
|
||||
participants: list | None = None) -> dict:
|
||||
if method not in CONSENT_METHODS:
|
||||
raise ValueError(f"unknown consent method: {method}")
|
||||
get_transcript(transcript_id) # 404 si inconnu
|
||||
entry = {
|
||||
"method": method,
|
||||
"message_ref": message_ref or "",
|
||||
"attested_by": attested_by,
|
||||
"participants": participants or [],
|
||||
"attested_at": _now(),
|
||||
}
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO meeting_consent_log
|
||||
(transcript_id, method, message_ref, attested_by, participants_json)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(transcript_id, method, message_ref or "",
|
||||
attested_by, json.dumps(participants or [], ensure_ascii=False)),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE meeting_transcripts SET consent_json=? WHERE id=?",
|
||||
(json.dumps(entry, ensure_ascii=False), transcript_id),
|
||||
)
|
||||
conn.commit()
|
||||
return entry
|
||||
|
||||
|
||||
def require_consent(tr: dict) -> dict:
|
||||
consent = _loads(tr.get("consent_json") or "", None)
|
||||
if consent:
|
||||
return consent
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM meeting_consent_log WHERE transcript_id=? "
|
||||
"ORDER BY id DESC LIMIT 1", (tr["id"],)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("consent required — record consent before capturing")
|
||||
return {
|
||||
"method": row["method"], "message_ref": row["message_ref"],
|
||||
"attested_by": row["attested_by"],
|
||||
"participants": _loads(row["participants_json"], []),
|
||||
}
|
||||
|
||||
|
||||
# ── Sessions de capture : start / pause / resume / stop ─────────────────────
|
||||
|
||||
def start_session(transcript_id: int, *, mode: str = "mic_only",
|
||||
channels: list | None = None, language: str = "fr",
|
||||
instruction_id: str = "auto", user_id: int | None = None) -> dict:
|
||||
if mode not in CAPTURE_MODES:
|
||||
raise ValueError(f"unknown capture mode: {mode}")
|
||||
tr = get_transcript(transcript_id)
|
||||
if tr.get("status") in ("recording", "processing"):
|
||||
raise ValueError(f"meeting is {tr['status']} — stop it first")
|
||||
consent = require_consent(tr)
|
||||
instr = (instruction_id or "auto").strip()[:40]
|
||||
snapshot = {
|
||||
"instruction_id": instr if instr in INSTRUCTIONS else "custom",
|
||||
"instruction_raw": instr,
|
||||
"definition": INSTRUCTIONS.get(instr, {"label": instr, "prompt": instr}),
|
||||
"frozen_at": _now(),
|
||||
}
|
||||
chans = channels or (
|
||||
[{"kind": "self", "label": "Microphone"}] if mode == "mic_only"
|
||||
else [{"kind": "self", "label": "My microphone"},
|
||||
{"kind": "remote", "label": "Tab audio"}] if mode == "tab_plus_mic"
|
||||
else []
|
||||
)
|
||||
_update(transcript_id, status="recording", mode=mode,
|
||||
channels_json=json.dumps(chans, ensure_ascii=False),
|
||||
language=(language or tr.get("language") or "fr")[:10],
|
||||
instruction_id=instr,
|
||||
instruction_snapshot=json.dumps(snapshot, ensure_ascii=False),
|
||||
segments_json="[]", processing_steps_json="[]", summary="",
|
||||
summary_json="", generated_title="", completeness_class="",
|
||||
duration_ms=0, paused_ms=0, started_at=_now(), stopped_at=None)
|
||||
return get_transcript(transcript_id) | {"_consent": consent}
|
||||
|
||||
|
||||
def pause_session(transcript_id: int, *, paused_ms_delta: int = 0) -> dict:
|
||||
tr = get_transcript(transcript_id)
|
||||
if tr.get("status") != "recording":
|
||||
raise ValueError("only a recording session can be paused")
|
||||
_update(transcript_id, status="paused",
|
||||
paused_ms=int(tr.get("paused_ms") or 0) + max(0, int(paused_ms_delta or 0)))
|
||||
return get_transcript(transcript_id)
|
||||
|
||||
|
||||
def resume_session(transcript_id: int) -> dict:
|
||||
tr = get_transcript(transcript_id)
|
||||
if tr.get("status") != "paused":
|
||||
raise ValueError("only a paused session can be resumed")
|
||||
require_consent(tr)
|
||||
_update(transcript_id, status="recording")
|
||||
return get_transcript(transcript_id)
|
||||
|
||||
|
||||
def stop_session(transcript_id: int, *, duration_ms: int = 0) -> dict:
|
||||
tr = get_transcript(transcript_id)
|
||||
if tr.get("status") not in ("recording", "paused"):
|
||||
raise ValueError("nothing to stop — no live session")
|
||||
require_consent(tr)
|
||||
_update(transcript_id, status="processing", stopped_at=_now(),
|
||||
duration_ms=max(int(duration_ms or 0), int(tr.get("duration_ms") or 0)))
|
||||
return get_transcript(transcript_id)
|
||||
|
||||
|
||||
# ── Segments : append (temps réel), correction du locuteur ───────────────────
|
||||
|
||||
def _next_seq(segments: list) -> int:
|
||||
return (max((s.get("seq", 0) for s in segments), default=0) + 1) if segments else 1
|
||||
|
||||
|
||||
def append_segments(transcript_id: int, segments: list) -> dict:
|
||||
"""Persiste les segments finaux du client (STT navigateur / import).
|
||||
|
||||
Chaque item : {channel, source_label, speaker, text, start_ms, end_ms,
|
||||
starts_mid, ends_mid}. Le transcript texte est reconstruit pour compat.
|
||||
"""
|
||||
tr = get_transcript(transcript_id)
|
||||
# Retry/regen après un échec : le client reverse le manuel collé
|
||||
# entre-temps — l'ajout reste possible hors session live.
|
||||
if tr.get("status") not in ("recording", "paused", "processing",
|
||||
"failed", "done"):
|
||||
raise ValueError("session is not live — start recording first")
|
||||
require_consent(tr)
|
||||
if not isinstance(segments, list) or not segments:
|
||||
raise ValueError("segments must be a non-empty list")
|
||||
if len(segments) > 200:
|
||||
raise ValueError("too many segments per batch (max 200)")
|
||||
current = _loads(tr.get("segments_json") or "[]", [])
|
||||
seq = _next_seq(current)
|
||||
for item in segments:
|
||||
if not isinstance(item, dict) or not (item.get("text") or "").strip():
|
||||
raise ValueError("each segment needs a non-empty text")
|
||||
text = item["text"].strip()[:4000]
|
||||
current.append({
|
||||
"seq": seq,
|
||||
"channel": (item.get("channel") or "self")[:12],
|
||||
"source_label": (item.get("source_label") or "")[:120],
|
||||
"speaker": (item.get("speaker") or "")[:120],
|
||||
"start_ms": max(0, int(item.get("start_ms") or 0)),
|
||||
"end_ms": max(0, int(item.get("end_ms") or 0)),
|
||||
"text": ("--" if item.get("starts_mid") else "") + text,
|
||||
"is_final": True,
|
||||
"starts_mid_utterance": bool(item.get("starts_mid")),
|
||||
"ends_mid_utterance": bool(item.get("ends_mid")),
|
||||
})
|
||||
seq += 1
|
||||
flat = "\n".join(
|
||||
f"[{s['start_ms'] // 60000}:{(s['start_ms'] // 1000) % 60:02d}] "
|
||||
f"{s['speaker'] or s['channel']} : {s['text']}" for s in current
|
||||
)
|
||||
if tr.get("transcript"):
|
||||
flat = (tr["transcript"] + "\n" + flat) if flat else tr["transcript"]
|
||||
_update(transcript_id, segments_json=json.dumps(current, ensure_ascii=False),
|
||||
transcript=flat[:200000])
|
||||
return {"transcript_id": transcript_id, "segments": len(current)}
|
||||
|
||||
|
||||
def set_segment_speaker(transcript_id: int, seq: int, speaker: str,
|
||||
user_id: int | None = None) -> dict:
|
||||
tr = get_transcript(transcript_id)
|
||||
segments = _loads(tr.get("segments_json") or "[]", [])
|
||||
found = False
|
||||
for s in segments:
|
||||
if int(s.get("seq") or 0) == int(seq):
|
||||
s["speaker"] = (speaker or "").strip()[:120]
|
||||
s["speaker_corrected_by"] = user_id
|
||||
found = True
|
||||
if not found:
|
||||
raise ValueError("segment not found")
|
||||
_update(transcript_id, segments_json=json.dumps(segments, ensure_ascii=False))
|
||||
return {"transcript_id": transcript_id, "seq": int(seq), "speaker": speaker}
|
||||
|
||||
|
||||
def grouped_transcript(transcript_id: int) -> dict:
|
||||
"""Transcript regroupé par piste source (§3A.7) + rail temporel."""
|
||||
tr = get_transcript(transcript_id)
|
||||
segments = _loads(tr.get("segments_json") or "[]", [])
|
||||
groups: dict[str, list] = {}
|
||||
order: list[str] = []
|
||||
for s in sorted(segments, key=lambda x: (x.get("seq") or 0)):
|
||||
key = s.get("source_label") or s.get("channel") or "self"
|
||||
if key not in groups:
|
||||
groups[key] = []
|
||||
order.append(key)
|
||||
groups[key].append(s)
|
||||
# Repli : transcript texte brut découpé en un groupe unique.
|
||||
if not groups and (tr.get("transcript") or "").strip():
|
||||
groups = {"audio": [{"seq": 1, "channel": "self", "source_label": "",
|
||||
"speaker": "", "start_ms": 0, "end_ms": 0,
|
||||
"text": tr["transcript"][:4000]}]}
|
||||
order = ["audio"]
|
||||
return {"transcript_id": transcript_id,
|
||||
"groups": [{"source": k, "segments": groups[k]} for k in order]}
|
||||
|
||||
|
||||
# ── Classification de complétude + titre (§12.8) ─────────────────────────────
|
||||
|
||||
def classify_completeness(*, text: str, segments: list, notes: str,
|
||||
duration_ms: int = 0) -> str:
|
||||
words = len(re.findall(r"\S+", text or ""))
|
||||
n_seg = len(segments or [])
|
||||
truncated = any(s.get("starts_mid_utterance") or s.get("ends_mid_utterance")
|
||||
for s in (segments or []))
|
||||
has_agenda = bool((notes or "").strip())
|
||||
if n_seg == 0 or words < 8:
|
||||
return "fragmentary"
|
||||
if words < 60 or (duration_ms and duration_ms < 45_000):
|
||||
return "brief" if not truncated else "incomplete"
|
||||
if truncated or not has_agenda and words < 150:
|
||||
return "incomplete"
|
||||
if words < 150:
|
||||
return "brief"
|
||||
return "complete"
|
||||
|
||||
|
||||
def _first_topic(text: str) -> str:
|
||||
sentences = re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", text or "").strip())
|
||||
for s in sentences:
|
||||
s = s.strip(" -–—\"'«»")
|
||||
if len(s) > 25:
|
||||
return s[:90].rstrip(" ,;:")
|
||||
return ""
|
||||
|
||||
|
||||
def generate_title(*, text: str, completeness: str, notes: str = "") -> str:
|
||||
if completeness in ("brief", "incomplete", "fragmentary"):
|
||||
topic = _first_topic(text)
|
||||
if not topic or len(re.findall(r"\S+", text or "")) < 25:
|
||||
return "Brief or Incomplete Meeting Recording"
|
||||
return topic[:90]
|
||||
topic = _first_topic(notes + " " + text)
|
||||
return topic[:90] or "Meeting notes"
|
||||
|
||||
|
||||
# ── Résumé structuré (§12.4) : JSON contraint + citations ────────────────────
|
||||
|
||||
_SUMMARY_SCHEMA_HINT = (
|
||||
"Réponds STRICTEMENT par un objet JSON : "
|
||||
'{"overview": ["..."], "decisions": ["..."], "action_items": '
|
||||
'[{"title": "...", "owner": null, "due_hint": null}], '
|
||||
'"open_questions": ["..."], "limits": ["..."]}. '
|
||||
"Chaque puce overview/decisions/action_items DOIT citer le transcript "
|
||||
"(suffixe entre parenthèses avec un extrait repris mot pour mot). "
|
||||
"N'invente ni responsable ni échéance : owner/due_hint null si absents. "
|
||||
"Si le contenu est insuffisant, overview décrit les manques et "
|
||||
"action_items vaut []."
|
||||
)
|
||||
|
||||
|
||||
def _cite(text: str, segments: list) -> str:
|
||||
"""Trouve un segment source pour une affirmation (citation vérifiable)."""
|
||||
words = [w.lower() for w in re.findall(r"[A-Za-zÀ-ÿa-zà-ÿ']{4,}", text or "")]
|
||||
best, best_score = None, 0
|
||||
for s in segments or []:
|
||||
hay = (s.get("text") or "").lower()
|
||||
score = sum(1 for w in words[:12] if w in hay)
|
||||
if score > best_score:
|
||||
best, best_score = s, score
|
||||
if best and best_score > 0:
|
||||
return f" ¹ [seg {best.get('seq')}]"
|
||||
if segments:
|
||||
return f" ¹ [seg {segments[0].get('seq')}]"
|
||||
return " ¹"
|
||||
|
||||
|
||||
def _offline_structured(*, text: str, notes: str, segments: list,
|
||||
completeness: str) -> dict:
|
||||
sentences = [s.strip() for s in
|
||||
re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", text).strip()) if s.strip()]
|
||||
overview: list[str] = []
|
||||
if completeness == "fragmentary":
|
||||
overview = [
|
||||
"The recording captured only a short, fragmentary exchange with no "
|
||||
f"discernible meeting topic, agenda, or context{_cite(text, segments)}",
|
||||
"The content appears to be mid-conversation and lacks the beginning "
|
||||
f"of the discussion{_cite(text, segments)}",
|
||||
]
|
||||
if not (notes or "").strip():
|
||||
overview.append("The user's notes were empty, providing no additional "
|
||||
"context about the meeting's purpose")
|
||||
else:
|
||||
for s in sentences[:4]:
|
||||
overview.append(f"{s[:220]}{_cite(s, segments)}")
|
||||
if not overview:
|
||||
overview = [f"No meaningful content could be extracted{_cite(text, segments)}"]
|
||||
if completeness in ("brief", "incomplete"):
|
||||
overview.append(
|
||||
"This was a brief or incomplete recording — treat this summary "
|
||||
f"as partial{_cite(text, segments)}")
|
||||
decisions = []
|
||||
for s in sentences:
|
||||
if re.search(r"\b(decid|décid|agreed|approuv|retain|retenu|chosen|choisi)\b", s, re.I):
|
||||
decisions.append(f"{s[:220]}{_cite(s, segments)}")
|
||||
actions = []
|
||||
for s in sentences:
|
||||
m = re.search(r"\b([A-ZÀ-Þ][a-zà-ÿ'\-]+)\s+(will|va|doit|should|must|s'occupe)\b", s)
|
||||
if re.search(r"\b(action|todo|to-?do|next step|prochaine étape|faire|envoyer|préparer)\b", s, re.I) or m:
|
||||
owner = m.group(1) if m and len(m.group(1)) > 2 else None
|
||||
actions.append({"title": s[:180], "owner": owner,
|
||||
"due_hint": None, "evidence": _cite(s, segments)})
|
||||
return {"overview": overview[:6], "decisions": decisions[:8],
|
||||
"action_items": actions[:10], "open_questions": [],
|
||||
"limits": [] if completeness == "complete" else overview[-2:]}
|
||||
|
||||
|
||||
def _render_markdown(struct: dict, completeness: str) -> str:
|
||||
L = ["## Overview", ""]
|
||||
for b in struct.get("overview") or []:
|
||||
L.append(f"• {b}")
|
||||
L += ["", "## Decisions", ""]
|
||||
if struct.get("decisions"):
|
||||
for b in struct["decisions"]:
|
||||
L.append(f"• {b}")
|
||||
else:
|
||||
L.append("• No decisions could be identified from the available transcript")
|
||||
L += ["", "## Action Items", ""]
|
||||
if struct.get("action_items"):
|
||||
for a in struct["action_items"]:
|
||||
if isinstance(a, dict):
|
||||
owner = f" — owner: {a['owner']}" if a.get("owner") else ""
|
||||
L.append(f"• {a.get('title', '')}{owner}{a.get('evidence', ' ¹')}")
|
||||
else:
|
||||
L.append(f"• {a}")
|
||||
else:
|
||||
L.append("• No action items could be identified from the available transcript")
|
||||
if struct.get("open_questions"):
|
||||
L += ["", "## Open questions", ""]
|
||||
for b in struct["open_questions"]:
|
||||
L.append(f"• {b}")
|
||||
if completeness in ("brief", "incomplete", "fragmentary"):
|
||||
L += ["", f"_Summary class: {completeness} — partial recording, verify against the transcript._"]
|
||||
return "\n".join(L).strip()
|
||||
|
||||
|
||||
def _write_steps(transcript_id: int, states: dict) -> None:
|
||||
steps = [{"key": k, "label": label,
|
||||
"state": states.get(k, "pending"),
|
||||
"at": _now() if states.get(k) in ("running", "done", "failed") else ""}
|
||||
for k, label in PROCESSING_STEPS]
|
||||
_update(transcript_id, processing_steps_json=json.dumps(steps, ensure_ascii=False))
|
||||
|
||||
|
||||
async def _llm_structured(*, text: str, notes: str, instruction: str,
|
||||
user_id: int | None) -> tuple[dict | None, bool, str]:
|
||||
from app.services.ai_writing import AIWritingService
|
||||
|
||||
svc = AIWritingService(user_id=user_id)
|
||||
if svc._offline_hint():
|
||||
return None, True, ""
|
||||
prompt = (
|
||||
"Rédige le résumé structuré de cette réunion (format AI Meeting Note). "
|
||||
f"Instructions de l'utilisateur : {instruction or 'Auto'}. "
|
||||
"Notes humaines (contexte prioritaire, signale tout conflit) : "
|
||||
f"{(notes or '(vides)')[:2000]}. " + _SUMMARY_SCHEMA_HINT
|
||||
)
|
||||
try:
|
||||
res = await svc.run("summarize", context=text[:18000], prompt=prompt)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("meeting LLM summary failed: %s", exc)
|
||||
return None, False, str(exc)
|
||||
if not res.get("ok") or not (res.get("text") or "").strip():
|
||||
return None, bool(res.get("offline")), res.get("error") or "empty"
|
||||
parsed = AIWritingService._parse_json_object(res["text"])
|
||||
if not parsed or not isinstance(parsed.get("overview"), list):
|
||||
# Le modèle a répondu en texte libre : on le conserve en overview sourcé.
|
||||
return {"overview": [res["text"][:1500]], "decisions": [],
|
||||
"action_items": [], "open_questions": [], "limits": []}, False, ""
|
||||
return parsed, False, ""
|
||||
|
||||
|
||||
async def run_processing(transcript_id: int, user_id: int | None = None,
|
||||
allow_empty: bool = False) -> dict:
|
||||
"""Pipeline §12 : étapes Thinking → classification → résumé → titre.
|
||||
|
||||
Jamais de refus sur contenu insuffisant : résumé dégradé + classe +
|
||||
titre-diagnostic (§12.8). Avec ``allow_empty`` (arrêt sans rien de
|
||||
capté), un diagnostic honnête est produit au lieu d'une erreur —
|
||||
l'utilisateur ne perd ni l'enregistrement ni le transcript.
|
||||
Sans ``allow_empty``, un transcript vide reste une 400 (l'appelant
|
||||
affiche alors un guide de reprise, pas un dump HTTP).
|
||||
"""
|
||||
tr = get_transcript(transcript_id)
|
||||
segments = _loads(tr.get("segments_json") or "[]", [])
|
||||
notes = tr.get("notes_text") or ""
|
||||
text = (tr.get("transcript") or "").strip()
|
||||
snapshot = _loads(tr.get("instruction_snapshot") or "", {})
|
||||
instruction = (snapshot.get("instruction_raw")
|
||||
or tr.get("instruction_id") or "auto")
|
||||
|
||||
order = [k for k, _ in PROCESSING_STEPS]
|
||||
states: dict[str, str] = {}
|
||||
try:
|
||||
for key in ("reading_transcript", "analyzing_transcript",
|
||||
"resolving_references", "understanding_content"):
|
||||
states[key] = "running"
|
||||
_write_steps(transcript_id, states)
|
||||
time.sleep(0) # point de cession : étapes réellement exécutées
|
||||
states[key] = "done"
|
||||
_write_steps(transcript_id, states)
|
||||
|
||||
states["classifying_completeness"] = "running"
|
||||
_write_steps(transcript_id, states)
|
||||
completeness = classify_completeness(
|
||||
text=text, segments=segments, notes=notes,
|
||||
duration_ms=int(tr.get("duration_ms") or 0))
|
||||
states["classifying_completeness"] = "done"
|
||||
_write_steps(transcript_id, states)
|
||||
|
||||
if not text:
|
||||
if not allow_empty:
|
||||
raise ValueError("transcript is empty — transcribe first")
|
||||
# Arrêt sans rien de capté (micro coupé, aucun mot reconnu) :
|
||||
# diagnostic honnête au lieu d'une erreur — rien n'est perdu,
|
||||
# l'utilisateur peut coller un transcript et régénérer.
|
||||
for key in ("generating_summary", "generating_title",
|
||||
"validating_citations"):
|
||||
states[key] = "running"
|
||||
_write_steps(transcript_id, states)
|
||||
states[key] = "done"
|
||||
_write_steps(transcript_id, states)
|
||||
struct = {
|
||||
"overview": [
|
||||
"No audio was captured during this session — no speech "
|
||||
"reached the transcript. Check that the microphone is "
|
||||
"allowed for this site and that the correct input is selected.",
|
||||
("If the call ran in another tab, restart with “Tab + "
|
||||
"microphone” and tick “Share audio” when choosing the tab."),
|
||||
],
|
||||
"decisions": [], "action_items": [], "open_questions": [],
|
||||
"limits": ["empty session — paste a transcript, then Regenerate"],
|
||||
}
|
||||
md = _render_markdown(struct, "fragmentary")
|
||||
_update(transcript_id, status="done", summary=md,
|
||||
summary_json=json.dumps(struct, ensure_ascii=False),
|
||||
generated_title="Brief or Incomplete Meeting Recording",
|
||||
title_source="ai", completeness_class="fragmentary")
|
||||
return await _finish_processing(transcript_id, offline=True)
|
||||
|
||||
states["generating_summary"] = "running"
|
||||
_write_steps(transcript_id, states)
|
||||
struct, offline, _err = await _llm_structured(
|
||||
text=text, notes=notes, instruction=instruction, user_id=user_id)
|
||||
if struct is None:
|
||||
struct = _offline_structured(text=text, notes=notes,
|
||||
segments=segments,
|
||||
completeness=completeness)
|
||||
offline = True
|
||||
else:
|
||||
# Garantir les citations même sur réponse LLM.
|
||||
for section in ("overview", "decisions"):
|
||||
fixed = []
|
||||
for b in struct.get(section) or []:
|
||||
s = b if isinstance(b, str) else str(b)
|
||||
fixed.append(s if "¹" in s or "[seg" in s else f"{s}{_cite(s, segments)}")
|
||||
struct[section] = fixed
|
||||
acts = []
|
||||
for a in struct.get("action_items") or []:
|
||||
if isinstance(a, dict):
|
||||
if "evidence" not in a:
|
||||
a["evidence"] = _cite(a.get("title", ""), segments)
|
||||
acts.append(a)
|
||||
else:
|
||||
acts.append({"title": str(a), "owner": None,
|
||||
"due_hint": None,
|
||||
"evidence": _cite(str(a), segments)})
|
||||
struct["action_items"] = acts
|
||||
states["generating_summary"] = "done"
|
||||
_write_steps(transcript_id, states)
|
||||
|
||||
states["generating_title"] = "running"
|
||||
_write_steps(transcript_id, states)
|
||||
title = generate_title(text=text, completeness=completeness, notes=notes)
|
||||
states["generating_title"] = "done"
|
||||
_write_steps(transcript_id, states)
|
||||
|
||||
states["validating_citations"] = "running"
|
||||
_write_steps(transcript_id, states)
|
||||
seqs = {int(s.get("seq") or 0) for s in segments}
|
||||
# Toute citation [seg N] pointe un segment réel, sinon repli seg 1.
|
||||
md = _render_markdown(struct, completeness)
|
||||
|
||||
def _fix_seg(m: re.Match) -> str:
|
||||
try:
|
||||
n = int(m.group(1))
|
||||
except (TypeError, ValueError):
|
||||
return "[seg 1]"
|
||||
return m.group(0) if n in seqs else "[seg 1]"
|
||||
md = re.sub(r"\[seg (\d+)\]", _fix_seg, md)
|
||||
states["validating_citations"] = "done"
|
||||
_write_steps(transcript_id, states)
|
||||
|
||||
_update(transcript_id, status="done", summary=md,
|
||||
summary_json=json.dumps(struct, ensure_ascii=False),
|
||||
generated_title=title, title_source="ai",
|
||||
completeness_class=completeness)
|
||||
except Exception as exc: # noqa: BLE001 — état failed récupérable, segments conservés
|
||||
for k in order:
|
||||
states.setdefault(k, "pending")
|
||||
# Marquer l'étape courante en échec.
|
||||
for k in order:
|
||||
if states.get(k) == "running":
|
||||
states[k] = "failed"
|
||||
break
|
||||
_write_steps(transcript_id, states)
|
||||
# Toujours sortir de « processing » : une ligne coincée est pire
|
||||
# qu'une erreur affichée (l'utilisateur peut réessayer).
|
||||
_update(transcript_id, status="failed")
|
||||
msg = str(exc) or "processing failed"
|
||||
if "transcript is empty" in msg:
|
||||
raise ValueError(msg) from exc
|
||||
logger.warning("meeting processing %s failed: %s", transcript_id, exc)
|
||||
raise RuntimeError(msg) from exc
|
||||
|
||||
return await _finish_processing(transcript_id, offline)
|
||||
|
||||
|
||||
async def _finish_processing(transcript_id: int, offline: bool) -> dict:
|
||||
"""Événement + réponse du pipeline (chemin nominal et diagnostic)."""
|
||||
out = get_transcript(transcript_id)
|
||||
try:
|
||||
from app.services.automations import fire_event
|
||||
await fire_event("meeting.summarized", {
|
||||
"page_id": out["page_id"], "transcript_id": transcript_id,
|
||||
"language": out.get("language") or "fr",
|
||||
"completeness": out.get("completeness_class") or "",
|
||||
})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("meeting.summarized dispatch failed: %s", exc)
|
||||
return {"transcript_id": transcript_id, "summary": out["summary"],
|
||||
"completeness": out.get("completeness_class") or "",
|
||||
"generated_title": out.get("generated_title") or "",
|
||||
"offline": bool(offline)}
|
||||
|
||||
|
||||
def transcribe_audio(audio_path: str, language: str = "fr") -> str:
|
||||
"""Transcribe with ``STT_COMMAND`` (``{cmd} {file}` → stdout text)."""
|
||||
cmd_template = os.environ.get("STT_COMMAND", "").strip()
|
||||
if not cmd_template:
|
||||
raise TranscriptionUnavailable(
|
||||
@@ -74,40 +694,39 @@ def transcribe_audio(audio_path: str, language: str = "fr") -> str:
|
||||
return text
|
||||
|
||||
|
||||
async def summarize_transcript(transcript_id: int, user_id: int | None = None) -> dict:
|
||||
"""Summarize a stored transcript + fire ``meeting.summarized``.
|
||||
async def summarize_transcript(transcript_id: int, user_id: int | None = None,
|
||||
allow_empty: bool = False) -> dict:
|
||||
"""Point d'entrée historique (v7.1.0) — bascule sur le pipeline Notion."""
|
||||
tr = get_transcript(transcript_id)
|
||||
if tr.get("status") in ("recording", "paused"):
|
||||
raise ValueError("stop the recording before generating the summary")
|
||||
if not ((tr.get("transcript") or "").strip()
|
||||
or _loads(tr.get("segments_json") or "[]", [])):
|
||||
if not allow_empty:
|
||||
raise ValueError("transcript is empty — transcribe first")
|
||||
if tr.get("status") not in ("processing", "done"):
|
||||
_update(transcript_id, status="processing")
|
||||
out = await run_processing(transcript_id, user_id=user_id,
|
||||
allow_empty=allow_empty)
|
||||
return {"transcript_id": transcript_id, "summary": out["summary"],
|
||||
"offline": out["offline"]}
|
||||
|
||||
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
|
||||
|
||||
# ── Partage tracé (§3A.5 : Copy link / Email / Slack) ─────────────────────────
|
||||
|
||||
def record_distribution(transcript_id: int, *, channel: str,
|
||||
actor_id: int | None = None,
|
||||
recipients: str = "", status: str = "created") -> dict:
|
||||
if channel not in ("copy_link", "email", "slack"):
|
||||
raise ValueError("channel must be copy_link|email|slack")
|
||||
get_transcript(transcript_id)
|
||||
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()
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO meeting_distributions
|
||||
(transcript_id, channel, actor_id, recipients_json, status)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(transcript_id, channel, actor_id, recipients or "", status),
|
||||
)
|
||||
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"))}
|
||||
return {"id": cur.lastrowid, "transcript_id": transcript_id,
|
||||
"channel": channel, "status": status}
|
||||
|
||||
+14
-6
@@ -9,10 +9,14 @@ Les trois surfaces divergeaient avant cette passe :
|
||||
- `/api/v2/pages/{id}/publish` : slug fourni par le corps ou aléatoire,
|
||||
`is_shared=1` en bonus (alors que v2 le remet à 0 quand aucun partage)
|
||||
|
||||
Canonical (comportement du front) : `is_published` + `publish_slug` seulement,
|
||||
404 si la page n'existe pas. `share_mode`/`is_shared`/`published` restent la
|
||||
propriété du share dialog (`/board/api/share/{pid}`) : dépublier ne révoque
|
||||
donc pas un partage manuel.
|
||||
Canonical (comportement du front) : `is_published` + `published` +
|
||||
`publish_slug`, maintenus ensemble par toutes les écritures (v7.69.6 —
|
||||
avant, chaque surface n'écrivait qu'un drapeau et chaque lecteur ne
|
||||
regardait qu'une colonne, donc l'état publié ne « tenait » nulle part).
|
||||
`share_mode`/`is_shared` restent la propriété du share dialog
|
||||
(`/board/api/share/{pid}`) : dépublier ne révoque donc pas un partage manuel.
|
||||
Les lecteurs acceptent `is_published=1 OR published=1` (lignes historiques
|
||||
issues d'un seul chemin).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -58,8 +62,12 @@ def publish(page_id: int, explicit_slug: str | None = None) -> tuple[str, str]:
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
slug = explicit_slug or _unique_slug(conn, page_id, page["title"])
|
||||
# v7.69.6 : les deux drapeaux sont maintenus ensemble — le share
|
||||
# dialog écrit ``published``, les routes /publish écrivaient seulement
|
||||
# ``is_published``, et chaque lecteur ne regardait qu'une colonne :
|
||||
# l'état publié ne « tenait » nulle part.
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?", (slug, page_id)
|
||||
"UPDATE pages SET is_published=1, published=1, publish_slug=? WHERE id=?", (slug, page_id)
|
||||
)
|
||||
conn.commit()
|
||||
return slug, page["title"] or ""
|
||||
@@ -71,7 +79,7 @@ def unpublish(page_id: int) -> None:
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", (page_id,)
|
||||
"UPDATE pages SET is_published=0, published=0, publish_slug='' WHERE id=?", (page_id,)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user