HomePilot Deploy Bot commited on
Commit ·
de5e4f8
1
Parent(s): a55105f
chore(hf): sync HomePilot to HF Space
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +4 -4
- backend/app/main.py +39 -11
- backend/app/meetingsense/__init__.py +10 -1
- backend/app/meetingsense/export.py +20 -1
- backend/app/meetingsense/intelligence/__init__.py +20 -0
- backend/app/meetingsense/intelligence/analyze.py +576 -0
- backend/app/meetingsense/intelligence/brief.py +401 -0
- backend/app/meetingsense/intelligence/layout.py +78 -0
- backend/app/meetingsense/intelligence/questions.py +89 -0
- backend/app/meetingsense/intelligence/schema.py +233 -0
- backend/app/meetingsense/intelligence/service.py +69 -0
- backend/app/meetingsense/intelligence/store.py +232 -0
- backend/app/meetingsense/intelligence/validate.py +142 -0
- backend/app/meetingsense/library/__init__.py +28 -0
- backend/app/meetingsense/library/adapters.py +332 -0
- backend/app/meetingsense/library/config.py +122 -0
- backend/app/meetingsense/library/db.py +1062 -0
- backend/app/meetingsense/library/importer.py +373 -0
- backend/app/meetingsense/library/jobs.py +441 -0
- backend/app/meetingsense/library/media.py +172 -0
- backend/app/meetingsense/library/parsers.py +292 -0
- backend/app/meetingsense/library/projection.py +314 -0
- backend/app/meetingsense/library/routes.py +977 -0
- backend/app/meetingsense/library/sync.py +266 -0
- backend/app/meetingsense/notes_engine.py +31 -10
- backend/app/personas/avatar_crop.py +64 -0
- frontend/.cache-bust +1 -1
- frontend/package-lock.json +7 -0
- frontend/package.json +2 -0
- frontend/scripts/ensure-deps.mjs +78 -0
- frontend/src/test/__snapshots__/meetingsLandingGate.test.tsx.snap +3 -0
- frontend/src/test/conversationHub.test.tsx +21 -0
- frontend/src/test/ensureDeps.test.js +74 -0
- frontend/src/test/meetingIntelligence.test.tsx +192 -0
- frontend/src/test/meetingsLandingGate.test.tsx +57 -0
- frontend/src/test/meetingsLibrary.test.tsx +240 -0
- frontend/src/test/visualSummaryTab.test.tsx +220 -0
- frontend/src/ui/App.tsx +197 -147
- frontend/src/ui/components/PersonaSettingsPanel.tsx +3 -1
- frontend/src/ui/components/ProjectAvatar.tsx +29 -2
- frontend/src/ui/meetingsense/MeetingSenseProvider.tsx +39 -6
- frontend/src/ui/meetingsense/library/AssistantDrawer.tsx +154 -0
- frontend/src/ui/meetingsense/library/FolderSyncPanel.tsx +181 -0
- frontend/src/ui/meetingsense/library/ImportDialog.tsx +279 -0
- frontend/src/ui/meetingsense/library/InsightsPanel.tsx +183 -0
- frontend/src/ui/meetingsense/library/MeetingDetailPage.tsx +929 -0
- frontend/src/ui/meetingsense/library/MeetingsHome.tsx +427 -0
- frontend/src/ui/meetingsense/library/PresentMode.tsx +66 -0
- frontend/src/ui/meetingsense/library/ProcessingPanel.tsx +101 -0
- frontend/src/ui/meetingsense/library/StartMeetingWizard.tsx +421 -0
.gitattributes
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
-
*.png filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.ico filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.hpersona filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 6 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.svg filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
| 8 |
*.woff filter=lfs diff=lfs merge=lfs -text
|
| 9 |
*.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.hpersona filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.svg filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.woff filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.ico filter=lfs diff=lfs merge=lfs -text
|
backend/app/main.py
CHANGED
|
@@ -3861,6 +3861,19 @@ async def update_project(project_id: str, request: Request) -> JSONResponse:
|
|
| 3861 |
"""Update project details."""
|
| 3862 |
try:
|
| 3863 |
data = await request.json()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3864 |
result = projects.update_project(project_id, data)
|
| 3865 |
|
| 3866 |
if not result:
|
|
@@ -3911,7 +3924,11 @@ async def update_project(project_id: str, request: Request) -> JSONResponse:
|
|
| 3911 |
# selected_filename was missing → changing Active Look left
|
| 3912 |
# the project mini-thumbnail stale.
|
| 3913 |
# -----------------------------------------------------------
|
|
|
|
|
|
|
| 3914 |
selected_url = _resolve_selected_image_url(appearance)
|
|
|
|
|
|
|
| 3915 |
if selected_url:
|
| 3916 |
# Extract the rel_path for already-committed /files/ URLs
|
| 3917 |
sel_rel = None
|
|
@@ -6158,9 +6175,19 @@ async def persona_commit_avatar(project_id: str, body: dict) -> JSONResponse:
|
|
| 6158 |
source_url = body.get("source_url")
|
| 6159 |
auto_mode = body.get("auto", False)
|
| 6160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6161 |
# ── Resolve the source file ──────────────────────────────────────────
|
| 6162 |
try:
|
| 6163 |
-
if
|
|
|
|
|
|
|
| 6164 |
# Auto-resolve: walk persona_appearance.sets to find selected URL
|
| 6165 |
appearance_data = p.get("persona_appearance") or {}
|
| 6166 |
resolved_url = _resolve_selected_image_url(appearance_data)
|
|
@@ -6207,17 +6234,18 @@ async def persona_commit_avatar(project_id: str, body: dict) -> JSONResponse:
|
|
| 6207 |
raise HTTPException(status_code=502, detail=f"Failed to download image: {e}")
|
| 6208 |
|
| 6209 |
# ── Commit into project-owned storage ────────────────────────────────
|
| 6210 |
-
|
| 6211 |
-
project_root = UPLOAD_PATH / "projects" / project_id
|
| 6212 |
-
result = commit_persona_avatar(UPLOAD_PATH, project_root, source_filename)
|
| 6213 |
-
except FileNotFoundError as e:
|
| 6214 |
-
raise HTTPException(status_code=404, detail=str(e))
|
| 6215 |
-
except ValueError as e:
|
| 6216 |
-
raise HTTPException(status_code=400, detail=str(e))
|
| 6217 |
-
|
| 6218 |
appearance = dict(p.get("persona_appearance") or {})
|
| 6219 |
-
|
| 6220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6221 |
|
| 6222 |
# ----- Commit ALL remaining /comfy/view/ images in sets[] + outfits[] -----
|
| 6223 |
# Same logic as project creation (POST /projects) — download ephemeral
|
|
|
|
| 3861 |
"""Update project details."""
|
| 3862 |
try:
|
| 3863 |
data = await request.json()
|
| 3864 |
+
|
| 3865 |
+
# A picture framed in Edit Profile Picture stays until the user picks
|
| 3866 |
+
# another look: settings panels echo the appearance they loaded, which
|
| 3867 |
+
# may predate the crop (see personas/avatar_crop.keep_manual_picture).
|
| 3868 |
+
from .personas.avatar_crop import keep_manual_picture, keeps_manual_picture
|
| 3869 |
+
|
| 3870 |
+
before = projects.get_project_by_id(project_id) or {}
|
| 3871 |
+
before_pap = dict(before.get("persona_appearance") or {})
|
| 3872 |
+
if isinstance(data, dict) and isinstance(data.get("persona_appearance"), dict):
|
| 3873 |
+
merged_pap = {**before_pap, **data["persona_appearance"]}
|
| 3874 |
+
data = {**data, "persona_appearance": keep_manual_picture(
|
| 3875 |
+
before_pap, merged_pap, _resolve_selected_image_url(merged_pap))}
|
| 3876 |
+
|
| 3877 |
result = projects.update_project(project_id, data)
|
| 3878 |
|
| 3879 |
if not result:
|
|
|
|
| 3924 |
# selected_filename was missing → changing Active Look left
|
| 3925 |
# the project mini-thumbnail stale.
|
| 3926 |
# -----------------------------------------------------------
|
| 3927 |
+
# ...except while the user's own picture from Edit Profile Picture
|
| 3928 |
+
# is in use and they have not picked another look.
|
| 3929 |
selected_url = _resolve_selected_image_url(appearance)
|
| 3930 |
+
if keeps_manual_picture(before_pap, appearance, selected_url):
|
| 3931 |
+
selected_url = None
|
| 3932 |
if selected_url:
|
| 3933 |
# Extract the rel_path for already-committed /files/ URLs
|
| 3934 |
sel_rel = None
|
|
|
|
| 6175 |
source_url = body.get("source_url")
|
| 6176 |
auto_mode = body.get("auto", False)
|
| 6177 |
|
| 6178 |
+
# A picture framed in Edit Profile Picture is the persona's picture until
|
| 6179 |
+
# the user picks another look — and the project save applies a new look
|
| 6180 |
+
# (dropping the framing) before this repair runs. Re-committing the look
|
| 6181 |
+
# here would replace the framing with a fresh automatic thumbnail.
|
| 6182 |
+
from .personas.avatar_crop import active_crop as _active_crop
|
| 6183 |
+
|
| 6184 |
+
keep_framed = bool(auto_mode) and _active_crop(p.get("persona_appearance") or {}) is not None
|
| 6185 |
+
|
| 6186 |
# ── Resolve the source file ──────────────────────────────────────────
|
| 6187 |
try:
|
| 6188 |
+
if keep_framed:
|
| 6189 |
+
source_filename = None
|
| 6190 |
+
elif auto_mode:
|
| 6191 |
# Auto-resolve: walk persona_appearance.sets to find selected URL
|
| 6192 |
appearance_data = p.get("persona_appearance") or {}
|
| 6193 |
resolved_url = _resolve_selected_image_url(appearance_data)
|
|
|
|
| 6234 |
raise HTTPException(status_code=502, detail=f"Failed to download image: {e}")
|
| 6235 |
|
| 6236 |
# ── Commit into project-owned storage ────────────────────────────────
|
| 6237 |
+
project_root = UPLOAD_PATH / "projects" / project_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6238 |
appearance = dict(p.get("persona_appearance") or {})
|
| 6239 |
+
if not keep_framed:
|
| 6240 |
+
try:
|
| 6241 |
+
result = commit_persona_avatar(UPLOAD_PATH, project_root, source_filename)
|
| 6242 |
+
except FileNotFoundError as e:
|
| 6243 |
+
raise HTTPException(status_code=404, detail=str(e))
|
| 6244 |
+
except ValueError as e:
|
| 6245 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 6246 |
+
|
| 6247 |
+
appearance["selected_filename"] = result.selected_filename
|
| 6248 |
+
appearance["selected_thumb_filename"] = result.thumb_filename
|
| 6249 |
|
| 6250 |
# ----- Commit ALL remaining /comfy/view/ images in sets[] + outfits[] -----
|
| 6251 |
# Same logic as project creation (POST /projects) — download ephemeral
|
backend/app/meetingsense/__init__.py
CHANGED
|
@@ -17,4 +17,13 @@ needs WebSockets arrives in MS3 and is imported lazily then, so an install that
|
|
| 17 |
MeetingSense on never loads it.
|
| 18 |
"""
|
| 19 |
|
| 20 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
MeetingSense on never loads it.
|
| 18 |
"""
|
| 19 |
|
| 20 |
+
from fastapi import APIRouter
|
| 21 |
+
|
| 22 |
+
from .library import router as _library_router
|
| 23 |
+
from .routes import router as _core_router
|
| 24 |
+
|
| 25 |
+
# The library's routes first: MeetingSense's ``GET /v1/meetingsense/{meeting_id}`` would
|
| 26 |
+
# otherwise read ``library`` or ``imports`` as a meeting id. Every core route is unchanged.
|
| 27 |
+
router = APIRouter()
|
| 28 |
+
router.include_router(_library_router)
|
| 29 |
+
router.include_router(_core_router)
|
backend/app/meetingsense/export.py
CHANGED
|
@@ -131,6 +131,25 @@ def notes_body(notes: Any) -> Optional[Dict[str, Any]]:
|
|
| 131 |
return None
|
| 132 |
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
def to_markdown(
|
| 135 |
meeting: Dict[str, Any],
|
| 136 |
segments: Sequence[Dict[str, Any]],
|
|
@@ -157,7 +176,7 @@ def to_markdown(
|
|
| 157 |
if isinstance(value, str):
|
| 158 |
out.append(f"\n{value}\n")
|
| 159 |
else:
|
| 160 |
-
out.extend(f"\n- {item}" for item in value)
|
| 161 |
out.append("\n")
|
| 162 |
|
| 163 |
if keyframes:
|
|
|
|
| 131 |
return None
|
| 132 |
|
| 133 |
|
| 134 |
+
def note_line(item: Any) -> str:
|
| 135 |
+
"""One note item as a Markdown bullet. The notes engine stores items as dicts
|
| 136 |
+
(``{"text", "owner", "t0", "done"}``); rendering those with ``str()`` printed Python
|
| 137 |
+
dict reprs into exports and into project knowledge. Strings pass through unchanged."""
|
| 138 |
+
if not isinstance(item, dict):
|
| 139 |
+
return str(item)
|
| 140 |
+
text = str(item.get("text") or item.get("title") or item.get("item") or "").strip()
|
| 141 |
+
extras = []
|
| 142 |
+
owner = item.get("owner") or item.get("who")
|
| 143 |
+
if owner:
|
| 144 |
+
extras.append(str(owner))
|
| 145 |
+
stamp = item.get("t0_ms") if item.get("t0_ms") is not None else item.get("t0")
|
| 146 |
+
if isinstance(stamp, (int, float)) and not isinstance(stamp, bool):
|
| 147 |
+
extras.append(clock(int(stamp)))
|
| 148 |
+
if item.get("done"):
|
| 149 |
+
extras.append("done")
|
| 150 |
+
return f"{text} ({', '.join(extras)})" if extras and text else text or str(item)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
def to_markdown(
|
| 154 |
meeting: Dict[str, Any],
|
| 155 |
segments: Sequence[Dict[str, Any]],
|
|
|
|
| 176 |
if isinstance(value, str):
|
| 177 |
out.append(f"\n{value}\n")
|
| 178 |
else:
|
| 179 |
+
out.extend(f"\n- {note_line(item)}" for item in value)
|
| 180 |
out.append("\n")
|
| 181 |
|
| 182 |
if keyframes:
|
backend/app/meetingsense/intelligence/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Meeting intelligence — the typed layer on top of a finished transcript.
|
| 2 |
+
|
| 3 |
+
One ``MeetingAnalysis`` per transcript version: topics, decisions (confirmed / proposed /
|
| 4 |
+
rejected), risks, action items, open questions and conclusions, every one of them carrying a
|
| 5 |
+
verbatim quote and the transcript line it came from. A validator keeps an item only when its
|
| 6 |
+
quote really is in that line, so what the visual page shows is what the meeting said.
|
| 7 |
+
|
| 8 |
+
From the analysis the module derives a ``VisualSpec`` — the one-page summary as a list of
|
| 9 |
+
typed blocks whose cells reference analysis items by id — and a handful of suggested
|
| 10 |
+
questions for the assistant.
|
| 11 |
+
|
| 12 |
+
Additive and flag-gated (``MEETINGS_INTELLIGENCE``, default off). Nothing here touches
|
| 13 |
+
MeetingSense's own tables, routes or the live recorder, and nothing here calls a model when
|
| 14 |
+
none is configured: without one, the analysis is assembled from the meeting's own words and
|
| 15 |
+
says so (``degraded == "extractive"``).
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from .schema import SCHEMA_VERSION, MeetingAnalysis, SuggestedQuestion, VisualSpec
|
| 19 |
+
|
| 20 |
+
__all__ = ["SCHEMA_VERSION", "MeetingAnalysis", "SuggestedQuestion", "VisualSpec"]
|
backend/app/meetingsense/intelligence/analyze.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Transcript → ``MeetingAnalysis``: map each chunk to cited candidates, reduce once.
|
| 2 |
+
|
| 3 |
+
The shape follows ``minutes.py`` on purpose — the same chunker, the same bounded prompts,
|
| 4 |
+
the same "one outage, one log line" rule — but the output is data, not prose:
|
| 5 |
+
|
| 6 |
+
* **map**: every chunk yields candidate topics, decisions, risks, actions and questions,
|
| 7 |
+
each with a verbatim quote and the stamp of the line it came from;
|
| 8 |
+
* **reduce**: one call merges the candidates into the final lists and *cites candidate
|
| 9 |
+
ids* instead of re-quoting, so evidence survives the merge untouched;
|
| 10 |
+
* **validate** (``validate.py``): anything whose quote is not in the transcript is dropped.
|
| 11 |
+
|
| 12 |
+
Without a model the analysis is **extractive**: cue words pick the lines that sound like
|
| 13 |
+
decisions, actions, risks and questions, each citing itself, and ``degraded`` says so. That is
|
| 14 |
+
the honest floor — the meeting's own words, never a guess dressed as a summary.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import asyncio
|
| 20 |
+
import json
|
| 21 |
+
import logging
|
| 22 |
+
import re
|
| 23 |
+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence, Tuple
|
| 24 |
+
|
| 25 |
+
from .. import export, minutes
|
| 26 |
+
from .schema import (
|
| 27 |
+
ActionItem, Conclusion, Decision, Evidence, MeetingAnalysis, OpenQuestion, Risk, SpeakerStat, Topic,
|
| 28 |
+
ValidatorReport,
|
| 29 |
+
)
|
| 30 |
+
from .validate import normalise, validate
|
| 31 |
+
|
| 32 |
+
log = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
ModelCall = Callable[..., Awaitable[str]]
|
| 35 |
+
|
| 36 |
+
#: Parallel map calls per analysis. Four keeps a 60-minute meeting to a handful of rounds
|
| 37 |
+
#: without starving the chat model that shares the GPU.
|
| 38 |
+
MAP_CONCURRENCY = 4
|
| 39 |
+
MAP_MAX_TOKENS = 900
|
| 40 |
+
REDUCE_MAX_TOKENS = 1400
|
| 41 |
+
#: Candidate caps per kind before the reduce — a bound on the prompt, not on the meeting.
|
| 42 |
+
CANDIDATE_CAP = {"topics": 48, "decisions": 40, "risks": 30, "actions": 40, "questions": 30}
|
| 43 |
+
|
| 44 |
+
MAP_SYSTEM = """\
|
| 45 |
+
You read one consecutive slice of a meeting transcript and list what it establishes.
|
| 46 |
+
Return ONLY a JSON object with the keys topics, decisions, risks, actions, questions.
|
| 47 |
+
|
| 48 |
+
Rules:
|
| 49 |
+
- Quote verbatim. Every decision, risk, action and question carries "quote": the exact words \
|
| 50 |
+
of ONE transcript line (copy them, never paraphrase) and "t0": that line's [hh:mm:ss] stamp.
|
| 51 |
+
- A decision is "confirmed" only when the speakers agree ("we agreed", "let's go with", \
|
| 52 |
+
"the decision is"); "proposed" for a suggestion ("we could", "maybe", "I think we should"); \
|
| 53 |
+
"rejected" when it was turned down.
|
| 54 |
+
- Never invent owners or dates: leave them empty unless said. Owners use speaker names as \
|
| 55 |
+
written.
|
| 56 |
+
- Keep every text under 30 words. Any list may be empty. No commentary outside the JSON.
|
| 57 |
+
|
| 58 |
+
Shape:
|
| 59 |
+
{"topics":[{"label":"...","summary":"..."}],
|
| 60 |
+
"decisions":[{"statement":"...","status":"confirmed|proposed|rejected","quote":"...","t0":"hh:mm:ss"}],
|
| 61 |
+
"risks":[{"title":"...","severity":"high|medium|low","detail":"...","root_cause":"...",\
|
| 62 |
+
"solutions":["..."],"quote":"...","t0":"hh:mm:ss"}],
|
| 63 |
+
"actions":[{"text":"...","owner":"","due_text":"","quote":"...","t0":"hh:mm:ss"}],
|
| 64 |
+
"questions":[{"text":"...","quote":"...","t0":"hh:mm:ss"}]}"""
|
| 65 |
+
|
| 66 |
+
REDUCE_SYSTEM = """\
|
| 67 |
+
You merge candidate findings from consecutive parts of ONE meeting into its final analysis.
|
| 68 |
+
Return ONLY a JSON object:
|
| 69 |
+
{"title":"...","one_line":"...",
|
| 70 |
+
"topics":[{"label":"...","summary":"...","from":["t3","t7"]}],
|
| 71 |
+
"decisions":[{"statement":"...","status":"confirmed|proposed|rejected","from":["d2","d9"]}],
|
| 72 |
+
"risks":[{"title":"...","severity":"high|medium|low","detail":"...","root_cause":"...",\
|
| 73 |
+
"solutions":["..."],"from":["r1"]}],
|
| 74 |
+
"actions":[{"text":"...","owner":"","due_text":"","from":["a4"]}],
|
| 75 |
+
"questions":[{"text":"...","from":["q2"]}],
|
| 76 |
+
"conclusions":[{"text":"...","from":["d2","r1"]}]}
|
| 77 |
+
|
| 78 |
+
Rules:
|
| 79 |
+
- "from" lists the candidate ids you merged, copied exactly. An item with no candidate id is \
|
| 80 |
+
dropped, so always cite at least one.
|
| 81 |
+
- Merge duplicates. When a proposal was later agreed, the result is ONE confirmed decision \
|
| 82 |
+
citing both candidates. Keep "rejected" items; they matter.
|
| 83 |
+
- The title is specific to this meeting and under 12 words. one_line is the one sentence a \
|
| 84 |
+
colleague who missed the meeting needs.
|
| 85 |
+
- 2 to 4 conclusions, each tied to the candidates that support it. No commentary outside the JSON."""
|
| 86 |
+
|
| 87 |
+
_FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.S)
|
| 88 |
+
_STAMP = re.compile(r"(\d{1,2}):(\d{2}):(\d{2})")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def json_object(text: str) -> Optional[Dict[str, Any]]:
|
| 92 |
+
"""The first JSON object in a model reply — fenced, bare, or buried in prose."""
|
| 93 |
+
if not isinstance(text, str) or not text.strip():
|
| 94 |
+
return None
|
| 95 |
+
body = text.strip()
|
| 96 |
+
fenced = _FENCE.search(body)
|
| 97 |
+
if fenced:
|
| 98 |
+
body = fenced.group(1).strip()
|
| 99 |
+
start = body.find("{")
|
| 100 |
+
if start < 0:
|
| 101 |
+
return None
|
| 102 |
+
depth = 0
|
| 103 |
+
in_str = False
|
| 104 |
+
esc = False
|
| 105 |
+
for i in range(start, len(body)):
|
| 106 |
+
ch = body[i]
|
| 107 |
+
if in_str:
|
| 108 |
+
if esc:
|
| 109 |
+
esc = False
|
| 110 |
+
elif ch == "\\":
|
| 111 |
+
esc = True
|
| 112 |
+
elif ch == '"':
|
| 113 |
+
in_str = False
|
| 114 |
+
continue
|
| 115 |
+
if ch == '"':
|
| 116 |
+
in_str = True
|
| 117 |
+
elif ch == "{":
|
| 118 |
+
depth += 1
|
| 119 |
+
elif ch == "}":
|
| 120 |
+
depth -= 1
|
| 121 |
+
if depth == 0:
|
| 122 |
+
try:
|
| 123 |
+
value = json.loads(body[start:i + 1])
|
| 124 |
+
except ValueError:
|
| 125 |
+
return None
|
| 126 |
+
return value if isinstance(value, dict) else None
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def stamp_ms(value: Any) -> Optional[int]:
|
| 131 |
+
"""``"00:12:34"`` → 754000. Accepts an int of milliseconds as well."""
|
| 132 |
+
if isinstance(value, (int, float)):
|
| 133 |
+
return int(value)
|
| 134 |
+
m = _STAMP.search(str(value or ""))
|
| 135 |
+
if not m:
|
| 136 |
+
return None
|
| 137 |
+
h, mi, s = (int(x) for x in m.groups())
|
| 138 |
+
return (h * 3600 + mi * 60 + s) * 1000
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _text(value: Any, limit: int = 240) -> str:
|
| 142 |
+
out = " ".join(str(value or "").split())
|
| 143 |
+
return out[:limit]
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _list(value: Any) -> List[Any]:
|
| 147 |
+
return [v for v in value if isinstance(v, dict)] if isinstance(value, list) else []
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _strings(value: Any, limit: int = 6) -> List[str]:
|
| 151 |
+
if not isinstance(value, list):
|
| 152 |
+
return []
|
| 153 |
+
return [_text(v, 160) for v in value if _text(v, 160)][:limit]
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _status(value: Any) -> str:
|
| 157 |
+
v = str(value or "").strip().lower()
|
| 158 |
+
return v if v in ("confirmed", "proposed", "rejected") else "proposed"
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _severity(value: Any) -> str:
|
| 162 |
+
v = str(value or "").strip().lower()
|
| 163 |
+
return v if v in ("high", "medium", "low") else "medium"
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ── candidates ──────────────────────────────────────────────────────────────
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
class Candidates:
|
| 170 |
+
"""Everything the map step produced, each with a stable id for the reduce to cite."""
|
| 171 |
+
|
| 172 |
+
def __init__(self) -> None:
|
| 173 |
+
self.items: Dict[str, Dict[str, Any]] = {}
|
| 174 |
+
self.counts = {"t": 0, "d": 0, "r": 0, "a": 0, "q": 0}
|
| 175 |
+
|
| 176 |
+
def add(self, kind: str, payload: Dict[str, Any]) -> str:
|
| 177 |
+
self.counts[kind] += 1
|
| 178 |
+
cid = f"{kind}{self.counts[kind]}"
|
| 179 |
+
self.items[cid] = {"id": cid, **payload}
|
| 180 |
+
return cid
|
| 181 |
+
|
| 182 |
+
def of(self, kind: str) -> List[Dict[str, Any]]:
|
| 183 |
+
return [v for k, v in self.items.items() if k.startswith(kind)]
|
| 184 |
+
|
| 185 |
+
def evidence(self, ids: Sequence[str]) -> List[Evidence]:
|
| 186 |
+
out: List[Evidence] = []
|
| 187 |
+
seen = set()
|
| 188 |
+
for cid in ids:
|
| 189 |
+
item = self.items.get(str(cid))
|
| 190 |
+
if not item:
|
| 191 |
+
continue
|
| 192 |
+
ev = item.get("evidence")
|
| 193 |
+
if isinstance(ev, Evidence) and (ev.t0_ms, ev.quote) not in seen:
|
| 194 |
+
seen.add((ev.t0_ms, ev.quote))
|
| 195 |
+
out.append(ev)
|
| 196 |
+
return out
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _candidate(kind: str, raw: Dict[str, Any], chunk: Dict[str, Any]) -> Dict[str, Any]:
|
| 200 |
+
t0 = stamp_ms(raw.get("t0"))
|
| 201 |
+
if t0 is None:
|
| 202 |
+
t0 = int(chunk.get("t0_ms") or 0)
|
| 203 |
+
ev = Evidence(t0_ms=t0, quote=_text(raw.get("quote"), 300))
|
| 204 |
+
base = {"chunk": chunk.get("index", 0), "t0_ms": t0, "evidence": ev}
|
| 205 |
+
if kind == "d":
|
| 206 |
+
return {**base, "statement": _text(raw.get("statement")), "status": _status(raw.get("status"))}
|
| 207 |
+
if kind == "r":
|
| 208 |
+
return {**base, "title": _text(raw.get("title"), 120), "severity": _severity(raw.get("severity")),
|
| 209 |
+
"detail": _text(raw.get("detail")), "root_cause": _text(raw.get("root_cause")),
|
| 210 |
+
"solutions": _strings(raw.get("solutions"))}
|
| 211 |
+
if kind == "a":
|
| 212 |
+
return {**base, "text": _text(raw.get("text")), "owner": _text(raw.get("owner"), 80) or None,
|
| 213 |
+
"due_text": _text(raw.get("due_text"), 80) or None}
|
| 214 |
+
return {**base, "text": _text(raw.get("text"))}
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _collect(cands: Candidates, parsed: Dict[str, Any], chunk: Dict[str, Any]) -> None:
|
| 218 |
+
for raw in _list(parsed.get("topics")):
|
| 219 |
+
label = _text(raw.get("label"), 80)
|
| 220 |
+
if label:
|
| 221 |
+
cands.add("t", {"label": label, "summary": _text(raw.get("summary")), "chunk": chunk.get("index", 0),
|
| 222 |
+
"t0_ms": int(chunk.get("t0_ms") or 0), "t1_ms": int(chunk.get("t1_ms") or 0)})
|
| 223 |
+
for kind, key, text_key in (("d", "decisions", "statement"), ("r", "risks", "title"),
|
| 224 |
+
("a", "actions", "text"), ("q", "questions", "text")):
|
| 225 |
+
for raw in _list(parsed.get(key)):
|
| 226 |
+
if not _text(raw.get(text_key)) or not _text(raw.get("quote")):
|
| 227 |
+
continue
|
| 228 |
+
cands.add(kind, _candidate(kind, raw, chunk))
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def render_candidates(cands: Candidates) -> str:
|
| 232 |
+
"""The reduce prompt's body: one line per candidate, ids first so the model can cite."""
|
| 233 |
+
lines: List[str] = []
|
| 234 |
+
for item in cands.of("t")[:CANDIDATE_CAP["topics"]]:
|
| 235 |
+
lines.append(f"{item['id']} topic: {item['label']} — {item.get('summary', '')}".rstrip(" —"))
|
| 236 |
+
for item in cands.of("d")[:CANDIDATE_CAP["decisions"]]:
|
| 237 |
+
lines.append(f"{item['id']} decision [{item['status']}] ({export.clock(item['t0_ms'])}): "
|
| 238 |
+
f"{item['statement']} — \"{item['evidence'].quote}\"")
|
| 239 |
+
for item in cands.of("r")[:CANDIDATE_CAP["risks"]]:
|
| 240 |
+
lines.append(f"{item['id']} risk [{item['severity']}] ({export.clock(item['t0_ms'])}): {item['title']}"
|
| 241 |
+
f"{' — ' + item['detail'] if item.get('detail') else ''}")
|
| 242 |
+
for item in cands.of("a")[:CANDIDATE_CAP["actions"]]:
|
| 243 |
+
owner = f" owner={item['owner']}" if item.get("owner") else ""
|
| 244 |
+
due = f" due={item['due_text']}" if item.get("due_text") else ""
|
| 245 |
+
lines.append(f"{item['id']} action ({export.clock(item['t0_ms'])}):{owner}{due} {item['text']}")
|
| 246 |
+
for item in cands.of("q")[:CANDIDATE_CAP["questions"]]:
|
| 247 |
+
lines.append(f"{item['id']} question ({export.clock(item['t0_ms'])}): {item['text']}")
|
| 248 |
+
return "\n".join(lines)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# ── model steps ─────────────────────────────────────────────────────────────
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
async def _ask(call: Optional[ModelCall], messages: List[Dict[str, str]], *, max_tokens: int,
|
| 255 |
+
outage: Optional[minutes._Outage], what: str) -> Optional[Dict[str, Any]]:
|
| 256 |
+
if call is None:
|
| 257 |
+
return None
|
| 258 |
+
try:
|
| 259 |
+
raw = await call(messages, temperature=0.1, max_tokens=max_tokens, response_format="json")
|
| 260 |
+
except Exception as exc: # noqa: BLE001 — a failed model is a state, not a crash
|
| 261 |
+
if outage is not None:
|
| 262 |
+
outage.record(exc, what)
|
| 263 |
+
return None
|
| 264 |
+
return json_object(raw) if isinstance(raw, str) else None
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
async def map_candidates(chunks: Sequence[Dict[str, Any]], *, call: Optional[ModelCall],
|
| 268 |
+
outage: Optional[minutes._Outage] = None) -> Tuple[Candidates, int]:
|
| 269 |
+
"""Candidates from every chunk, ``MAP_CONCURRENCY`` at a time. Returns the candidates and
|
| 270 |
+
how many chunks a model actually answered for."""
|
| 271 |
+
cands = Candidates()
|
| 272 |
+
if call is None or not chunks:
|
| 273 |
+
return cands, 0
|
| 274 |
+
sem = asyncio.Semaphore(MAP_CONCURRENCY)
|
| 275 |
+
|
| 276 |
+
async def one(chunk: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
| 277 |
+
async with sem:
|
| 278 |
+
messages = [
|
| 279 |
+
{"role": "system", "content": MAP_SYSTEM},
|
| 280 |
+
{"role": "user", "content": f"Transcript lines {minutes._range_label(chunk)}:\n\n"
|
| 281 |
+
f"{minutes.render_chunk(chunk['segments'])}"},
|
| 282 |
+
]
|
| 283 |
+
return await _ask(call, messages, max_tokens=MAP_MAX_TOKENS, outage=outage, what="analysis map")
|
| 284 |
+
|
| 285 |
+
results = await asyncio.gather(*(one(c) for c in chunks))
|
| 286 |
+
answered = 0
|
| 287 |
+
for chunk, parsed in zip(chunks, results):
|
| 288 |
+
if parsed is None:
|
| 289 |
+
continue
|
| 290 |
+
answered += 1
|
| 291 |
+
_collect(cands, parsed, chunk)
|
| 292 |
+
return cands, answered
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
async def reduce_candidates(cands: Candidates, *, meeting_title: str, call: Optional[ModelCall],
|
| 296 |
+
outage: Optional[minutes._Outage] = None) -> Optional[Dict[str, Any]]:
|
| 297 |
+
body = render_candidates(cands)
|
| 298 |
+
if not body.strip():
|
| 299 |
+
return None
|
| 300 |
+
messages = [
|
| 301 |
+
{"role": "system", "content": REDUCE_SYSTEM},
|
| 302 |
+
{"role": "user", "content": f"Meeting title as recorded: {meeting_title or 'untitled'}\n\nCandidates:\n{body}"},
|
| 303 |
+
]
|
| 304 |
+
return await _ask(call, messages, max_tokens=REDUCE_MAX_TOKENS, outage=outage, what="analysis reduce")
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
# ── assembling the analysis ─────────────────────────────────────────────────
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def speaker_stats(segments: Sequence[Dict[str, Any]]) -> List[SpeakerStat]:
|
| 311 |
+
talk: Dict[str, int] = {}
|
| 312 |
+
lines: Dict[str, int] = {}
|
| 313 |
+
for seg in segments:
|
| 314 |
+
label = export.speaker_label(seg.get("speaker")) if seg.get("speaker") in ("me", "them") \
|
| 315 |
+
else (seg.get("speaker") or "")
|
| 316 |
+
if not label:
|
| 317 |
+
continue
|
| 318 |
+
t0 = int(seg.get("t0_ms") or 0)
|
| 319 |
+
t1 = seg.get("t1_ms")
|
| 320 |
+
dur = max(0, int(t1) - t0) if t1 is not None else 4000
|
| 321 |
+
talk[label] = talk.get(label, 0) + dur
|
| 322 |
+
lines[label] = lines.get(label, 0) + 1
|
| 323 |
+
total = sum(talk.values()) or 1
|
| 324 |
+
out = [SpeakerStat(label=k, talk_ms=v, share=round(v / total, 3), lines=lines[k]) for k, v in talk.items()]
|
| 325 |
+
out.sort(key=lambda s: -s.talk_ms)
|
| 326 |
+
return out
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def _topics_from_candidates(cands: Candidates, reduced: Optional[Dict[str, Any]]) -> List[Topic]:
|
| 330 |
+
out: List[Topic] = []
|
| 331 |
+
rows = _list((reduced or {}).get("topics"))
|
| 332 |
+
if rows:
|
| 333 |
+
for i, raw in enumerate(rows[:12], start=1):
|
| 334 |
+
label = _text(raw.get("label"), 80)
|
| 335 |
+
if not label:
|
| 336 |
+
continue
|
| 337 |
+
refs = [cands.items[c] for c in (raw.get("from") or []) if str(c) in cands.items]
|
| 338 |
+
t0 = min((r["t0_ms"] for r in refs), default=0)
|
| 339 |
+
t1 = max((r.get("t1_ms", r["t0_ms"]) for r in refs), default=t0)
|
| 340 |
+
out.append(Topic(id=f"t{i}", label=label, t0_ms=int(t0), t1_ms=int(t1), summary=_text(raw.get("summary"))))
|
| 341 |
+
if out:
|
| 342 |
+
return out
|
| 343 |
+
# No reduce: one topic per candidate label, de-duplicated, in time order.
|
| 344 |
+
seen = set()
|
| 345 |
+
for item in sorted(cands.of("t"), key=lambda c: c["t0_ms"]):
|
| 346 |
+
key = normalise(item["label"])
|
| 347 |
+
if key in seen:
|
| 348 |
+
continue
|
| 349 |
+
seen.add(key)
|
| 350 |
+
out.append(Topic(id=f"t{len(out) + 1}", label=item["label"], t0_ms=item["t0_ms"],
|
| 351 |
+
t1_ms=item.get("t1_ms", item["t0_ms"]), summary=item.get("summary", "")))
|
| 352 |
+
return out[:12]
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def assemble(cands: Candidates, reduced: Optional[Dict[str, Any]], *, meeting_id: str, meeting_title: str,
|
| 356 |
+
segments: Sequence[Dict[str, Any]], model: Optional[str]) -> MeetingAnalysis:
|
| 357 |
+
"""Turn reduce output (or, failing that, the raw candidates) into the typed analysis."""
|
| 358 |
+
analysis = MeetingAnalysis(meeting_id=meeting_id, title=meeting_title or "Meeting", model=model)
|
| 359 |
+
analysis.speakers = speaker_stats(segments)
|
| 360 |
+
analysis.topics = _topics_from_candidates(cands, reduced)
|
| 361 |
+
|
| 362 |
+
def merged(kind: str, key: str) -> List[Tuple[Dict[str, Any], List[Evidence]]]:
|
| 363 |
+
rows = _list((reduced or {}).get(key))
|
| 364 |
+
out: List[Tuple[Dict[str, Any], List[Evidence]]] = []
|
| 365 |
+
if rows:
|
| 366 |
+
for raw in rows:
|
| 367 |
+
ids = [str(c) for c in (raw.get("from") or [])]
|
| 368 |
+
ev = cands.evidence(ids)
|
| 369 |
+
if ev:
|
| 370 |
+
out.append((raw, ev))
|
| 371 |
+
return out
|
| 372 |
+
seen = set()
|
| 373 |
+
text_key = {"d": "statement", "r": "title", "a": "text", "q": "text"}[kind]
|
| 374 |
+
for item in cands.of(kind):
|
| 375 |
+
k = normalise(item[text_key])
|
| 376 |
+
if not k or k in seen:
|
| 377 |
+
continue
|
| 378 |
+
seen.add(k)
|
| 379 |
+
out.append((item, [item["evidence"]]))
|
| 380 |
+
return out
|
| 381 |
+
|
| 382 |
+
analysis.title = _text((reduced or {}).get("title"), 120) or analysis.title
|
| 383 |
+
analysis.one_line = _text((reduced or {}).get("one_line"), 300)
|
| 384 |
+
|
| 385 |
+
for i, (raw, ev) in enumerate(merged("d", "decisions"), start=1):
|
| 386 |
+
analysis.decisions.append(Decision(id=f"d{i}", statement=_text(raw.get("statement")), status=_status(raw.get("status")),
|
| 387 |
+
evidence=ev, confidence=0.8 if reduced else 0.6))
|
| 388 |
+
for i, (raw, ev) in enumerate(merged("r", "risks"), start=1):
|
| 389 |
+
analysis.risks.append(Risk(id=f"r{i}", title=_text(raw.get("title"), 120), severity=_severity(raw.get("severity")),
|
| 390 |
+
detail=_text(raw.get("detail")), root_cause=_text(raw.get("root_cause")),
|
| 391 |
+
solutions=_strings(raw.get("solutions")), evidence=ev))
|
| 392 |
+
for i, (raw, ev) in enumerate(merged("a", "actions"), start=1):
|
| 393 |
+
analysis.action_items.append(ActionItem(id=f"a{i}", text=_text(raw.get("text")), owner=_text(raw.get("owner"), 80) or None,
|
| 394 |
+
due_text=_text(raw.get("due_text"), 80) or None, evidence=ev,
|
| 395 |
+
confidence=0.8 if reduced else 0.6))
|
| 396 |
+
for i, (raw, ev) in enumerate(merged("q", "questions"), start=1):
|
| 397 |
+
analysis.open_questions.append(OpenQuestion(id=f"q{i}", text=_text(raw.get("text")), evidence=ev))
|
| 398 |
+
for i, raw in enumerate(_list((reduced or {}).get("conclusions"))[:4], start=1):
|
| 399 |
+
ev = cands.evidence([str(c) for c in (raw.get("from") or [])])
|
| 400 |
+
text = _text(raw.get("text"), 300)
|
| 401 |
+
if text and ev:
|
| 402 |
+
analysis.conclusions.append(Conclusion(id=f"c{i}", text=text, evidence=ev))
|
| 403 |
+
return analysis
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
# ── the extractive floor ────────────────────────────────────────────────────
|
| 407 |
+
|
| 408 |
+
_DECIDED = re.compile(r"\b(we agreed|agreed to|we decided|decided to|let'?s go with|the decision is|we will go with|"
|
| 409 |
+
r"go ahead with|approved|sign(?:ed)? off)\b", re.I)
|
| 410 |
+
_PROPOSED = re.compile(r"\b(we could|could we|maybe we|i think we should|we should|what if we|how about|i propose|we propose|"
|
| 411 |
+
r"propose that|proposing|i suggest|we might|one option is|an option would be)\b", re.I)
|
| 412 |
+
_REJECTED = re.compile(r"\b(we won'?t|we will not|not going to|rejected|drop that|decided against|ruled out|"
|
| 413 |
+
r"let'?s not)\b", re.I)
|
| 414 |
+
_ACTION = re.compile(r"\b(i will|i'?ll|we will|we'?ll|we need to|needs to|has to|to do|action item|follow up|follow-up|"
|
| 415 |
+
r"send (?:you|the)|schedule|set up|prepare|complete|launch|inject|review)\b", re.I)
|
| 416 |
+
_DUE = re.compile(r"\b(today|tomorrow|tonight|this week|next week|end of (?:the )?(?:week|month|day)|by (?:monday|tuesday|"
|
| 417 |
+
r"wednesday|thursday|friday|saturday|sunday|tomorrow|eod|next week)|on (?:monday|tuesday|wednesday|thursday|"
|
| 418 |
+
r"friday)|next (?:monday|tuesday|wednesday|thursday|friday))\b", re.I)
|
| 419 |
+
_RISK = re.compile(r"\b(risk|problem|issue|blocked|blocker|fails?|failure|error|bug|concern|broken|missing|unavailable|"
|
| 420 |
+
r"cannot|can'?t|doesn'?t work|not working|outage)\b", re.I)
|
| 421 |
+
_HIGH = re.compile(r"\b(critical|urgent|high priority|blocked|blocker|outage|data loss|security)\b", re.I)
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def fallback_analysis(segments: Sequence[Dict[str, Any]], *, meeting_id: str, meeting_title: str) -> MeetingAnalysis:
|
| 425 |
+
"""The no-model analysis: lines that sound like decisions, actions, risks and questions,
|
| 426 |
+
each citing itself. Topics are the transcript in order, one per chunk, named by their
|
| 427 |
+
most frequent words. ``degraded`` is set so every surface can say what this is."""
|
| 428 |
+
rows = [s for s in segments if (s.get("text") or "").strip()]
|
| 429 |
+
analysis = MeetingAnalysis(meeting_id=meeting_id, title=meeting_title or "Meeting", degraded="extractive")
|
| 430 |
+
analysis.speakers = speaker_stats(rows)
|
| 431 |
+
|
| 432 |
+
def ev(seg: Dict[str, Any]) -> List[Evidence]:
|
| 433 |
+
return [Evidence(segment_id=str(seg.get("id")) if seg.get("id") else None, t0_ms=int(seg.get("t0_ms") or 0),
|
| 434 |
+
t1_ms=seg.get("t1_ms"), quote=_text(seg.get("text"), 300))]
|
| 435 |
+
|
| 436 |
+
def who(seg: Dict[str, Any]) -> Optional[str]:
|
| 437 |
+
sp = seg.get("speaker")
|
| 438 |
+
if not sp:
|
| 439 |
+
return None
|
| 440 |
+
return export.speaker_label(sp) if sp in ("me", "them") else str(sp)
|
| 441 |
+
|
| 442 |
+
def decision(seg: Dict[str, Any], text: str, status: str) -> None:
|
| 443 |
+
analysis.decisions.append(Decision(id=f"d{len(analysis.decisions) + 1}", statement=text, status=status, # type: ignore[arg-type]
|
| 444 |
+
evidence=ev(seg), confidence=0.4))
|
| 445 |
+
|
| 446 |
+
# Strong cues first (an explicit agreement or refusal), then problems, then commitments,
|
| 447 |
+
# then mere suggestions: a line that says "that is a real problem" is a risk even when it
|
| 448 |
+
# also contains a softer phrase.
|
| 449 |
+
for seg in rows:
|
| 450 |
+
text = _text(seg.get("text"), 300)
|
| 451 |
+
if len(analysis.decisions) < 12 and _DECIDED.search(text):
|
| 452 |
+
decision(seg, _cue_sentence(text, _DECIDED), "confirmed")
|
| 453 |
+
continue
|
| 454 |
+
if len(analysis.decisions) < 12 and _REJECTED.search(text):
|
| 455 |
+
decision(seg, _cue_sentence(text, _REJECTED), "rejected")
|
| 456 |
+
continue
|
| 457 |
+
if len(analysis.risks) < 8 and _RISK.search(text):
|
| 458 |
+
analysis.risks.append(Risk(id=f"r{len(analysis.risks) + 1}", title=_headline(_cue_sentence(text, _RISK)),
|
| 459 |
+
severity="high" if _HIGH.search(text) else "medium", detail=_clean(text), evidence=ev(seg)))
|
| 460 |
+
continue
|
| 461 |
+
if len(analysis.action_items) < 12 and _ACTION.search(text):
|
| 462 |
+
due = _DUE.search(text)
|
| 463 |
+
analysis.action_items.append(ActionItem(id=f"a{len(analysis.action_items) + 1}", text=_cue_sentence(text, _ACTION),
|
| 464 |
+
owner=who(seg), due_text=due.group(0) if due else None, evidence=ev(seg),
|
| 465 |
+
confidence=0.4))
|
| 466 |
+
continue
|
| 467 |
+
if len(analysis.decisions) < 12 and _PROPOSED.search(text):
|
| 468 |
+
decision(seg, _cue_sentence(text, _PROPOSED), "proposed")
|
| 469 |
+
continue
|
| 470 |
+
if len(analysis.open_questions) < 8 and text.rstrip().endswith("?") and len(text.split()) >= 4:
|
| 471 |
+
analysis.open_questions.append(OpenQuestion(id=f"q{len(analysis.open_questions) + 1}", text=_clean(text), owner=who(seg),
|
| 472 |
+
evidence=ev(seg)))
|
| 473 |
+
|
| 474 |
+
chunks = minutes.chunk(rows, max_words=400, overlap_words=0, max_chunks=12)
|
| 475 |
+
for i, chunk in enumerate(chunks, start=1):
|
| 476 |
+
digest = minutes.extractive_digest(chunk["segments"], limit=1)
|
| 477 |
+
analysis.topics.append(Topic(id=f"t{i}", label=_topic_label(chunk["segments"]), t0_ms=chunk["t0_ms"],
|
| 478 |
+
t1_ms=chunk["t1_ms"], summary=_strip_stamps(digest)))
|
| 479 |
+
if chunks:
|
| 480 |
+
first = minutes.extractive_digest(rows[: min(len(rows), 40)], limit=1)
|
| 481 |
+
analysis.one_line = _strip_stamps(first)
|
| 482 |
+
analysis.validator = ValidatorReport(kept=len(analysis.decisions) + len(analysis.action_items) + len(analysis.risks)
|
| 483 |
+
+ len(analysis.open_questions),
|
| 484 |
+
notes=["No language model answered; items are the meeting's own lines, chosen by cue words."])
|
| 485 |
+
return analysis
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def _strip_stamps(text: str) -> str:
|
| 489 |
+
return re.sub(r"\[\d{2}:\d{2}:\d{2}\]\s*", "", text or "").strip()
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
_LABEL = re.compile(r"^(?:(?:another|a|the|one|first|second|big|main|core|key)\s+)?"
|
| 493 |
+
r"(?:risk|problem|issue|concern|blocker|summary|note|update|open question|question|action item|action|"
|
| 494 |
+
r"decision|proposal|suggestion|recap|takeaway)\s*:\s*", re.I)
|
| 495 |
+
_OPENER = re.compile(r"^(?:(?:the|a|one|another)\s+(?:core|main|big|real|key|other|first)?\s*(?:problem|issue|risk|concern|thing|point)"
|
| 496 |
+
r"\s+(?:is|was|here is)\s+that\s+|that\s+is\s+(?:a|an|the)\s+|there\s+is\s+(?:a|an)\s+|this\s+is\s+(?:a|an)\s+|"
|
| 497 |
+
r"i\s+think\s+(?:that\s+)?|so\s+|also,?\s+|and\s+|but\s+|well,?\s+|ok(?:ay)?,?\s+|fine\.?\s+)", re.I)
|
| 498 |
+
_SENTENCE = re.compile(r"(?<=[.!?])\s+")
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
def _trim_lead(text: str) -> str:
|
| 502 |
+
"""Drop a leading label ("Another risk:") or opener ("The core problem is that") so a
|
| 503 |
+
title or statement starts on the substance. Words only; the evidence quote stays whole."""
|
| 504 |
+
out = (text or "").strip()
|
| 505 |
+
for _ in range(2):
|
| 506 |
+
out = _OPENER.sub("", _LABEL.sub("", out))
|
| 507 |
+
return out.strip()
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
def _clean(text: str) -> str:
|
| 511 |
+
out = _trim_lead(text) or (text or "").strip()
|
| 512 |
+
return out[:1].upper() + out[1:]
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
def _cue_sentence(text: str, pattern: "re.Pattern[str]") -> str:
|
| 516 |
+
"""The sentence of a line that carries the cue. A cue sentence too short to stand alone
|
| 517 |
+
("Agreed.") gives way to the next sentence when that one carries the cue too, and is
|
| 518 |
+
joined with it otherwise."""
|
| 519 |
+
pieces = [p for p in _SENTENCE.split((text or "").strip()) if p]
|
| 520 |
+
for i, piece in enumerate(pieces):
|
| 521 |
+
if not pattern.search(piece):
|
| 522 |
+
continue
|
| 523 |
+
out = piece
|
| 524 |
+
if len(piece.split()) < 5 and i + 1 < len(pieces):
|
| 525 |
+
out = pieces[i + 1] if pattern.search(pieces[i + 1]) else f"{piece} {pieces[i + 1]}"
|
| 526 |
+
return _clean(out)
|
| 527 |
+
return _clean(text)
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
def _headline(text: str, limit: int = 90) -> str:
|
| 531 |
+
"""A card title from a transcript line: its first clause, cut at a word, never mid-word."""
|
| 532 |
+
lead = _trim_lead(text) or (text or "").strip()
|
| 533 |
+
first = re.split(r"(?<=[.!?])\s+|;\s+|,\s+(?=(?:and|but|so|which|because|while|although)\b)|\s+so that\b",
|
| 534 |
+
lead, maxsplit=1)[0].strip()
|
| 535 |
+
first = first[:1].upper() + first[1:]
|
| 536 |
+
if len(first) <= limit:
|
| 537 |
+
return first.rstrip(".,;:")
|
| 538 |
+
cut = first[:limit].rsplit(" ", 1)[0].rstrip(".,;:")
|
| 539 |
+
return f"{cut}…"
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
def _topic_label(segments: Sequence[Dict[str, Any]]) -> str:
|
| 543 |
+
counts: Dict[str, int] = {}
|
| 544 |
+
for seg in segments:
|
| 545 |
+
for m in minutes._WORD.finditer((seg.get("text") or "").lower()):
|
| 546 |
+
w = m.group(0)
|
| 547 |
+
if w in minutes._STOP or len(w) < 4:
|
| 548 |
+
continue
|
| 549 |
+
counts[w] = counts.get(w, 0) + 1
|
| 550 |
+
top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:3]
|
| 551 |
+
return ", ".join(w for w, _ in top).capitalize() or "Discussion"
|
| 552 |
+
|
| 553 |
+
|
| 554 |
+
# ── entry point ─────────────────────────────────────────────────────────────
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
async def analyse(segments: Sequence[Dict[str, Any]], *, meeting_id: str, meeting_title: str,
|
| 558 |
+
call: Optional[ModelCall], model: Optional[str] = None) -> MeetingAnalysis:
|
| 559 |
+
"""The whole pipeline for one transcript. Never raises on a model failure."""
|
| 560 |
+
rows = [s for s in segments if (s.get("text") or "").strip()]
|
| 561 |
+
if not rows:
|
| 562 |
+
return MeetingAnalysis(meeting_id=meeting_id, title=meeting_title or "Meeting", degraded="extractive",
|
| 563 |
+
validator=ValidatorReport(notes=["There is no transcript to analyse."]))
|
| 564 |
+
outage = minutes._Outage(meeting_id)
|
| 565 |
+
chunks = minutes.chunk(rows)
|
| 566 |
+
cands, answered = await map_candidates(chunks, call=call, outage=outage)
|
| 567 |
+
if answered == 0:
|
| 568 |
+
return validate(fallback_analysis(rows, meeting_id=meeting_id, meeting_title=meeting_title), rows)
|
| 569 |
+
reduced = await reduce_candidates(cands, meeting_title=meeting_title, call=call, outage=outage)
|
| 570 |
+
analysis = assemble(cands, reduced, meeting_id=meeting_id, meeting_title=meeting_title, segments=rows, model=model)
|
| 571 |
+
analysis = validate(analysis, rows)
|
| 572 |
+
if reduced is None and analysis.validator is not None:
|
| 573 |
+
analysis.validator.notes.append("The merge step did not answer; items were de-duplicated by text instead.")
|
| 574 |
+
if answered < len(chunks) and analysis.validator is not None:
|
| 575 |
+
analysis.validator.notes.append(f"{len(chunks) - answered} of {len(chunks)} parts went unanswered by the model.")
|
| 576 |
+
return analysis
|
backend/app/meetingsense/intelligence/brief.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The Executive Brief composer: polished wording for the one-page report, strictly grounded.
|
| 2 |
+
|
| 3 |
+
The visual page people expect reads like an executive brief — "Missing DNS verification
|
| 4 |
+
tools", not a transcript line. A model writes that wording here, but under rules the
|
| 5 |
+
validator can check:
|
| 6 |
+
|
| 7 |
+
* every card, row, step and the takeaway names the analysis item it rests on; an unknown
|
| 8 |
+
or wrong-kind id is dropped;
|
| 9 |
+
* **facts are never the model's to set**: severity, decision status, due bucket and owner
|
| 10 |
+
come from the verified analysis, and a proposal can never be worded as a decision;
|
| 11 |
+
* numbers and technical terms in the polished text must already appear in the source item
|
| 12 |
+
(its text, quote and detail); anything else falls back to the item's own words;
|
| 13 |
+
* with no model, or a model that fails, the brief is composed deterministically from the
|
| 14 |
+
analysis, so the page always exists and is always true.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
import re
|
| 21 |
+
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Set, Tuple
|
| 22 |
+
|
| 23 |
+
from .analyze import _headline, json_object
|
| 24 |
+
from .validate import normalise
|
| 25 |
+
from .schema import (
|
| 26 |
+
ActionBucket, ActionItem, BriefCard, BriefRow, BriefStep, Decision, ExecutiveBrief, MeetingAnalysis, Risk,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
log = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
ModelCall = Callable[..., Awaitable[str]]
|
| 32 |
+
|
| 33 |
+
STATUS_LABEL = {"confirmed": "Selected", "proposed": "Proposed", "rejected": "Rejected"}
|
| 34 |
+
MAX_RISKS = 2
|
| 35 |
+
MAX_BLOCKERS = 2
|
| 36 |
+
MAX_DECISIONS = 6
|
| 37 |
+
MAX_ACTIONS = 3
|
| 38 |
+
MAX_TOKENS = 1200
|
| 39 |
+
|
| 40 |
+
SYSTEM = """\
|
| 41 |
+
You write the one-page executive brief of a meeting from its verified analysis.
|
| 42 |
+
Return ONLY a JSON object:
|
| 43 |
+
{"eyebrow":"<kind of meeting> · Executive brief","title":"...","intro":"...",
|
| 44 |
+
"risks":[{"item":"r1","title":"...","summary":"...","note":"..."}],
|
| 45 |
+
"blockers":[{"item":"r2","title":"...","summary":"..."}],
|
| 46 |
+
"decisions":[{"item":"d1","option":"...","impact":"..."}],
|
| 47 |
+
"actions":[{"item":"a1","text":"..."}],
|
| 48 |
+
"takeaway":{"text":"...","items":["d1","r1"]}}
|
| 49 |
+
|
| 50 |
+
Rules:
|
| 51 |
+
- Use ONLY the items given, by their ids. Never add a risk, decision or action that is not listed.
|
| 52 |
+
- Titles: at most 6 words. Summaries: at most 2 sentences. "note": one short consequence or cause. \
|
| 53 |
+
"impact": at most 18 words on what the option costs or gains. Action "text": at most 14 words.
|
| 54 |
+
- Keep every number, name, product and technical term exactly as the source says it. Do not add facts.
|
| 55 |
+
- A decision's status is given; if it is "proposed", write it as an option under consideration, never \
|
| 56 |
+
as chosen, agreed or selected. If it is "rejected", say it was set aside.
|
| 57 |
+
- "blockers" are risks that depend on something missing or external (tools, access, another team); \
|
| 58 |
+
other risks stay in "risks". A risk appears in one list only.
|
| 59 |
+
- The takeaway is at most 40 words, cites the ids it rests on, and does not announce decisions that \
|
| 60 |
+
were only proposed.
|
| 61 |
+
- No commentary outside the JSON."""
|
| 62 |
+
|
| 63 |
+
_NUM_WORDS = ("zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|"
|
| 64 |
+
"sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|"
|
| 65 |
+
"hundred|thousand|million|billion|dozen|half|quarter|percent")
|
| 66 |
+
# Digits and spelled-out quantities alike: "50", "3.5", "12%", "fifty", "a dozen".
|
| 67 |
+
_NUM = re.compile(rf"\d+(?:[.,]\d+)?%?|\b(?:{_NUM_WORDS})\b", re.I)
|
| 68 |
+
_TERM = re.compile(r"\b(?:[A-Z][a-z]+(?:[A-Z][a-z]*)+|[A-Z]{2,}(?:[A-Z0-9-]*)|[a-z]+[A-Z][A-Za-z]*)\b")
|
| 69 |
+
_CHOSEN = re.compile(r"\b(selected|chosen|approved|agreed|decided|decision is|will adopt|adopted|go with|green-?lit)\b", re.I)
|
| 70 |
+
_BLOCKER = re.compile(r"\b(block\w*|missing|unavailable|depend\w*|waiting|access|not available|need\w* (?:the )?tool|"
|
| 71 |
+
r"environment|another team|external)\b", re.I)
|
| 72 |
+
_IMMEDIATE = re.compile(r"\b(today|now|immediately|asap|right away|tonight|tomorrow|this morning|this afternoon)\b", re.I)
|
| 73 |
+
_THIS_WEEK = re.compile(r"\b(this week|end of (?:the )?week|by friday|friday|thursday|wednesday|eow|in the next (?:few )?days|"
|
| 74 |
+
r"by (?:monday|tuesday|wednesday|thursday|friday)(?! next))\b", re.I)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _words(text: str, limit: int) -> str:
|
| 78 |
+
parts = (text or "").split()
|
| 79 |
+
if len(parts) <= limit:
|
| 80 |
+
return " ".join(parts)
|
| 81 |
+
return " ".join(parts[:limit]).rstrip(",;:") + "…"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _sentences(text: str, limit: int) -> str:
|
| 85 |
+
pieces = re.split(r"(?<=[.!?])\s+", (text or "").strip())
|
| 86 |
+
return " ".join(p for p in pieces[:limit] if p)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _source_text(item: Any) -> str:
|
| 90 |
+
parts = [getattr(item, "statement", ""), getattr(item, "title", ""), getattr(item, "text", ""),
|
| 91 |
+
getattr(item, "detail", ""), getattr(item, "root_cause", "")]
|
| 92 |
+
parts += list(getattr(item, "solutions", []) or [])
|
| 93 |
+
parts += [e.quote for e in (getattr(item, "evidence", []) or [])]
|
| 94 |
+
return " ".join(p for p in parts if p)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _proper_nouns(text: str) -> List[str]:
|
| 98 |
+
"""Capitalised words that are not the first word of their sentence — names and products."""
|
| 99 |
+
out: List[str] = []
|
| 100 |
+
for sentence in re.split(r"(?<=[.!?])\s+", text or ""):
|
| 101 |
+
for token in sentence.split()[1:]:
|
| 102 |
+
core = token.strip("\"'()[],.;:!?“”’")
|
| 103 |
+
if re.fullmatch(r"[A-Z][a-z]{2,}", core):
|
| 104 |
+
out.append(core)
|
| 105 |
+
return out
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def terms_preserved(polished: str, source: str, *, sentence_case: bool = True) -> bool:
|
| 109 |
+
"""Every number and every term that looks technical (CamelCase, ALLCAPS, mixedCase) in the
|
| 110 |
+
polished text must already be in the source; in sentence-case fields, so must every
|
| 111 |
+
capitalised word after the first (a name or a product). Ordinary words may change; facts
|
| 112 |
+
may not. Title-case fields (card titles, option labels) skip the proper-noun rule, since
|
| 113 |
+
there every word is capitalised."""
|
| 114 |
+
src = (source or "").lower()
|
| 115 |
+
for number in _NUM.findall(polished or ""):
|
| 116 |
+
if number.lower() not in src:
|
| 117 |
+
return False
|
| 118 |
+
src_terms = {t.lower() for t in _TERM.findall(source or "")}
|
| 119 |
+
for term in _TERM.findall(polished or ""):
|
| 120 |
+
if term.lower() not in src_terms and term.lower() not in src:
|
| 121 |
+
return False
|
| 122 |
+
if sentence_case:
|
| 123 |
+
for noun in _proper_nouns(polished):
|
| 124 |
+
if noun.lower() not in src:
|
| 125 |
+
return False
|
| 126 |
+
return True
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def bucket_for(action: ActionItem) -> Tuple[ActionBucket, str]:
|
| 130 |
+
"""The timeline column an action belongs to, from its own due words — never from a model."""
|
| 131 |
+
due = f"{action.due_text or ''} {action.due or ''}"
|
| 132 |
+
if _IMMEDIATE.search(due):
|
| 133 |
+
return "Immediate", action.due_text or "Immediate"
|
| 134 |
+
if _THIS_WEEK.search(due):
|
| 135 |
+
return "This week", action.due_text or "This week"
|
| 136 |
+
return "Next steps", action.due_text or action.due or "Next steps"
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def is_blocker(risk: Risk) -> bool:
|
| 140 |
+
return bool(_BLOCKER.search(_source_text(risk)))
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def source_count(analysis: MeetingAnalysis, used: Iterable[str]) -> int:
|
| 144 |
+
seen: Set[Any] = set()
|
| 145 |
+
for item_id in used:
|
| 146 |
+
item = analysis.item(item_id)
|
| 147 |
+
for ev in getattr(item, "evidence", []) or []:
|
| 148 |
+
seen.add(ev.segment_id or ev.t0_ms)
|
| 149 |
+
return len(seen)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ── deterministic composition (the floor) ──────────────────────────────────
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def compose_fallback(analysis: MeetingAnalysis) -> ExecutiveBrief:
|
| 156 |
+
"""The brief built from the analysis alone: honest wording, same structure."""
|
| 157 |
+
brief = ExecutiveBrief(title=analysis.title or "Meeting", intro=analysis.one_line or "")
|
| 158 |
+
risks = sorted(analysis.risks, key=lambda r: ({"high": 0, "medium": 1, "low": 2}[r.severity],
|
| 159 |
+
r.evidence[0].t0_ms if r.evidence else 0))
|
| 160 |
+
for r in risks:
|
| 161 |
+
title = _headline(r.title, 60)
|
| 162 |
+
card = BriefCard(item=r.id, title=title, summary=_rest(r.detail, title),
|
| 163 |
+
note=r.root_cause or (r.solutions[0] if r.solutions else ""), severity=r.severity)
|
| 164 |
+
target = brief.blockers if is_blocker(r) and len(brief.blockers) < MAX_BLOCKERS else brief.risks
|
| 165 |
+
if len(target) < (MAX_RISKS if target is brief.risks else MAX_BLOCKERS):
|
| 166 |
+
target.append(card)
|
| 167 |
+
ordered = sorted(analysis.decisions, key=lambda d: ({"confirmed": 0, "rejected": 1, "proposed": 2}[d.status],
|
| 168 |
+
d.evidence[0].t0_ms if d.evidence else 0))
|
| 169 |
+
for d in ordered[:MAX_DECISIONS]:
|
| 170 |
+
brief.decisions.append(BriefRow(item=d.id, option=_headline(d.statement, 96), status=d.status,
|
| 171 |
+
status_label=STATUS_LABEL[d.status]))
|
| 172 |
+
for a in analysis.action_items:
|
| 173 |
+
bucket, label = bucket_for(a)
|
| 174 |
+
brief.actions.append(BriefStep(item=a.id, bucket=bucket, label=label, text=_words(a.text, 16), owner=a.owner))
|
| 175 |
+
brief.actions = _spread(brief.actions)[:MAX_ACTIONS]
|
| 176 |
+
brief.open_questions = [q.id for q in analysis.open_questions[:4]]
|
| 177 |
+
# The takeaway always rests on a verified item: a conclusion, else a confirmed decision,
|
| 178 |
+
# else the gravest risk — and it is never the intro said twice.
|
| 179 |
+
candidates: List[Tuple[str, str]] = [(c.id, c.text) for c in analysis.conclusions]
|
| 180 |
+
candidates += [(d.id, d.statement) for d in analysis.decisions if d.status == "confirmed"]
|
| 181 |
+
candidates += [(r.id, r.detail or r.title) for r in risks]
|
| 182 |
+
for item_id, text in candidates:
|
| 183 |
+
if text and normalise(text) != normalise(brief.intro):
|
| 184 |
+
brief.takeaway, brief.takeaway_items = text, [item_id]
|
| 185 |
+
break
|
| 186 |
+
intro_n = normalise(brief.intro)
|
| 187 |
+
if intro_n and any(normalise(c.summary) in intro_n or intro_n in normalise(c.summary)
|
| 188 |
+
for c in brief.risks + brief.blockers if c.summary):
|
| 189 |
+
brief.intro = "" # already said on a card
|
| 190 |
+
used = [c.item for c in brief.risks + brief.blockers] + [r.item for r in brief.decisions] \
|
| 191 |
+
+ [s.item for s in brief.actions] + brief.open_questions + brief.takeaway_items
|
| 192 |
+
brief.source_count = source_count(analysis, used)
|
| 193 |
+
brief.eyebrow = _eyebrow(analysis)
|
| 194 |
+
return brief
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _rest(detail: str, title: str) -> str:
|
| 198 |
+
"""What a card's sentence adds beyond its title: the remainder when the sentence opens with
|
| 199 |
+
the title's words, the whole sentence otherwise, nothing when nothing is left."""
|
| 200 |
+
d, t = (detail or "").strip(), (title or "").rstrip("…").strip()
|
| 201 |
+
if not d or not t or d.lower() == t.lower():
|
| 202 |
+
return "" if d.lower() == t.lower() else d
|
| 203 |
+
if d.lower().startswith(t.lower()):
|
| 204 |
+
rest = re.sub(r"^(?:so|and|but|which|because|while|then)\s+", "", d[len(t):].lstrip(" ,;:—-"), flags=re.I)
|
| 205 |
+
if len(rest.split()) < 3:
|
| 206 |
+
return ""
|
| 207 |
+
return rest[:1].upper() + rest[1:]
|
| 208 |
+
return d
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def _spread(steps: List[BriefStep]) -> List[BriefStep]:
|
| 212 |
+
"""Prefer one step per bucket (Immediate, This week, Next steps) before filling a bucket twice."""
|
| 213 |
+
order: Dict[str, int] = {"Immediate": 0, "This week": 1, "Next steps": 2}
|
| 214 |
+
by_bucket: Dict[str, List[BriefStep]] = {}
|
| 215 |
+
for s in steps:
|
| 216 |
+
by_bucket.setdefault(s.bucket, []).append(s)
|
| 217 |
+
out: List[BriefStep] = []
|
| 218 |
+
for bucket in sorted(by_bucket, key=lambda b: order[b]):
|
| 219 |
+
out.append(by_bucket[bucket][0])
|
| 220 |
+
for bucket in sorted(by_bucket, key=lambda b: order[b]):
|
| 221 |
+
out.extend(by_bucket[bucket][1:])
|
| 222 |
+
return out
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _eyebrow(analysis: MeetingAnalysis) -> str:
|
| 226 |
+
text = " ".join(t.label for t in analysis.topics).lower()
|
| 227 |
+
if re.search(r"\b(deploy|pipeline|agent|api|dns|server|bug|latency|model|prompt|code|release)\b", text):
|
| 228 |
+
return "Technical meeting · Executive brief"
|
| 229 |
+
if re.search(r"\b(budget|revenue|cost|pricing|forecast|sales)\b", text):
|
| 230 |
+
return "Business review · Executive brief"
|
| 231 |
+
return "Meeting · Executive brief"
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# ── the polished composition ───────────────────────────────────────────────
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _catalog(analysis: MeetingAnalysis) -> str:
|
| 238 |
+
lines: List[str] = [f"Title as recorded: {analysis.title}", f"One line: {analysis.one_line}"]
|
| 239 |
+
for r in analysis.risks:
|
| 240 |
+
kind = "blocker-candidate" if is_blocker(r) else "risk"
|
| 241 |
+
lines.append(f"{r.id} {kind} [{r.severity}]: {r.title} | detail: {r.detail} | cause: {r.root_cause} | "
|
| 242 |
+
f"solutions: {'; '.join(r.solutions)} | quote: \"{r.evidence[0].quote if r.evidence else ''}\"")
|
| 243 |
+
for d in analysis.decisions:
|
| 244 |
+
lines.append(f"{d.id} decision [{d.status}]: {d.statement} | quote: \"{d.evidence[0].quote if d.evidence else ''}\"")
|
| 245 |
+
for a in analysis.action_items:
|
| 246 |
+
lines.append(f"{a.id} action: {a.text} | owner: {a.owner or 'not stated'} | due: {a.due_text or 'not stated'}")
|
| 247 |
+
for q in analysis.open_questions:
|
| 248 |
+
lines.append(f"{q.id} open question: {q.text}")
|
| 249 |
+
for c in analysis.conclusions:
|
| 250 |
+
lines.append(f"{c.id} conclusion: {c.text}")
|
| 251 |
+
return "\n".join(lines)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def _card(raw: Dict[str, Any], risk: Risk, *, notes: List[str], fallback: BriefCard) -> BriefCard:
|
| 255 |
+
source = _source_text(risk)
|
| 256 |
+
title = _words(str(raw.get("title") or ""), 6)
|
| 257 |
+
summary = _sentences(str(raw.get("summary") or ""), 2)
|
| 258 |
+
note = _words(str(raw.get("note") or ""), 20)
|
| 259 |
+
ok = bool(title) and terms_preserved(title, source, sentence_case=False) \
|
| 260 |
+
and all(terms_preserved(t, source) for t in (summary, note) if t)
|
| 261 |
+
if not ok:
|
| 262 |
+
notes.append(f"{risk.id}: polished wording dropped (unsupported term or number); the meeting's words are shown.")
|
| 263 |
+
return fallback
|
| 264 |
+
return BriefCard(item=risk.id, title=title, summary=summary, note=note, severity=risk.severity, polished=True)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def _row(raw: Dict[str, Any], decision: Decision, *, notes: List[str], fallback: BriefRow) -> BriefRow:
|
| 268 |
+
source = _source_text(decision)
|
| 269 |
+
option = _words(str(raw.get("option") or ""), 8)
|
| 270 |
+
impact = _words(str(raw.get("impact") or ""), 18)
|
| 271 |
+
if not option or not terms_preserved(option, source, sentence_case=False) or (impact and not terms_preserved(impact, source)):
|
| 272 |
+
notes.append(f"{decision.id}: polished wording dropped (unsupported term or number).")
|
| 273 |
+
return fallback
|
| 274 |
+
if decision.status != "confirmed" and (_CHOSEN.search(option) or _CHOSEN.search(impact)):
|
| 275 |
+
notes.append(f"{decision.id}: wording presented a {decision.status} item as decided; the verified wording is shown.")
|
| 276 |
+
return fallback
|
| 277 |
+
return BriefRow(item=decision.id, option=option, status=decision.status, status_label=STATUS_LABEL[decision.status],
|
| 278 |
+
impact=impact, polished=True)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def _step(raw: Dict[str, Any], action: ActionItem, *, notes: List[str], fallback: BriefStep) -> BriefStep:
|
| 282 |
+
text = _words(str(raw.get("text") or ""), 14)
|
| 283 |
+
if not text or not terms_preserved(text, _source_text(action)):
|
| 284 |
+
notes.append(f"{action.id}: polished wording dropped (unsupported term or number).")
|
| 285 |
+
return fallback
|
| 286 |
+
return BriefStep(item=action.id, bucket=fallback.bucket, label=fallback.label, text=text, owner=action.owner, polished=True)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def merge(parsed: Dict[str, Any], analysis: MeetingAnalysis) -> ExecutiveBrief:
|
| 290 |
+
"""Fold the model's wording onto the deterministic brief, field by field, keeping each
|
| 291 |
+
polished field only when the validator accepts it."""
|
| 292 |
+
base = compose_fallback(analysis)
|
| 293 |
+
notes: List[str] = []
|
| 294 |
+
out = ExecutiveBrief(**base.model_dump())
|
| 295 |
+
used_any = False
|
| 296 |
+
|
| 297 |
+
eyebrow = _words(str(parsed.get("eyebrow") or ""), 6)
|
| 298 |
+
if eyebrow and "brief" in eyebrow.lower():
|
| 299 |
+
out.eyebrow = eyebrow
|
| 300 |
+
title = _words(str(parsed.get("title") or ""), 12)
|
| 301 |
+
if title and terms_preserved(title, _catalog(analysis), sentence_case=False):
|
| 302 |
+
out.title, used_any = title, True
|
| 303 |
+
intro = _sentences(str(parsed.get("intro") or ""), 1)
|
| 304 |
+
if intro and terms_preserved(intro, _catalog(analysis)) and not (_CHOSEN.search(intro) and not any(
|
| 305 |
+
d.status == "confirmed" for d in analysis.decisions)):
|
| 306 |
+
out.intro, used_any = intro, True
|
| 307 |
+
|
| 308 |
+
risks_by_id = {r.id: r for r in analysis.risks}
|
| 309 |
+
base_cards = {c.item: c for c in base.risks + base.blockers}
|
| 310 |
+
seen: Set[str] = set()
|
| 311 |
+
new_risks: List[BriefCard] = []
|
| 312 |
+
new_blockers: List[BriefCard] = []
|
| 313 |
+
for key, target, cap in (("risks", new_risks, MAX_RISKS), ("blockers", new_blockers, MAX_BLOCKERS)):
|
| 314 |
+
for raw in parsed.get(key) or []:
|
| 315 |
+
if not isinstance(raw, dict):
|
| 316 |
+
continue
|
| 317 |
+
rid = str(raw.get("item") or "")
|
| 318 |
+
risk = risks_by_id.get(rid)
|
| 319 |
+
if risk is None or rid in seen or len(target) >= cap:
|
| 320 |
+
continue
|
| 321 |
+
seen.add(rid)
|
| 322 |
+
fallback = base_cards.get(rid) or BriefCard(item=rid, title=_headline(risk.title, 60), summary=risk.detail,
|
| 323 |
+
note=risk.root_cause, severity=risk.severity)
|
| 324 |
+
card = _card(raw, risk, notes=notes, fallback=fallback)
|
| 325 |
+
used_any = used_any or card.polished
|
| 326 |
+
target.append(card)
|
| 327 |
+
if new_risks or new_blockers:
|
| 328 |
+
out.risks, out.blockers = new_risks, new_blockers
|
| 329 |
+
|
| 330 |
+
decisions_by_id = {d.id: d for d in analysis.decisions}
|
| 331 |
+
base_rows = {r.item: r for r in base.decisions}
|
| 332 |
+
new_rows: List[BriefRow] = []
|
| 333 |
+
for raw in parsed.get("decisions") or []:
|
| 334 |
+
if not isinstance(raw, dict):
|
| 335 |
+
continue
|
| 336 |
+
did = str(raw.get("item") or "")
|
| 337 |
+
decision = decisions_by_id.get(did)
|
| 338 |
+
if decision is None or any(r.item == did for r in new_rows) or len(new_rows) >= MAX_DECISIONS:
|
| 339 |
+
continue
|
| 340 |
+
fallback = base_rows.get(did) or BriefRow(item=did, option=_headline(decision.statement, 70), status=decision.status,
|
| 341 |
+
status_label=STATUS_LABEL[decision.status])
|
| 342 |
+
row = _row(raw, decision, notes=notes, fallback=fallback)
|
| 343 |
+
used_any = used_any or row.polished
|
| 344 |
+
new_rows.append(row)
|
| 345 |
+
if new_rows:
|
| 346 |
+
out.decisions = new_rows
|
| 347 |
+
|
| 348 |
+
actions_by_id = {a.id: a for a in analysis.action_items}
|
| 349 |
+
base_steps = {s.item: s for s in base.actions}
|
| 350 |
+
new_steps: List[BriefStep] = []
|
| 351 |
+
for raw in parsed.get("actions") or []:
|
| 352 |
+
if not isinstance(raw, dict):
|
| 353 |
+
continue
|
| 354 |
+
aid = str(raw.get("item") or "")
|
| 355 |
+
action = actions_by_id.get(aid)
|
| 356 |
+
if action is None or any(s.item == aid for s in new_steps):
|
| 357 |
+
continue
|
| 358 |
+
bucket, label = bucket_for(action)
|
| 359 |
+
fallback = base_steps.get(aid) or BriefStep(item=aid, bucket=bucket, label=label, text=_words(action.text, 16), owner=action.owner)
|
| 360 |
+
step = _step(raw, action, notes=notes, fallback=fallback)
|
| 361 |
+
used_any = used_any or step.polished
|
| 362 |
+
new_steps.append(step)
|
| 363 |
+
if new_steps:
|
| 364 |
+
out.actions = _spread(new_steps)[:MAX_ACTIONS]
|
| 365 |
+
|
| 366 |
+
take = parsed.get("takeaway") if isinstance(parsed.get("takeaway"), dict) else {}
|
| 367 |
+
text = _words(str(take.get("text") or ""), 40)
|
| 368 |
+
ids = [str(i) for i in (take.get("items") or []) if analysis.item(str(i)) is not None]
|
| 369 |
+
if text and ids:
|
| 370 |
+
cited = " ".join(_source_text(analysis.item(i)) for i in ids)
|
| 371 |
+
confirmed_cited = any(getattr(analysis.item(i), "status", "") == "confirmed" for i in ids)
|
| 372 |
+
if terms_preserved(text, cited) and (confirmed_cited or not _CHOSEN.search(text)):
|
| 373 |
+
out.takeaway, out.takeaway_items, used_any = text, ids, True
|
| 374 |
+
else:
|
| 375 |
+
notes.append("takeaway: polished wording dropped (unsupported term or an unconfirmed decision stated as made).")
|
| 376 |
+
|
| 377 |
+
used = [c.item for c in out.risks + out.blockers] + [r.item for r in out.decisions] + [s.item for s in out.actions] \
|
| 378 |
+
+ out.open_questions + out.takeaway_items
|
| 379 |
+
out.source_count = source_count(analysis, used)
|
| 380 |
+
out.polished = used_any
|
| 381 |
+
out.notes = notes
|
| 382 |
+
return out
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
async def compose(analysis: MeetingAnalysis, *, call: Optional[ModelCall]) -> ExecutiveBrief:
|
| 386 |
+
"""The Executive Brief for an analysis: polished when a model answers and the validator
|
| 387 |
+
agrees, the meeting's own words otherwise. Never raises."""
|
| 388 |
+
if call is None or analysis.degraded is not None or not (analysis.risks or analysis.decisions or analysis.action_items):
|
| 389 |
+
return compose_fallback(analysis)
|
| 390 |
+
try:
|
| 391 |
+
raw = await call([{"role": "system", "content": SYSTEM}, {"role": "user", "content": _catalog(analysis)}],
|
| 392 |
+
temperature=0.2, max_tokens=MAX_TOKENS, response_format="json")
|
| 393 |
+
parsed = json_object(raw) if isinstance(raw, str) else None
|
| 394 |
+
except Exception as exc: # noqa: BLE001 — a failed model is a state, not a crash
|
| 395 |
+
log.warning("meetingsense: brief composer failed for %s (%s); using the meeting's own words", analysis.meeting_id, exc)
|
| 396 |
+
parsed = None
|
| 397 |
+
if not parsed:
|
| 398 |
+
brief = compose_fallback(analysis)
|
| 399 |
+
brief.notes.append("The model did not answer; wording is taken from the transcript.")
|
| 400 |
+
return brief
|
| 401 |
+
return merge(parsed, analysis)
|
backend/app/meetingsense/intelligence/layout.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""From a ``MeetingAnalysis`` to the one-page ``VisualSpec`` — deterministically.
|
| 2 |
+
|
| 3 |
+
The page InSight renders is a small vocabulary of blocks (risk cards, a status table, a
|
| 4 |
+
step timeline, a highlighted conclusion) filled from the analysis. Choosing those blocks is
|
| 5 |
+
a job rules do reliably and a model does inconsistently, so the layout is built here, by
|
| 6 |
+
rules, from the analysis alone: block order and tints follow what the meeting contains, and
|
| 7 |
+
every cell is either an item id or text copied verbatim from an analysis item.
|
| 8 |
+
|
| 9 |
+
A later batch may let a model *propose* a different arrangement; its output would be
|
| 10 |
+
validated against this schema and this builder stays the fallback.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from typing import List
|
| 16 |
+
|
| 17 |
+
from .schema import MeetingAnalysis, VisualBlock, VisualSpec
|
| 18 |
+
|
| 19 |
+
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
| 20 |
+
STATUS_LABEL = {"confirmed": "Confirmed", "proposed": "Proposed", "rejected": "Rejected"}
|
| 21 |
+
STATUS_TONE = {"confirmed": "green", "proposed": "amber", "rejected": "rose"}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _clock(ms: int) -> str:
|
| 25 |
+
total = max(0, int(ms or 0)) // 1000
|
| 26 |
+
h, rest = divmod(total, 3600)
|
| 27 |
+
m, s = divmod(rest, 60)
|
| 28 |
+
return f"{h:02d}:{m:02d}:{s:02d}"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def build_spec(analysis: MeetingAnalysis, *, analysis_id: str = "", template: str = "autopilot") -> VisualSpec:
|
| 32 |
+
blocks: List[VisualBlock] = []
|
| 33 |
+
counts = analysis.counts()
|
| 34 |
+
|
| 35 |
+
blocks.append(VisualBlock(type="header", title=analysis.title or "Meeting", text=analysis.one_line or None,
|
| 36 |
+
meta={"degraded": analysis.degraded, "counts": counts}))
|
| 37 |
+
|
| 38 |
+
kpis = [
|
| 39 |
+
{"label": "Decisions", "value": counts["decisions"], "key": "decisions"},
|
| 40 |
+
{"label": "Action items", "value": counts["actions"], "key": "actions"},
|
| 41 |
+
{"label": "Risks", "value": counts["risks"], "key": "risks"},
|
| 42 |
+
{"label": "Open questions", "value": counts["questions"], "key": "questions"},
|
| 43 |
+
]
|
| 44 |
+
if analysis.speakers:
|
| 45 |
+
lead = analysis.speakers[0]
|
| 46 |
+
kpis.append({"label": "Most talk time", "value": f"{round(lead.share * 100)}%", "key": "speaker", "sub": lead.label})
|
| 47 |
+
blocks.append(VisualBlock(type="kpi_row", meta={"kpis": kpis}))
|
| 48 |
+
|
| 49 |
+
if analysis.risks:
|
| 50 |
+
ordered = sorted(analysis.risks, key=lambda r: (SEVERITY_ORDER.get(r.severity, 1), r.evidence[0].t0_ms if r.evidence else 0))
|
| 51 |
+
blocks.append(VisualBlock(type="risk_cards", title="Risks and problems", items=[r.id for r in ordered[:4]]))
|
| 52 |
+
|
| 53 |
+
if analysis.decisions:
|
| 54 |
+
ordered = sorted(analysis.decisions, key=lambda d: ({"confirmed": 0, "rejected": 1, "proposed": 2}[d.status],
|
| 55 |
+
d.evidence[0].t0_ms if d.evidence else 0))
|
| 56 |
+
blocks.append(VisualBlock(type="decision_list", title="Decisions", items=[d.id for d in ordered[:8]]))
|
| 57 |
+
if len(ordered) >= 2 and len({d.status for d in ordered}) >= 2:
|
| 58 |
+
rows = [[d.statement, STATUS_LABEL[d.status], _clock(d.evidence[0].t0_ms) if d.evidence else ""] for d in ordered[:6]]
|
| 59 |
+
blocks.append(VisualBlock(type="comparison_table", title="Decision status at a glance",
|
| 60 |
+
columns=["Decision", "Status", "Said at"], rows=rows,
|
| 61 |
+
meta={"status_column": 1, "time_column": 2, "items": [d.id for d in ordered[:6]]}))
|
| 62 |
+
|
| 63 |
+
if analysis.action_items:
|
| 64 |
+
ordered = sorted(analysis.action_items, key=lambda a: (a.due or "9999", a.evidence[0].t0_ms if a.evidence else 0))
|
| 65 |
+
blocks.append(VisualBlock(type="timeline", title="Action items", items=[a.id for a in ordered[:6]]))
|
| 66 |
+
|
| 67 |
+
if analysis.open_questions:
|
| 68 |
+
blocks.append(VisualBlock(type="section", title="Still open", items=[q.id for q in analysis.open_questions[:6]], tone="amber"))
|
| 69 |
+
|
| 70 |
+
if analysis.topics and len(analysis.topics) > 1:
|
| 71 |
+
blocks.append(VisualBlock(type="section", title="The meeting in order", items=[t.id for t in analysis.topics[:8]], tone="slate"))
|
| 72 |
+
|
| 73 |
+
conclusion = analysis.conclusions[0].text if analysis.conclusions else analysis.one_line
|
| 74 |
+
if conclusion:
|
| 75 |
+
blocks.append(VisualBlock(type="callout", title="Key conclusion", text=conclusion,
|
| 76 |
+
items=[analysis.conclusions[0].id] if analysis.conclusions else [], tone="violet"))
|
| 77 |
+
|
| 78 |
+
return VisualSpec(analysis_id=analysis_id, template=template, blocks=blocks)
|
backend/app/meetingsense/intelligence/questions.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Suggested questions: three to five things a colleague who missed the meeting would ask.
|
| 2 |
+
|
| 3 |
+
Generated from the *analysis* (never the raw transcript) so they point at what the page
|
| 4 |
+
already knows how to answer, and so the call is small. Without a model, templates over the
|
| 5 |
+
analysis's own items produce questions that are still specific to this meeting.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Any, Awaitable, Callable, List, Optional
|
| 11 |
+
|
| 12 |
+
from .analyze import json_object
|
| 13 |
+
from .schema import MeetingAnalysis, SuggestedQuestion
|
| 14 |
+
|
| 15 |
+
ModelCall = Callable[..., Awaitable[str]]
|
| 16 |
+
|
| 17 |
+
SYSTEM = """\
|
| 18 |
+
Write the questions a colleague who missed this meeting would ask first. Each must be answerable \
|
| 19 |
+
from the meeting's transcript. Return ONLY JSON: {"questions":[{"text":"...","kind":"decisions|actions|risks|why|open|general"}]}. \
|
| 20 |
+
Four questions, each under 18 words, specific to this meeting (name the things discussed), no yes/no questions."""
|
| 21 |
+
|
| 22 |
+
KIND_VALUES = {"decisions", "actions", "risks", "why", "open", "general"}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _brief(analysis: MeetingAnalysis) -> str:
|
| 26 |
+
parts = [f"Title: {analysis.title}", f"Summary: {analysis.one_line}"]
|
| 27 |
+
if analysis.decisions:
|
| 28 |
+
parts.append("Decisions: " + "; ".join(f"{d.statement} [{d.status}]" for d in analysis.decisions[:6]))
|
| 29 |
+
if analysis.risks:
|
| 30 |
+
parts.append("Risks: " + "; ".join(r.title for r in analysis.risks[:5]))
|
| 31 |
+
if analysis.action_items:
|
| 32 |
+
parts.append("Actions: " + "; ".join(f"{a.text}{' (' + a.owner + ')' if a.owner else ''}" for a in analysis.action_items[:6]))
|
| 33 |
+
if analysis.open_questions:
|
| 34 |
+
parts.append("Open: " + "; ".join(q.text for q in analysis.open_questions[:4]))
|
| 35 |
+
return "\n".join(parts)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def fallback_questions(analysis: MeetingAnalysis) -> List[SuggestedQuestion]:
|
| 39 |
+
out: List[SuggestedQuestion] = []
|
| 40 |
+
|
| 41 |
+
def add(text: str, kind: str) -> None:
|
| 42 |
+
if len(out) < 5 and text and all(q.text != text for q in out):
|
| 43 |
+
out.append(SuggestedQuestion(id=f"sq{len(out) + 1}", text=text, kind=kind)) # type: ignore[arg-type]
|
| 44 |
+
|
| 45 |
+
if analysis.decisions:
|
| 46 |
+
d = analysis.decisions[0]
|
| 47 |
+
add(f"What was decided about “{_short(d.statement)}”, and was it confirmed?", "decisions")
|
| 48 |
+
if analysis.action_items:
|
| 49 |
+
a = analysis.action_items[0]
|
| 50 |
+
add(f"Who is responsible for “{_short(a.text)}” and by when?", "actions")
|
| 51 |
+
if analysis.risks:
|
| 52 |
+
r = analysis.risks[0]
|
| 53 |
+
add(f"What is the plan for the problem “{_short(r.title)}”?", "risks")
|
| 54 |
+
if analysis.topics:
|
| 55 |
+
add(f"Why did the discussion focus on {analysis.topics[0].label.lower()}?", "why")
|
| 56 |
+
if analysis.open_questions:
|
| 57 |
+
add("Which questions were left unresolved at the end?", "open")
|
| 58 |
+
add("What are the next steps after this meeting?", "general")
|
| 59 |
+
return out[:4]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _short(text: str, limit: int = 60) -> str:
|
| 63 |
+
t = " ".join((text or "").split())
|
| 64 |
+
return t if len(t) <= limit else t[:limit].rsplit(" ", 1)[0] + "…"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
async def suggest(analysis: MeetingAnalysis, *, call: Optional[ModelCall]) -> List[SuggestedQuestion]:
|
| 68 |
+
if call is not None and analysis.degraded is None:
|
| 69 |
+
try:
|
| 70 |
+
raw = await call([{"role": "system", "content": SYSTEM}, {"role": "user", "content": _brief(analysis)}],
|
| 71 |
+
temperature=0.3, max_tokens=400, response_format="json")
|
| 72 |
+
parsed = json_object(raw) if isinstance(raw, str) else None
|
| 73 |
+
except Exception: # noqa: BLE001
|
| 74 |
+
parsed = None
|
| 75 |
+
rows: List[Any] = (parsed or {}).get("questions") or []
|
| 76 |
+
out: List[SuggestedQuestion] = []
|
| 77 |
+
for raw_q in rows:
|
| 78 |
+
if not isinstance(raw_q, dict):
|
| 79 |
+
continue
|
| 80 |
+
text = " ".join(str(raw_q.get("text") or "").split())[:200]
|
| 81 |
+
kind = str(raw_q.get("kind") or "general").strip().lower()
|
| 82 |
+
if text:
|
| 83 |
+
out.append(SuggestedQuestion(id=f"sq{len(out) + 1}", text=text,
|
| 84 |
+
kind=kind if kind in KIND_VALUES else "general")) # type: ignore[arg-type]
|
| 85 |
+
if len(out) == 5:
|
| 86 |
+
break
|
| 87 |
+
if len(out) >= 3:
|
| 88 |
+
return out
|
| 89 |
+
return fallback_questions(analysis)
|
backend/app/meetingsense/intelligence/schema.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The typed meeting analysis (schema v1) and the visual-summary spec built from it.
|
| 2 |
+
|
| 3 |
+
Pydantic models, because this is the contract three consumers share: the extraction
|
| 4 |
+
pipeline writes it, the visual renderer reads it, and a future DayPilot pulls it over HTTP.
|
| 5 |
+
``json_schema()`` publishes the same shape for them.
|
| 6 |
+
|
| 7 |
+
Every item that makes a claim about the meeting carries ``evidence``: the transcript line
|
| 8 |
+
(``segment_id`` / ``t0_ms``) and the verbatim ``quote`` the claim rests on. Topics and
|
| 9 |
+
speaker statistics are descriptive and carry none.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import time
|
| 15 |
+
from typing import Any, Dict, List, Literal, Optional
|
| 16 |
+
|
| 17 |
+
from pydantic import BaseModel, Field
|
| 18 |
+
|
| 19 |
+
SCHEMA_VERSION = 1
|
| 20 |
+
|
| 21 |
+
DecisionStatus = Literal["confirmed", "proposed", "rejected"]
|
| 22 |
+
Severity = Literal["high", "medium", "low"]
|
| 23 |
+
RiskStatus = Literal["open", "mitigated", "resolved"]
|
| 24 |
+
ActionStatus = Literal["open", "done", "dropped"]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class Evidence(BaseModel):
|
| 28 |
+
segment_id: Optional[str] = None
|
| 29 |
+
t0_ms: int = 0
|
| 30 |
+
t1_ms: Optional[int] = None
|
| 31 |
+
quote: str = ""
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class Topic(BaseModel):
|
| 35 |
+
id: str
|
| 36 |
+
label: str
|
| 37 |
+
t0_ms: int = 0
|
| 38 |
+
t1_ms: int = 0
|
| 39 |
+
summary: str = ""
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class Decision(BaseModel):
|
| 43 |
+
id: str
|
| 44 |
+
statement: str
|
| 45 |
+
status: DecisionStatus = "proposed"
|
| 46 |
+
topic_id: Optional[str] = None
|
| 47 |
+
evidence: List[Evidence] = Field(default_factory=list)
|
| 48 |
+
confidence: float = 0.5
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class Risk(BaseModel):
|
| 52 |
+
id: str
|
| 53 |
+
title: str
|
| 54 |
+
severity: Severity = "medium"
|
| 55 |
+
detail: str = ""
|
| 56 |
+
root_cause: str = ""
|
| 57 |
+
solutions: List[str] = Field(default_factory=list)
|
| 58 |
+
status: RiskStatus = "open"
|
| 59 |
+
evidence: List[Evidence] = Field(default_factory=list)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class ActionItem(BaseModel):
|
| 63 |
+
id: str
|
| 64 |
+
text: str
|
| 65 |
+
owner: Optional[str] = None
|
| 66 |
+
due: Optional[str] = None
|
| 67 |
+
due_text: Optional[str] = None
|
| 68 |
+
status: ActionStatus = "open"
|
| 69 |
+
evidence: List[Evidence] = Field(default_factory=list)
|
| 70 |
+
confidence: float = 0.5
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class OpenQuestion(BaseModel):
|
| 74 |
+
id: str
|
| 75 |
+
text: str
|
| 76 |
+
owner: Optional[str] = None
|
| 77 |
+
evidence: List[Evidence] = Field(default_factory=list)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class Conclusion(BaseModel):
|
| 81 |
+
id: str
|
| 82 |
+
text: str
|
| 83 |
+
evidence: List[Evidence] = Field(default_factory=list)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class SpeakerStat(BaseModel):
|
| 87 |
+
label: str
|
| 88 |
+
talk_ms: int = 0
|
| 89 |
+
share: float = 0.0
|
| 90 |
+
lines: int = 0
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class ValidatorReport(BaseModel):
|
| 94 |
+
kept: int = 0
|
| 95 |
+
dropped: int = 0
|
| 96 |
+
repaired: int = 0
|
| 97 |
+
notes: List[str] = Field(default_factory=list)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class MeetingAnalysis(BaseModel):
|
| 101 |
+
schema_version: int = SCHEMA_VERSION
|
| 102 |
+
meeting_id: str
|
| 103 |
+
transcript_version_id: Optional[str] = None
|
| 104 |
+
language: str = "en"
|
| 105 |
+
title: str = ""
|
| 106 |
+
one_line: str = ""
|
| 107 |
+
topics: List[Topic] = Field(default_factory=list)
|
| 108 |
+
decisions: List[Decision] = Field(default_factory=list)
|
| 109 |
+
risks: List[Risk] = Field(default_factory=list)
|
| 110 |
+
action_items: List[ActionItem] = Field(default_factory=list)
|
| 111 |
+
open_questions: List[OpenQuestion] = Field(default_factory=list)
|
| 112 |
+
conclusions: List[Conclusion] = Field(default_factory=list)
|
| 113 |
+
speakers: List[SpeakerStat] = Field(default_factory=list)
|
| 114 |
+
#: ``None`` when a model wrote it; ``"extractive"`` when it is the meeting's own words.
|
| 115 |
+
degraded: Optional[str] = None
|
| 116 |
+
model: Optional[str] = None
|
| 117 |
+
created_at: float = Field(default_factory=time.time)
|
| 118 |
+
validator: Optional[ValidatorReport] = None
|
| 119 |
+
|
| 120 |
+
def item(self, item_id: str) -> Optional[BaseModel]:
|
| 121 |
+
"""Look an item up by id across every list (``d3`` → the third decision, …)."""
|
| 122 |
+
for group in (self.decisions, self.risks, self.action_items, self.open_questions,
|
| 123 |
+
self.conclusions, self.topics):
|
| 124 |
+
for entry in group:
|
| 125 |
+
if entry.id == item_id:
|
| 126 |
+
return entry
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
+
def counts(self) -> Dict[str, int]:
|
| 130 |
+
return {
|
| 131 |
+
"topics": len(self.topics), "decisions": len(self.decisions), "risks": len(self.risks),
|
| 132 |
+
"actions": len(self.action_items), "questions": len(self.open_questions),
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class SuggestedQuestion(BaseModel):
|
| 137 |
+
id: str
|
| 138 |
+
text: str
|
| 139 |
+
kind: Literal["decisions", "actions", "risks", "why", "open", "general"] = "general"
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
BlockType = Literal[
|
| 143 |
+
"header", "kpi_row", "section", "risk_cards", "decision_list", "comparison_table",
|
| 144 |
+
"timeline", "callout", "quote",
|
| 145 |
+
]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
class VisualBlock(BaseModel):
|
| 149 |
+
type: BlockType
|
| 150 |
+
title: Optional[str] = None
|
| 151 |
+
#: Analysis item ids this block renders (cards, lists, timeline steps).
|
| 152 |
+
items: List[str] = Field(default_factory=list)
|
| 153 |
+
#: Literal text for blocks that are not item lists (callout, quote, header lead). Always
|
| 154 |
+
#: copied from the analysis by the layout step — never free text from a model.
|
| 155 |
+
text: Optional[str] = None
|
| 156 |
+
columns: List[str] = Field(default_factory=list)
|
| 157 |
+
rows: List[List[str]] = Field(default_factory=list)
|
| 158 |
+
tone: Optional[str] = None
|
| 159 |
+
meta: Dict[str, Any] = Field(default_factory=dict)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
ActionBucket = Literal["Immediate", "This week", "Next steps"]
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
class BriefCard(BaseModel):
|
| 166 |
+
"""A risk or blocker card. ``severity`` always comes from the analysis item; the words
|
| 167 |
+
may be the model's (``polished``) or the item's own."""
|
| 168 |
+
item: str
|
| 169 |
+
title: str
|
| 170 |
+
summary: str = ""
|
| 171 |
+
note: str = ""
|
| 172 |
+
severity: Severity = "medium"
|
| 173 |
+
polished: bool = False
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
class BriefRow(BaseModel):
|
| 177 |
+
"""One row of the decision comparison. ``status`` is the verified decision status and
|
| 178 |
+
``status_label`` is its fixed display word — never a model's choice."""
|
| 179 |
+
item: str
|
| 180 |
+
option: str
|
| 181 |
+
status: DecisionStatus = "proposed"
|
| 182 |
+
status_label: str = "Proposed"
|
| 183 |
+
impact: str = ""
|
| 184 |
+
polished: bool = False
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class BriefStep(BaseModel):
|
| 188 |
+
item: str
|
| 189 |
+
bucket: ActionBucket = "Next steps"
|
| 190 |
+
label: str = ""
|
| 191 |
+
text: str
|
| 192 |
+
owner: Optional[str] = None
|
| 193 |
+
polished: bool = False
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
class ExecutiveBrief(BaseModel):
|
| 197 |
+
"""The one-page Executive Brief: what the paper report renders. Every entry carries the
|
| 198 |
+
analysis item id it rests on, so the renderer can show the transcript evidence and the
|
| 199 |
+
validator can refuse wording that drifted from it."""
|
| 200 |
+
eyebrow: str = "Meeting · Executive brief"
|
| 201 |
+
title: str = ""
|
| 202 |
+
intro: str = ""
|
| 203 |
+
risks: List[BriefCard] = Field(default_factory=list)
|
| 204 |
+
blockers: List[BriefCard] = Field(default_factory=list)
|
| 205 |
+
decisions: List[BriefRow] = Field(default_factory=list)
|
| 206 |
+
actions: List[BriefStep] = Field(default_factory=list)
|
| 207 |
+
open_questions: List[str] = Field(default_factory=list)
|
| 208 |
+
takeaway: str = ""
|
| 209 |
+
takeaway_items: List[str] = Field(default_factory=list)
|
| 210 |
+
#: Distinct transcript moments cited by everything on the page.
|
| 211 |
+
source_count: int = 0
|
| 212 |
+
#: True when at least one field carries model-written wording.
|
| 213 |
+
polished: bool = False
|
| 214 |
+
notes: List[str] = Field(default_factory=list)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
class VisualSpec(BaseModel):
|
| 218 |
+
schema_version: int = SCHEMA_VERSION
|
| 219 |
+
analysis_id: str = ""
|
| 220 |
+
template: str = "executive_brief"
|
| 221 |
+
blocks: List[VisualBlock] = Field(default_factory=list)
|
| 222 |
+
brief: Optional[ExecutiveBrief] = None
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def json_schema() -> Dict[str, Any]:
|
| 226 |
+
"""The published contract: the analysis, the spec and the questions, one document."""
|
| 227 |
+
return {
|
| 228 |
+
"schema_version": SCHEMA_VERSION,
|
| 229 |
+
"analysis": MeetingAnalysis.model_json_schema(),
|
| 230 |
+
"visual": VisualSpec.model_json_schema(),
|
| 231 |
+
"brief": ExecutiveBrief.model_json_schema(),
|
| 232 |
+
"question": SuggestedQuestion.model_json_schema(),
|
| 233 |
+
}
|
backend/app/meetingsense/intelligence/service.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The one entry point the job and the routes call: analyse a meeting and store the result."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Any, Awaitable, Callable, Dict, Optional
|
| 7 |
+
|
| 8 |
+
from .. import store as ms_store
|
| 9 |
+
from . import analyze, brief as brief_mod, layout, questions as questions_mod, store
|
| 10 |
+
from .schema import MeetingAnalysis, VisualSpec
|
| 11 |
+
|
| 12 |
+
log = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
ModelCall = Callable[..., Awaitable[str]]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def compose_spec(analysis: MeetingAnalysis, *, call: Optional[ModelCall], analysis_id: str = "") -> VisualSpec:
|
| 18 |
+
"""The visual spec for an analysis: the block list (reusable by any renderer) plus the
|
| 19 |
+
Executive Brief composed on top of it."""
|
| 20 |
+
spec = layout.build_spec(analysis, analysis_id=analysis_id)
|
| 21 |
+
spec.template = "executive_brief"
|
| 22 |
+
spec.brief = await brief_mod.compose(analysis, call=call)
|
| 23 |
+
return spec
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
async def run_analysis(meeting_id: str, *, call: Optional[ModelCall], model: Optional[str] = None,
|
| 27 |
+
transcript_version_id: Optional[str] = None,
|
| 28 |
+
progress: Optional[Callable[[str, float], None]] = None) -> Dict[str, Any]:
|
| 29 |
+
"""Analyse the meeting's current transcript, build the page and the questions, store all
|
| 30 |
+
three. Returns what :func:`store.latest` returns. Raises ``ValueError`` when there is no
|
| 31 |
+
transcript — the caller turns that into a 409 or a failed job, never into a fake page."""
|
| 32 |
+
meeting = ms_store.get_meeting(meeting_id) or {}
|
| 33 |
+
segments = ms_store.get_segments(meeting_id)
|
| 34 |
+
if not segments:
|
| 35 |
+
raise ValueError("no_transcript")
|
| 36 |
+
|
| 37 |
+
def tick(stage: str, fraction: float) -> None:
|
| 38 |
+
if progress is not None:
|
| 39 |
+
try:
|
| 40 |
+
progress(stage, fraction)
|
| 41 |
+
except Exception: # noqa: BLE001
|
| 42 |
+
pass
|
| 43 |
+
|
| 44 |
+
tick("analysing", 0.1)
|
| 45 |
+
analysis = await analyze.analyse(segments, meeting_id=meeting_id, meeting_title=meeting.get("title") or "",
|
| 46 |
+
call=call, model=model)
|
| 47 |
+
tick("writing the brief", 0.7)
|
| 48 |
+
spec = await compose_spec(analysis, call=call)
|
| 49 |
+
tick("questions", 0.85)
|
| 50 |
+
suggested = await questions_mod.suggest(analysis, call=call)
|
| 51 |
+
tick("saving", 0.95)
|
| 52 |
+
store.save(meeting_id, analysis, spec, suggested, transcript_version_id=transcript_version_id)
|
| 53 |
+
return store.latest(meeting_id) or {}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
async def recompose(meeting_id: str, *, call: Optional[ModelCall]) -> Dict[str, Any]:
|
| 57 |
+
"""Write the brief again for the latest analysis without re-extracting. Raises
|
| 58 |
+
``ValueError("no_analysis")`` when there is nothing to lay out."""
|
| 59 |
+
current_row = store.latest(meeting_id)
|
| 60 |
+
if not current_row:
|
| 61 |
+
raise ValueError("no_analysis")
|
| 62 |
+
analysis = MeetingAnalysis.model_validate(current_row["analysis"])
|
| 63 |
+
spec = await compose_spec(analysis, call=call, analysis_id=current_row["id"])
|
| 64 |
+
store.save_spec(meeting_id, current_row["id"], spec)
|
| 65 |
+
return store.latest(meeting_id) or {}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def current(meeting_id: str) -> Optional[Dict[str, Any]]:
|
| 69 |
+
return store.latest(meeting_id)
|
backend/app/meetingsense/intelligence/store.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persistence for analyses, visual specs and first-class action items.
|
| 2 |
+
|
| 3 |
+
Additive tables beside the library's, on the same connection (``store._connect``), created
|
| 4 |
+
only when the library migrates with ``MEETINGS_INTELLIGENCE`` on. Analyses are versioned
|
| 5 |
+
rows, never overwritten: regenerating keeps the previous page until the new one is ready,
|
| 6 |
+
and the history stays available.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import time
|
| 13 |
+
import uuid
|
| 14 |
+
from typing import Any, Dict, List, Optional, Sequence
|
| 15 |
+
|
| 16 |
+
from .. import store as ms_store
|
| 17 |
+
from .schema import MeetingAnalysis, SuggestedQuestion, VisualSpec
|
| 18 |
+
|
| 19 |
+
_TABLES = (
|
| 20 |
+
"""
|
| 21 |
+
CREATE TABLE IF NOT EXISTS ms_analyses(
|
| 22 |
+
id TEXT PRIMARY KEY,
|
| 23 |
+
meeting_id TEXT NOT NULL,
|
| 24 |
+
transcript_version_id TEXT,
|
| 25 |
+
schema_version INTEGER NOT NULL,
|
| 26 |
+
model TEXT,
|
| 27 |
+
degraded TEXT,
|
| 28 |
+
status TEXT NOT NULL DEFAULT 'ready',
|
| 29 |
+
json TEXT NOT NULL,
|
| 30 |
+
report_json TEXT,
|
| 31 |
+
questions_json TEXT,
|
| 32 |
+
created_at REAL NOT NULL
|
| 33 |
+
)
|
| 34 |
+
""",
|
| 35 |
+
"""
|
| 36 |
+
CREATE TABLE IF NOT EXISTS ms_visual_specs(
|
| 37 |
+
id TEXT PRIMARY KEY,
|
| 38 |
+
analysis_id TEXT NOT NULL,
|
| 39 |
+
meeting_id TEXT NOT NULL,
|
| 40 |
+
template TEXT NOT NULL,
|
| 41 |
+
spec_json TEXT NOT NULL,
|
| 42 |
+
created_at REAL NOT NULL
|
| 43 |
+
)
|
| 44 |
+
""",
|
| 45 |
+
"""
|
| 46 |
+
CREATE TABLE IF NOT EXISTS ms_action_items(
|
| 47 |
+
id TEXT PRIMARY KEY,
|
| 48 |
+
meeting_id TEXT NOT NULL,
|
| 49 |
+
analysis_id TEXT NOT NULL,
|
| 50 |
+
item_id TEXT NOT NULL,
|
| 51 |
+
text TEXT NOT NULL,
|
| 52 |
+
owner TEXT,
|
| 53 |
+
due TEXT,
|
| 54 |
+
due_text TEXT,
|
| 55 |
+
status TEXT NOT NULL DEFAULT 'open',
|
| 56 |
+
evidence_json TEXT,
|
| 57 |
+
updated_at REAL NOT NULL,
|
| 58 |
+
UNIQUE(meeting_id, item_id)
|
| 59 |
+
)
|
| 60 |
+
""",
|
| 61 |
+
)
|
| 62 |
+
_INDEXES = (
|
| 63 |
+
"CREATE INDEX IF NOT EXISTS ix_ms_analyses_meeting ON ms_analyses(meeting_id, created_at)",
|
| 64 |
+
"CREATE INDEX IF NOT EXISTS ix_ms_visual_specs_meeting ON ms_visual_specs(meeting_id, created_at)",
|
| 65 |
+
"CREATE INDEX IF NOT EXISTS ix_ms_action_items_meeting ON ms_action_items(meeting_id)",
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _connect():
|
| 70 |
+
con = ms_store._connect()
|
| 71 |
+
try:
|
| 72 |
+
# WAL and a busy timeout: the worker, the live socket and request handlers all write
|
| 73 |
+
# to this file. Best effort — a test's in-memory connection may refuse either.
|
| 74 |
+
con.execute("PRAGMA busy_timeout = 5000")
|
| 75 |
+
except Exception: # noqa: BLE001
|
| 76 |
+
pass
|
| 77 |
+
return con
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def migrate() -> None:
|
| 81 |
+
con = _connect()
|
| 82 |
+
try:
|
| 83 |
+
cur = con.cursor()
|
| 84 |
+
for statement in _TABLES:
|
| 85 |
+
cur.execute(statement)
|
| 86 |
+
for statement in _INDEXES:
|
| 87 |
+
cur.execute(statement)
|
| 88 |
+
con.commit()
|
| 89 |
+
finally:
|
| 90 |
+
con.close()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _now() -> float:
|
| 94 |
+
return time.time()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def save(meeting_id: str, analysis: MeetingAnalysis, spec: VisualSpec, questions: Sequence[SuggestedQuestion], *,
|
| 98 |
+
transcript_version_id: Optional[str] = None) -> str:
|
| 99 |
+
"""One new analysis row, its spec, and the action items (statuses of items that already
|
| 100 |
+
existed with the same text are carried over, so a tick survives regeneration)."""
|
| 101 |
+
aid = uuid.uuid4().hex
|
| 102 |
+
spec.analysis_id = aid
|
| 103 |
+
now = _now()
|
| 104 |
+
con = _connect()
|
| 105 |
+
try:
|
| 106 |
+
previous = {r["text"]: r for r in con.execute(
|
| 107 |
+
"SELECT text, owner, due, status FROM ms_action_items WHERE meeting_id = ?", (meeting_id,)).fetchall()}
|
| 108 |
+
con.execute(
|
| 109 |
+
"INSERT INTO ms_analyses(id, meeting_id, transcript_version_id, schema_version, model, degraded, status,"
|
| 110 |
+
" json, report_json, questions_json, created_at) VALUES (?, ?, ?, ?, ?, ?, 'ready', ?, ?, ?, ?)",
|
| 111 |
+
(aid, meeting_id, transcript_version_id, analysis.schema_version, analysis.model, analysis.degraded,
|
| 112 |
+
analysis.model_dump_json(), analysis.validator.model_dump_json() if analysis.validator else None,
|
| 113 |
+
json.dumps([q.model_dump() for q in questions]), now),
|
| 114 |
+
)
|
| 115 |
+
con.execute(
|
| 116 |
+
"INSERT INTO ms_visual_specs(id, analysis_id, meeting_id, template, spec_json, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
| 117 |
+
(uuid.uuid4().hex, aid, meeting_id, spec.template, spec.model_dump_json(), now),
|
| 118 |
+
)
|
| 119 |
+
con.execute("DELETE FROM ms_action_items WHERE meeting_id = ?", (meeting_id,))
|
| 120 |
+
for item in analysis.action_items:
|
| 121 |
+
prior = previous.get(item.text)
|
| 122 |
+
con.execute(
|
| 123 |
+
"INSERT INTO ms_action_items(id, meeting_id, analysis_id, item_id, text, owner, due, due_text, status,"
|
| 124 |
+
" evidence_json, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
| 125 |
+
(uuid.uuid4().hex, meeting_id, aid, item.id, item.text,
|
| 126 |
+
(prior["owner"] if prior and prior["owner"] else item.owner),
|
| 127 |
+
(prior["due"] if prior and prior["due"] else item.due), item.due_text,
|
| 128 |
+
(prior["status"] if prior else item.status),
|
| 129 |
+
json.dumps([e.model_dump() for e in item.evidence]), now),
|
| 130 |
+
)
|
| 131 |
+
con.commit()
|
| 132 |
+
finally:
|
| 133 |
+
con.close()
|
| 134 |
+
return aid
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def save_spec(meeting_id: str, analysis_id: str, spec: VisualSpec) -> str:
|
| 138 |
+
"""A new visual spec for an existing analysis (re-layout). The previous spec stays."""
|
| 139 |
+
spec.analysis_id = analysis_id
|
| 140 |
+
sid = uuid.uuid4().hex
|
| 141 |
+
con = _connect()
|
| 142 |
+
try:
|
| 143 |
+
con.execute(
|
| 144 |
+
"INSERT INTO ms_visual_specs(id, analysis_id, meeting_id, template, spec_json, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
| 145 |
+
(sid, analysis_id, meeting_id, spec.template, spec.model_dump_json(), _now()),
|
| 146 |
+
)
|
| 147 |
+
con.commit()
|
| 148 |
+
finally:
|
| 149 |
+
con.close()
|
| 150 |
+
return sid
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def latest(meeting_id: str) -> Optional[Dict[str, Any]]:
|
| 154 |
+
"""The newest analysis with its spec and questions, as plain dicts, or ``None``."""
|
| 155 |
+
con = _connect()
|
| 156 |
+
try:
|
| 157 |
+
row = con.execute("SELECT * FROM ms_analyses WHERE meeting_id = ? AND status = 'ready' ORDER BY created_at DESC LIMIT 1",
|
| 158 |
+
(meeting_id,)).fetchone()
|
| 159 |
+
if row is None:
|
| 160 |
+
return None
|
| 161 |
+
spec = con.execute("SELECT spec_json FROM ms_visual_specs WHERE analysis_id = ? ORDER BY created_at DESC LIMIT 1",
|
| 162 |
+
(row["id"],)).fetchone()
|
| 163 |
+
count = con.execute("SELECT COUNT(*) AS n FROM ms_analyses WHERE meeting_id = ?", (meeting_id,)).fetchone()
|
| 164 |
+
return {
|
| 165 |
+
"id": row["id"], "meeting_id": meeting_id, "created_at": row["created_at"], "model": row["model"],
|
| 166 |
+
"degraded": row["degraded"], "schema_version": row["schema_version"],
|
| 167 |
+
"transcript_version_id": row["transcript_version_id"],
|
| 168 |
+
"analysis": json.loads(row["json"]),
|
| 169 |
+
"report": json.loads(row["report_json"]) if row["report_json"] else None,
|
| 170 |
+
"questions": json.loads(row["questions_json"]) if row["questions_json"] else [],
|
| 171 |
+
"visual": json.loads(spec["spec_json"]) if spec else None,
|
| 172 |
+
"versions": int(count["n"]) if count else 1,
|
| 173 |
+
}
|
| 174 |
+
finally:
|
| 175 |
+
con.close()
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _action_row(row) -> Dict[str, Any]:
|
| 179 |
+
out = dict(row)
|
| 180 |
+
raw = out.pop("evidence_json", None)
|
| 181 |
+
try:
|
| 182 |
+
out["evidence"] = json.loads(raw) if raw else []
|
| 183 |
+
except ValueError:
|
| 184 |
+
out["evidence"] = []
|
| 185 |
+
return out
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def actions(meeting_id: str) -> List[Dict[str, Any]]:
|
| 189 |
+
con = _connect()
|
| 190 |
+
try:
|
| 191 |
+
return [_action_row(r) for r in con.execute(
|
| 192 |
+
"SELECT * FROM ms_action_items WHERE meeting_id = ? ORDER BY updated_at ASC", (meeting_id,)).fetchall()]
|
| 193 |
+
finally:
|
| 194 |
+
con.close()
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
UPDATABLE = ("status", "owner", "due", "text")
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def update_action(meeting_id: str, item_id: str, **fields: Any) -> Optional[Dict[str, Any]]:
|
| 201 |
+
sets, args = [], []
|
| 202 |
+
for key in UPDATABLE:
|
| 203 |
+
if key in fields and fields[key] is not None:
|
| 204 |
+
value = str(fields[key]).strip()
|
| 205 |
+
if key == "status" and value not in ("open", "done", "dropped"):
|
| 206 |
+
continue
|
| 207 |
+
sets.append(f"{key} = ?")
|
| 208 |
+
args.append(value or None)
|
| 209 |
+
con = _connect()
|
| 210 |
+
try:
|
| 211 |
+
if sets:
|
| 212 |
+
sets.append("updated_at = ?")
|
| 213 |
+
args.append(_now())
|
| 214 |
+
con.execute(f"UPDATE ms_action_items SET {', '.join(sets)} WHERE meeting_id = ? AND item_id = ?",
|
| 215 |
+
args + [meeting_id, item_id])
|
| 216 |
+
con.commit()
|
| 217 |
+
row = con.execute("SELECT * FROM ms_action_items WHERE meeting_id = ? AND item_id = ?", (meeting_id, item_id)).fetchone()
|
| 218 |
+
return _action_row(row) if row else None
|
| 219 |
+
finally:
|
| 220 |
+
con.close()
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def delete_for(meeting_id: str) -> Dict[str, int]:
|
| 224 |
+
con = _connect()
|
| 225 |
+
try:
|
| 226 |
+
out = {}
|
| 227 |
+
for table in ("ms_action_items", "ms_visual_specs", "ms_analyses"):
|
| 228 |
+
out[table] = con.execute(f"DELETE FROM {table} WHERE meeting_id = ?", (meeting_id,)).rowcount
|
| 229 |
+
con.commit()
|
| 230 |
+
return out
|
| 231 |
+
finally:
|
| 232 |
+
con.close()
|
backend/app/meetingsense/intelligence/validate.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evidence validation: an item stays only if its quote really is in the line it cites.
|
| 2 |
+
|
| 3 |
+
The model is asked for verbatim quotes and the timestamp of the line they come from. Models
|
| 4 |
+
paraphrase, shift a stamp by a line, or invent a tidy sentence nobody said. This module is
|
| 5 |
+
the difference between a page that *looks* grounded and one that is: it resolves each
|
| 6 |
+
citation to a real segment (by id, else by nearest start time), compares the quote with the
|
| 7 |
+
segment text (substring after normalisation, else a fuzzy ratio against the best window),
|
| 8 |
+
tries the neighbouring lines, and drops the item when nothing matches.
|
| 9 |
+
|
| 10 |
+
Dropping is reported, not hidden: the ``ValidatorReport`` travels with the analysis and the
|
| 11 |
+
page shows the count.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import difflib
|
| 17 |
+
import re
|
| 18 |
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
| 19 |
+
|
| 20 |
+
from .schema import Evidence, MeetingAnalysis, ValidatorReport
|
| 21 |
+
|
| 22 |
+
#: Fuzzy threshold for "the quote is this line, give or take a word".
|
| 23 |
+
MATCH_RATIO = 0.85
|
| 24 |
+
#: A quote shorter than this (in normalised characters) matches too easily to count.
|
| 25 |
+
MIN_QUOTE_CHARS = 8
|
| 26 |
+
#: How far (ms) a cited start may sit from a real segment start before it is "not that line".
|
| 27 |
+
STAMP_TOLERANCE_MS = 4000
|
| 28 |
+
|
| 29 |
+
_WS = re.compile(r"\s+")
|
| 30 |
+
_PUNCT = re.compile(r"[^\w\s]", re.UNICODE)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def normalise(text: str) -> str:
|
| 34 |
+
return _WS.sub(" ", _PUNCT.sub(" ", (text or "").lower())).strip()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class SegmentIndex:
|
| 38 |
+
"""Segments by id and by start time, with neighbours."""
|
| 39 |
+
|
| 40 |
+
def __init__(self, segments: Sequence[Dict[str, Any]]):
|
| 41 |
+
self.rows: List[Dict[str, Any]] = [s for s in segments if (s.get("text") or "").strip()]
|
| 42 |
+
self.rows.sort(key=lambda s: int(s.get("t0_ms") or 0))
|
| 43 |
+
self.by_id = {str(s.get("id")): i for i, s in enumerate(self.rows) if s.get("id")}
|
| 44 |
+
|
| 45 |
+
def locate(self, ev: Evidence) -> Optional[int]:
|
| 46 |
+
if ev.segment_id and ev.segment_id in self.by_id:
|
| 47 |
+
return self.by_id[ev.segment_id]
|
| 48 |
+
if not self.rows:
|
| 49 |
+
return None
|
| 50 |
+
best, best_gap = None, None
|
| 51 |
+
for i, s in enumerate(self.rows):
|
| 52 |
+
gap = abs(int(s.get("t0_ms") or 0) - int(ev.t0_ms or 0))
|
| 53 |
+
if best_gap is None or gap < best_gap:
|
| 54 |
+
best, best_gap = i, gap
|
| 55 |
+
if best is not None and best_gap is not None and best_gap <= STAMP_TOLERANCE_MS:
|
| 56 |
+
return best
|
| 57 |
+
return best if ev.quote else None
|
| 58 |
+
|
| 59 |
+
def neighbours(self, i: int, span: int = 1) -> List[int]:
|
| 60 |
+
return [j for j in range(i - span, i + span + 1) if 0 <= j < len(self.rows)]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def quote_matches(quote: str, text: str) -> Tuple[bool, float]:
|
| 64 |
+
"""Does ``quote`` appear in ``text``? Exact (normalised) substring first, then the best
|
| 65 |
+
fuzzy window of the same word length."""
|
| 66 |
+
q = normalise(quote)
|
| 67 |
+
t = normalise(text)
|
| 68 |
+
if len(q) < MIN_QUOTE_CHARS:
|
| 69 |
+
return (bool(q) and q in t, 1.0 if q and q in t else 0.0)
|
| 70 |
+
if q in t:
|
| 71 |
+
return True, 1.0
|
| 72 |
+
qw = q.split()
|
| 73 |
+
tw = t.split()
|
| 74 |
+
if not qw or not tw:
|
| 75 |
+
return False, 0.0
|
| 76 |
+
best = 0.0
|
| 77 |
+
width = len(qw)
|
| 78 |
+
for start in range(0, max(1, len(tw) - width + 1)):
|
| 79 |
+
window = " ".join(tw[start:start + width])
|
| 80 |
+
ratio = difflib.SequenceMatcher(None, q, window).ratio()
|
| 81 |
+
if ratio > best:
|
| 82 |
+
best = ratio
|
| 83 |
+
if best >= 0.98:
|
| 84 |
+
break
|
| 85 |
+
return best >= MATCH_RATIO, best
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def verify(ev: Evidence, index: SegmentIndex) -> Optional[Evidence]:
|
| 89 |
+
"""The evidence re-anchored to the segment that really carries the quote, or ``None``."""
|
| 90 |
+
i = index.locate(ev)
|
| 91 |
+
if i is None:
|
| 92 |
+
return None
|
| 93 |
+
candidates = [i] + [j for j in index.neighbours(i) if j != i]
|
| 94 |
+
for j in candidates:
|
| 95 |
+
seg = index.rows[j]
|
| 96 |
+
ok, _ = quote_matches(ev.quote, seg.get("text") or "")
|
| 97 |
+
if ok:
|
| 98 |
+
return Evidence(segment_id=str(seg.get("id")) if seg.get("id") else None,
|
| 99 |
+
t0_ms=int(seg.get("t0_ms") or 0), t1_ms=seg.get("t1_ms"), quote=ev.quote)
|
| 100 |
+
return None
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def validate(analysis: MeetingAnalysis, segments: Sequence[Dict[str, Any]]) -> MeetingAnalysis:
|
| 104 |
+
"""Return the analysis with unverifiable items removed and a report attached.
|
| 105 |
+
|
| 106 |
+
Items with no evidence at all are dropped too, except when the analysis is extractive
|
| 107 |
+
(every extractive item is its own line, so this is a consistency check there).
|
| 108 |
+
"""
|
| 109 |
+
index = SegmentIndex(segments)
|
| 110 |
+
report = ValidatorReport()
|
| 111 |
+
|
| 112 |
+
def keep(items: List[Any], name: str) -> List[Any]:
|
| 113 |
+
kept: List[Any] = []
|
| 114 |
+
for item in items:
|
| 115 |
+
verified: List[Evidence] = []
|
| 116 |
+
repaired = False
|
| 117 |
+
for ev in item.evidence:
|
| 118 |
+
good = verify(ev, index)
|
| 119 |
+
if good is None:
|
| 120 |
+
continue
|
| 121 |
+
if good.segment_id != ev.segment_id or good.t0_ms != ev.t0_ms:
|
| 122 |
+
repaired = True
|
| 123 |
+
verified.append(good)
|
| 124 |
+
if verified:
|
| 125 |
+
item.evidence = verified
|
| 126 |
+
kept.append(item)
|
| 127 |
+
report.kept += 1
|
| 128 |
+
if repaired:
|
| 129 |
+
report.repaired += 1
|
| 130 |
+
else:
|
| 131 |
+
report.dropped += 1
|
| 132 |
+
label = getattr(item, "statement", None) or getattr(item, "title", None) or getattr(item, "text", "")
|
| 133 |
+
report.notes.append(f"{name}: dropped “{str(label)[:80]}” — its quote is not in the transcript.")
|
| 134 |
+
return kept
|
| 135 |
+
|
| 136 |
+
analysis.decisions = keep(list(analysis.decisions), "decision")
|
| 137 |
+
analysis.risks = keep(list(analysis.risks), "risk")
|
| 138 |
+
analysis.action_items = keep(list(analysis.action_items), "action")
|
| 139 |
+
analysis.open_questions = keep(list(analysis.open_questions), "question")
|
| 140 |
+
analysis.conclusions = keep(list(analysis.conclusions), "conclusion")
|
| 141 |
+
analysis.validator = report
|
| 142 |
+
return analysis
|
backend/app/meetingsense/library/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Meetings Library — the additive upgrade to the Meetings view (``MEETINGS_LIBRARY_UPGRADE``).
|
| 2 |
+
|
| 3 |
+
One library over every meeting (recorded live, imported, synced from a folder, scheduled, and
|
| 4 |
+
read-only Teams rooms), imports of Insta360 / AIRecord archives, audio and transcript files,
|
| 5 |
+
opt-in folder sync, and offline transcription through HomePilot's own speech provider.
|
| 6 |
+
|
| 7 |
+
See ``docs/meetings-library.md``.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from . import jobs as _jobs
|
| 11 |
+
from . import sync as _sync
|
| 12 |
+
from .routes import router
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def _startup() -> None:
|
| 16 |
+
_jobs.start_worker()
|
| 17 |
+
_sync.start_sync_loop()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
async def _shutdown() -> None:
|
| 21 |
+
await _jobs.stop_worker()
|
| 22 |
+
await _sync.stop_sync_loop()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
router.add_event_handler("startup", _startup)
|
| 26 |
+
router.add_event_handler("shutdown", _shutdown)
|
| 27 |
+
|
| 28 |
+
__all__ = ["router"]
|
backend/app/meetingsense/library/adapters.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Finding meetings in a pile of files — an upload, an archive, or a synced folder.
|
| 2 |
+
|
| 3 |
+
One discovery routine for all three, so an Insta360 folder looks the same whether it was
|
| 4 |
+
zipped and uploaded or picked up from ``D:\\AIRecord``:
|
| 5 |
+
|
| 6 |
+
* **AIRecord** — a folder holding ``.airecord_data/record_info.json``. The recording beside
|
| 7 |
+
it is the meeting's audio; ``audio_asr_total.json`` is its transcript (often empty); the
|
| 8 |
+
ASR slices are the device's own working copies and are not imported twice.
|
| 9 |
+
* **Audio** — any other recording, paired with a transcript that shares its name
|
| 10 |
+
(``call.mp3`` + ``call.srt``).
|
| 11 |
+
* **Transcript only** — a text, subtitle or JSON transcript with no audio.
|
| 12 |
+
|
| 13 |
+
Discovery only describes; it never copies or changes a file. Names are kept exactly — the
|
| 14 |
+
Insta360 ``∶`` in ``12∶31∶51`` is a real character and stays one.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
import re
|
| 21 |
+
import stat
|
| 22 |
+
import time
|
| 23 |
+
import unicodedata
|
| 24 |
+
import zipfile
|
| 25 |
+
from dataclasses import dataclass, field
|
| 26 |
+
from datetime import datetime
|
| 27 |
+
from pathlib import Path, PurePosixPath
|
| 28 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 29 |
+
|
| 30 |
+
from .media import AUDIO_EXTENSIONS
|
| 31 |
+
from .parsers import TRANSCRIPT_EXTENSIONS
|
| 32 |
+
|
| 33 |
+
_IGNORED_NAMES = {"dot.txt", "desktop.ini", "thumbs.db", ".ds_store"}
|
| 34 |
+
_TEMP_SUFFIXES = (".tmp", ".part", ".crdownload", ".partial", ".download", ".lock")
|
| 35 |
+
|
| 36 |
+
#: Limits for an uploaded archive. Generous for real meetings, tight enough that an archive
|
| 37 |
+
#: built to expand without end is refused before it fills the disk.
|
| 38 |
+
ZIP_MAX_ENTRIES = 5000
|
| 39 |
+
ZIP_MAX_RATIO = 200
|
| 40 |
+
ZIP_MAX_DEPTH = 12
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass
|
| 44 |
+
class FileRef:
|
| 45 |
+
rel: str # path relative to the scan root, POSIX separators
|
| 46 |
+
path: Path # absolute path on disk
|
| 47 |
+
size: int
|
| 48 |
+
mtime: float
|
| 49 |
+
role: str # audio | transcript | metadata | slice
|
| 50 |
+
|
| 51 |
+
def as_dict(self) -> Dict[str, Any]:
|
| 52 |
+
return {"rel": self.rel, "name": PurePosixPath(self.rel).name, "size": self.size,
|
| 53 |
+
"mtime": self.mtime, "role": self.role}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@dataclass
|
| 57 |
+
class Candidate:
|
| 58 |
+
key: str
|
| 59 |
+
kind: str # airecord | audio | transcript
|
| 60 |
+
title: str
|
| 61 |
+
started_at: Optional[float]
|
| 62 |
+
ended_at: Optional[float] = None
|
| 63 |
+
claimed_duration_ms: Optional[int] = None
|
| 64 |
+
audio: Optional[FileRef] = None
|
| 65 |
+
transcript: Optional[FileRef] = None
|
| 66 |
+
metadata_files: List[FileRef] = field(default_factory=list)
|
| 67 |
+
ignored: List[FileRef] = field(default_factory=list)
|
| 68 |
+
origin: Optional[Tuple[str, str]] = None # ("airecord", localMeetingId)
|
| 69 |
+
raw_metadata: Dict[str, Any] = field(default_factory=dict)
|
| 70 |
+
time_source: str = "metadata" # metadata | filename | file_date
|
| 71 |
+
warnings: List[str] = field(default_factory=list)
|
| 72 |
+
|
| 73 |
+
@property
|
| 74 |
+
def files(self) -> List[FileRef]:
|
| 75 |
+
out = [f for f in (self.audio, self.transcript) if f is not None]
|
| 76 |
+
return out + list(self.metadata_files)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _skip(name: str) -> bool:
|
| 80 |
+
lower = name.lower()
|
| 81 |
+
return (
|
| 82 |
+
lower in _IGNORED_NAMES
|
| 83 |
+
or lower.endswith(_TEMP_SUFFIXES)
|
| 84 |
+
or name.startswith("~$")
|
| 85 |
+
or name.startswith("._")
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _ref(root: Path, path: Path, role: str) -> FileRef:
|
| 90 |
+
st = path.stat()
|
| 91 |
+
return FileRef(rel=path.relative_to(root).as_posix(), path=path, size=st.st_size,
|
| 92 |
+
mtime=st.st_mtime, role=role)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# 2026_10_10 12∶31∶51 · 2026-10-10 12-31-51 · 20261010_123151 · 2026-10-10T12:31:51
|
| 96 |
+
_NAME_TIME = re.compile(
|
| 97 |
+
r"(20\d{2})[-_.]?(\d{2})[-_.]?(\d{2})[ T_-]+(\d{2})[:∶꞉\-_.]?(\d{2})[:∶꞉\-_.]?(\d{2})"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def time_from_name(name: str) -> Optional[datetime]:
|
| 102 |
+
m = _NAME_TIME.search(unicodedata.normalize("NFKC", name))
|
| 103 |
+
if not m:
|
| 104 |
+
return None
|
| 105 |
+
try:
|
| 106 |
+
return datetime(*(int(g) for g in m.groups()))
|
| 107 |
+
except ValueError:
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def display_title(name: str, *, fallback_prefix: str = "Imported meeting") -> str:
|
| 112 |
+
"""A person-readable title. A name that is only a timestamp becomes "… · Oct 10, 12:31";
|
| 113 |
+
"Design Review 2026-02-10 13-00-00" becomes "Design Review" (the time is kept as the
|
| 114 |
+
meeting's start, so it still shows on the card)."""
|
| 115 |
+
stem = name.strip()
|
| 116 |
+
when = time_from_name(stem)
|
| 117 |
+
if not when:
|
| 118 |
+
return stem
|
| 119 |
+
normalized = unicodedata.normalize("NFKC", stem)
|
| 120 |
+
leftover = re.sub(r"\s{2,}", " ", _NAME_TIME.sub(" ", normalized)).strip(" -_.·()[]")
|
| 121 |
+
if not leftover:
|
| 122 |
+
return f"{fallback_prefix} · {when.strftime('%b')} {when.day}, {when.strftime('%H:%M')}"
|
| 123 |
+
return leftover
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _airecord_candidate(root: Path, folder: Path) -> Candidate:
|
| 127 |
+
import json # noqa: PLC0415
|
| 128 |
+
|
| 129 |
+
data_dir = folder / ".airecord_data"
|
| 130 |
+
info_path = data_dir / "record_info.json"
|
| 131 |
+
info: Dict[str, Any] = {}
|
| 132 |
+
warnings: List[str] = []
|
| 133 |
+
try:
|
| 134 |
+
info = json.loads(info_path.read_text(encoding="utf-8-sig"))
|
| 135 |
+
except (OSError, ValueError):
|
| 136 |
+
warnings.append("record_info.json could not be read; times come from the folder name.")
|
| 137 |
+
name = str(info.get("meetingname") or folder.name)
|
| 138 |
+
started = info.get("startTime")
|
| 139 |
+
ended = info.get("endTime")
|
| 140 |
+
started_at = float(started) if isinstance(started, (int, float)) and started > 0 else None
|
| 141 |
+
ended_at = float(ended) if isinstance(ended, (int, float)) and ended > 0 else None
|
| 142 |
+
time_source = "metadata"
|
| 143 |
+
if started_at is None:
|
| 144 |
+
when = time_from_name(name) or time_from_name(folder.name)
|
| 145 |
+
if when:
|
| 146 |
+
started_at, time_source = when.timestamp(), "filename"
|
| 147 |
+
claimed = info.get("duration")
|
| 148 |
+
claimed_ms = int(claimed) if isinstance(claimed, (int, float)) and claimed > 0 else None
|
| 149 |
+
|
| 150 |
+
audio_files = [p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS
|
| 151 |
+
and not _skip(p.name)]
|
| 152 |
+
# The recording that carries the meeting's own name, else the largest.
|
| 153 |
+
audio_files.sort(key=lambda p: (p.stem != name and p.stem != folder.name, -p.stat().st_size))
|
| 154 |
+
cand = Candidate(
|
| 155 |
+
key=folder.relative_to(root).as_posix() or ".",
|
| 156 |
+
kind="airecord",
|
| 157 |
+
title=display_title(name),
|
| 158 |
+
started_at=started_at,
|
| 159 |
+
ended_at=ended_at,
|
| 160 |
+
claimed_duration_ms=claimed_ms,
|
| 161 |
+
audio=_ref(root, audio_files[0], "audio") if audio_files else None,
|
| 162 |
+
raw_metadata={"record_info": info},
|
| 163 |
+
time_source=time_source,
|
| 164 |
+
warnings=warnings,
|
| 165 |
+
)
|
| 166 |
+
local_id = str(info.get("localMeetingId") or "").strip()
|
| 167 |
+
if local_id and local_id.lower() != "none":
|
| 168 |
+
cand.origin = ("airecord", local_id)
|
| 169 |
+
for extra in audio_files[1:]:
|
| 170 |
+
cand.ignored.append(_ref(root, extra, "audio"))
|
| 171 |
+
if info_path.is_file():
|
| 172 |
+
cand.metadata_files.append(_ref(root, info_path, "metadata"))
|
| 173 |
+
asr = data_dir / "audio_asr_total.json"
|
| 174 |
+
if asr.is_file():
|
| 175 |
+
cand.transcript = _ref(root, asr, "transcript")
|
| 176 |
+
slices = data_dir / "asr_audio_slice"
|
| 177 |
+
if slices.is_dir():
|
| 178 |
+
for p in sorted(slices.iterdir()):
|
| 179 |
+
if p.is_file():
|
| 180 |
+
cand.ignored.append(_ref(root, p, "slice"))
|
| 181 |
+
if not cand.audio:
|
| 182 |
+
cand.warnings.append("No recording was found in this AIRecord folder.")
|
| 183 |
+
description = str(info.get("description") or "").strip()
|
| 184 |
+
if description and description != "xxxx会议描述": # the device's placeholder text
|
| 185 |
+
cand.raw_metadata["description"] = description
|
| 186 |
+
return cand
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _file_time(path: Path, name: str) -> Tuple[Optional[float], str]:
|
| 190 |
+
when = time_from_name(name)
|
| 191 |
+
if when:
|
| 192 |
+
return when.timestamp(), "filename"
|
| 193 |
+
try:
|
| 194 |
+
return path.stat().st_mtime, "file_date"
|
| 195 |
+
except OSError:
|
| 196 |
+
return None, "file_date"
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def discover(root: Path, *, max_files: int = 20000, max_depth: int = 6) -> List[Candidate]:
|
| 200 |
+
"""Every meeting found below ``root``, oldest first. Never follows symlinks."""
|
| 201 |
+
root = root.resolve()
|
| 202 |
+
candidates: List[Candidate] = []
|
| 203 |
+
airecord_dirs: List[Path] = []
|
| 204 |
+
loose_audio: Dict[str, Path] = {}
|
| 205 |
+
loose_text: Dict[str, Path] = {}
|
| 206 |
+
seen = 0
|
| 207 |
+
|
| 208 |
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
| 209 |
+
current = Path(dirpath)
|
| 210 |
+
depth = len(current.relative_to(root).parts)
|
| 211 |
+
if depth > max_depth:
|
| 212 |
+
dirnames[:] = []
|
| 213 |
+
continue
|
| 214 |
+
if (current / ".airecord_data" / "record_info.json").is_file():
|
| 215 |
+
airecord_dirs.append(current)
|
| 216 |
+
dirnames[:] = [] # its contents belong to the recording
|
| 217 |
+
continue
|
| 218 |
+
dirnames[:] = sorted(d for d in dirnames if not d.startswith(".") and d != "__MACOSX")
|
| 219 |
+
for fname in sorted(filenames):
|
| 220 |
+
seen += 1
|
| 221 |
+
if seen > max_files:
|
| 222 |
+
break
|
| 223 |
+
if fname.startswith(".") or _skip(fname):
|
| 224 |
+
continue
|
| 225 |
+
path = current / fname
|
| 226 |
+
try:
|
| 227 |
+
if path.is_symlink() or not path.is_file():
|
| 228 |
+
continue
|
| 229 |
+
except OSError:
|
| 230 |
+
continue
|
| 231 |
+
ext = path.suffix.lower()
|
| 232 |
+
key = (current / path.stem).relative_to(root).as_posix()
|
| 233 |
+
if ext in AUDIO_EXTENSIONS:
|
| 234 |
+
loose_audio[key] = path
|
| 235 |
+
elif ext in TRANSCRIPT_EXTENSIONS:
|
| 236 |
+
loose_text.setdefault(key, path)
|
| 237 |
+
|
| 238 |
+
for folder in airecord_dirs:
|
| 239 |
+
candidates.append(_airecord_candidate(root, folder))
|
| 240 |
+
|
| 241 |
+
for key, path in loose_audio.items():
|
| 242 |
+
started_at, source = _file_time(path, path.name)
|
| 243 |
+
cand = Candidate(key=key, kind="audio", title=display_title(path.stem, fallback_prefix="Recording"),
|
| 244 |
+
started_at=started_at, audio=_ref(root, path, "audio"), time_source=source)
|
| 245 |
+
if source == "file_date":
|
| 246 |
+
cand.warnings.append("The start time comes from the file's date; check it.")
|
| 247 |
+
text = loose_text.pop(key, None)
|
| 248 |
+
if text is not None:
|
| 249 |
+
cand.transcript = _ref(root, text, "transcript")
|
| 250 |
+
candidates.append(cand)
|
| 251 |
+
|
| 252 |
+
for key, path in loose_text.items():
|
| 253 |
+
started_at, source = _file_time(path, path.name)
|
| 254 |
+
candidates.append(Candidate(key=key, kind="transcript",
|
| 255 |
+
title=display_title(path.stem, fallback_prefix="Transcript"),
|
| 256 |
+
started_at=started_at, transcript=_ref(root, path, "transcript"),
|
| 257 |
+
time_source=source))
|
| 258 |
+
|
| 259 |
+
candidates.sort(key=lambda c: (c.started_at or 0, c.key))
|
| 260 |
+
return candidates
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
# ── archives ────────────────────────────────────────────────────────────────
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
class UnsafeArchive(ValueError):
|
| 267 |
+
pass
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def _safe_member(name: str) -> Optional[str]:
|
| 271 |
+
"""The member's relative path, normalised — or None for anything that escapes."""
|
| 272 |
+
name = unicodedata.normalize("NFC", name.replace("\\", "/"))
|
| 273 |
+
if not name or name.startswith("/") or re.match(r"^[A-Za-z]:", name):
|
| 274 |
+
return None
|
| 275 |
+
parts = [p for p in name.split("/") if p not in ("", ".")]
|
| 276 |
+
if any(p == ".." for p in parts) or len(parts) > ZIP_MAX_DEPTH:
|
| 277 |
+
return None
|
| 278 |
+
return "/".join(parts)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def extract_zip(archive: Path, dest: Path, *, max_total_bytes: int) -> List[str]:
|
| 282 |
+
"""Extract an upload safely: no path escapes, no symlinks, no zip bombs.
|
| 283 |
+
|
| 284 |
+
Returns the relative paths written. Raises :class:`UnsafeArchive` with a reason a person
|
| 285 |
+
can act on; nothing is left half-extracted to be mistaken for an import.
|
| 286 |
+
"""
|
| 287 |
+
written: List[str] = []
|
| 288 |
+
try:
|
| 289 |
+
zf = zipfile.ZipFile(archive)
|
| 290 |
+
except zipfile.BadZipFile as exc:
|
| 291 |
+
raise UnsafeArchive("This is not a ZIP archive HomePilot can open.") from exc
|
| 292 |
+
with zf:
|
| 293 |
+
infos = zf.infolist()
|
| 294 |
+
if len(infos) > ZIP_MAX_ENTRIES:
|
| 295 |
+
raise UnsafeArchive(f"The archive has more than {ZIP_MAX_ENTRIES} files.")
|
| 296 |
+
total = sum(i.file_size for i in infos)
|
| 297 |
+
if total > max_total_bytes:
|
| 298 |
+
raise UnsafeArchive("The archive expands to more than the import size limit.")
|
| 299 |
+
dest.mkdir(parents=True, exist_ok=True)
|
| 300 |
+
for info in infos:
|
| 301 |
+
rel = _safe_member(info.filename)
|
| 302 |
+
if rel is None:
|
| 303 |
+
raise UnsafeArchive(f"Refused an unsafe path in the archive: “{info.filename}”")
|
| 304 |
+
mode = info.external_attr >> 16
|
| 305 |
+
if stat.S_ISLNK(mode):
|
| 306 |
+
continue # a link inside an upload points somewhere the user did not upload
|
| 307 |
+
if info.is_dir():
|
| 308 |
+
(dest / rel).mkdir(parents=True, exist_ok=True)
|
| 309 |
+
continue
|
| 310 |
+
if info.compress_size and info.file_size > 10 * 1024 * 1024 and \
|
| 311 |
+
info.file_size / max(1, info.compress_size) > ZIP_MAX_RATIO:
|
| 312 |
+
raise UnsafeArchive("The archive is compressed suspiciously well (a possible zip bomb).")
|
| 313 |
+
target = dest / rel
|
| 314 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 315 |
+
written_bytes = 0
|
| 316 |
+
with zf.open(info) as src, open(target, "wb") as out:
|
| 317 |
+
while True:
|
| 318 |
+
chunk = src.read(1024 * 1024)
|
| 319 |
+
if not chunk:
|
| 320 |
+
break
|
| 321 |
+
written_bytes += len(chunk)
|
| 322 |
+
if written_bytes > info.file_size + 1024:
|
| 323 |
+
raise UnsafeArchive("An archive member is larger than it claims.")
|
| 324 |
+
out.write(chunk)
|
| 325 |
+
# Keep the recorder's own timestamp — it is what "newest" sorts by.
|
| 326 |
+
try:
|
| 327 |
+
ts = time.mktime(info.date_time + (0, 0, -1))
|
| 328 |
+
os.utime(target, (ts, ts))
|
| 329 |
+
except (OverflowError, ValueError, OSError):
|
| 330 |
+
pass
|
| 331 |
+
written.append(rel)
|
| 332 |
+
return written
|
backend/app/meetingsense/library/config.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Meetings Library configuration — its own rollout gate, separate from MeetingSense's.
|
| 2 |
+
|
| 3 |
+
``MEETINGS_LIBRARY_UPGRADE`` turns on the unified library, imports, folder sync and offline
|
| 4 |
+
transcription. It defaults **off**: with it off the Meetings view, its routes and the live
|
| 5 |
+
recorder are exactly what they were, and turning it off again leaves every table and file the
|
| 6 |
+
upgrade wrote where it is, ready for the next time it is turned on.
|
| 7 |
+
|
| 8 |
+
Kept out of ``meetingsense.config`` on purpose: that module's key set is frozen by a test,
|
| 9 |
+
and a rollout gate that has to be removable must not be woven into the config it sits beside.
|
| 10 |
+
|
| 11 |
+
Folder sync reads the host's disk, so it is allowed only below roots the *operator* lists in
|
| 12 |
+
``MEETINGS_SYNC_ALLOWED_ROOTS``. A path typed into the browser grants nothing by itself.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
from dataclasses import dataclass
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import List, Tuple
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _flag(name: str, default: bool = False) -> bool:
|
| 24 |
+
raw = os.getenv(name)
|
| 25 |
+
if raw is None:
|
| 26 |
+
return default
|
| 27 |
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _int(name: str, default: int) -> int:
|
| 31 |
+
raw = os.getenv(name, "").strip()
|
| 32 |
+
try:
|
| 33 |
+
return int(raw) if raw else default
|
| 34 |
+
except ValueError:
|
| 35 |
+
return default
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _roots(raw: str) -> Tuple[str, ...]:
|
| 39 |
+
"""Allowed sync roots: separated by ``;`` or newlines (``:`` would split ``D:\\``)."""
|
| 40 |
+
out: List[str] = []
|
| 41 |
+
for part in raw.replace("\n", ";").split(";"):
|
| 42 |
+
part = part.strip()
|
| 43 |
+
if part:
|
| 44 |
+
out.append(part)
|
| 45 |
+
return tuple(out)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
#: Where a Windows install keeps Insta360 / AIRecord recordings. A suggestion for the
|
| 49 |
+
#: Folder Sync form, never a path the server reads on its own.
|
| 50 |
+
DEFAULT_SYNC_SUGGESTION = "D:\\AIRecord"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@dataclass(frozen=True)
|
| 54 |
+
class LibraryConfig:
|
| 55 |
+
enabled: bool = False
|
| 56 |
+
#: Offline transcription and summaries for imported recordings. On with the library;
|
| 57 |
+
#: separate so an operator can keep imports but stop all background inference.
|
| 58 |
+
processing: bool = True
|
| 59 |
+
#: Meeting intelligence: the typed analysis, the visual summary page, suggested questions
|
| 60 |
+
#: and first-class action items (``MEETINGS_INTELLIGENCE``). Off by default; needs the
|
| 61 |
+
#: library. Turning it off hides the routes and the page; nothing written is removed.
|
| 62 |
+
intelligence: bool = False
|
| 63 |
+
#: After a model has analysed a meeting, a title that names nothing — a timestamp, a
|
| 64 |
+
#: recorder default, the library's own "Imported meeting · Oct 10, 12:31" — gives way to
|
| 65 |
+
#: the analysis title (``MEETINGS_AUTO_TITLE``, on by default). A title a person typed is
|
| 66 |
+
#: never touched, and an extractive analysis never renames anything.
|
| 67 |
+
auto_title: bool = True
|
| 68 |
+
sync_roots: Tuple[str, ...] = ()
|
| 69 |
+
#: Upper bound for one uploaded file (MB). Meetings are long; an hour of 128 kb/s MP3
|
| 70 |
+
#: is ~60 MB and an hour of WAV ~600 MB.
|
| 71 |
+
max_upload_mb: int = 2048
|
| 72 |
+
#: Seconds of audio per transcription chunk — bounds memory on long recordings and gives
|
| 73 |
+
#: the Processing view progress it can show.
|
| 74 |
+
stt_chunk_s: int = 300
|
| 75 |
+
#: How often enabled folder sources are reconciled.
|
| 76 |
+
sync_interval_s: int = 60
|
| 77 |
+
#: A file must keep its size and mtime this long before it is imported, so a recording
|
| 78 |
+
#: still being written is never indexed half-finished.
|
| 79 |
+
stability_s: int = 10
|
| 80 |
+
|
| 81 |
+
def as_dict(self) -> dict:
|
| 82 |
+
return {
|
| 83 |
+
"enabled": self.enabled,
|
| 84 |
+
"processing": self.processing,
|
| 85 |
+
"intelligence": bool(self.enabled and self.intelligence),
|
| 86 |
+
"sync": bool(self.sync_roots),
|
| 87 |
+
"sync_suggestion": DEFAULT_SYNC_SUGGESTION,
|
| 88 |
+
"max_upload_mb": self.max_upload_mb,
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def load_library_config() -> LibraryConfig:
|
| 93 |
+
"""Read per call, like ``meetingsense.config.load_config``, so tests can vary it."""
|
| 94 |
+
return LibraryConfig(
|
| 95 |
+
enabled=_flag("MEETINGS_LIBRARY_UPGRADE", False),
|
| 96 |
+
processing=_flag("MEETINGS_LIBRARY_PROCESSING", True),
|
| 97 |
+
intelligence=_flag("MEETINGS_INTELLIGENCE", False),
|
| 98 |
+
auto_title=_flag("MEETINGS_AUTO_TITLE", True),
|
| 99 |
+
sync_roots=_roots(os.getenv("MEETINGS_SYNC_ALLOWED_ROOTS", "")),
|
| 100 |
+
max_upload_mb=max(1, _int("MEETINGS_IMPORT_MAX_MB", 2048)),
|
| 101 |
+
stt_chunk_s=max(30, _int("MEETINGS_STT_CHUNK_S", 300)),
|
| 102 |
+
sync_interval_s=max(5, _int("MEETINGS_SYNC_INTERVAL_S", 60)),
|
| 103 |
+
stability_s=max(0, _int("MEETINGS_SYNC_STABILITY_S", 10)),
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def media_root() -> Path:
|
| 108 |
+
"""HomePilot-owned storage for imported meeting files.
|
| 109 |
+
|
| 110 |
+
A sibling of the uploads directory rather than inside it, so meeting audio is never
|
| 111 |
+
reachable through the generic ``/files/`` route — it is served only by the library's own
|
| 112 |
+
media route, which checks who is asking.
|
| 113 |
+
"""
|
| 114 |
+
override = os.getenv("MEETINGS_LIBRARY_DIR", "").strip()
|
| 115 |
+
if override:
|
| 116 |
+
root = Path(override)
|
| 117 |
+
else:
|
| 118 |
+
from ...files import _upload_root # noqa: PLC0415 — keep import-light
|
| 119 |
+
|
| 120 |
+
root = _upload_root().parent / "meetings_library"
|
| 121 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 122 |
+
return root
|
backend/app/meetingsense/library/db.py
ADDED
|
@@ -0,0 +1,1062 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Meetings Library persistence — companion tables beside MeetingSense's own.
|
| 2 |
+
|
| 3 |
+
Additive only. Nothing here alters an ``ms_`` table MeetingSense created; it adds tables
|
| 4 |
+
next to them and keys them by the same meeting id, so a meeting is one row in ``ms_meetings``
|
| 5 |
+
whichever way it arrived — recorded live, imported, synced from a folder or scheduled.
|
| 6 |
+
|
| 7 |
+
The connection is MeetingSense's (``store._connect``) so the library writes to the same
|
| 8 |
+
database file, and so a test that redirects that one function redirects both.
|
| 9 |
+
|
| 10 |
+
Rollback is "turn the flag off": the tables stay, every row stays, and turning the flag on
|
| 11 |
+
again finds them as they were.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import time
|
| 18 |
+
import uuid
|
| 19 |
+
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
| 20 |
+
|
| 21 |
+
from .. import store
|
| 22 |
+
|
| 23 |
+
LIBRARY_SCHEMA_VERSION = 1
|
| 24 |
+
|
| 25 |
+
_TABLES = (
|
| 26 |
+
# One row per meeting that the library knows more about than ms_meetings can hold:
|
| 27 |
+
# where it came from, whether it has audio and a transcript, what it is about.
|
| 28 |
+
"""
|
| 29 |
+
CREATE TABLE IF NOT EXISTS ms_library_entries(
|
| 30 |
+
meeting_id TEXT PRIMARY KEY,
|
| 31 |
+
origin_type TEXT NOT NULL,
|
| 32 |
+
owner_user_id TEXT NOT NULL DEFAULT '',
|
| 33 |
+
category TEXT,
|
| 34 |
+
description TEXT,
|
| 35 |
+
scheduled_for REAL,
|
| 36 |
+
recording_plan TEXT,
|
| 37 |
+
recording_state TEXT NOT NULL DEFAULT 'none',
|
| 38 |
+
transcript_state TEXT NOT NULL DEFAULT 'none',
|
| 39 |
+
summary_state TEXT NOT NULL DEFAULT 'none',
|
| 40 |
+
duration_ms INTEGER,
|
| 41 |
+
media_present INTEGER NOT NULL DEFAULT 0,
|
| 42 |
+
outputs_json TEXT,
|
| 43 |
+
raw_metadata_json TEXT,
|
| 44 |
+
warnings_json TEXT,
|
| 45 |
+
hidden_at REAL,
|
| 46 |
+
created_at REAL NOT NULL,
|
| 47 |
+
updated_at REAL NOT NULL
|
| 48 |
+
)
|
| 49 |
+
""",
|
| 50 |
+
"""
|
| 51 |
+
CREATE TABLE IF NOT EXISTS ms_library_sources(
|
| 52 |
+
id TEXT PRIMARY KEY,
|
| 53 |
+
kind TEXT NOT NULL,
|
| 54 |
+
display_name TEXT NOT NULL,
|
| 55 |
+
path TEXT NOT NULL,
|
| 56 |
+
resolved_path TEXT NOT NULL,
|
| 57 |
+
owner_user_id TEXT NOT NULL DEFAULT '',
|
| 58 |
+
enabled INTEGER NOT NULL DEFAULT 1,
|
| 59 |
+
project_id TEXT,
|
| 60 |
+
last_scan_at REAL,
|
| 61 |
+
last_success_at REAL,
|
| 62 |
+
last_error TEXT,
|
| 63 |
+
counts_json TEXT,
|
| 64 |
+
created_at REAL NOT NULL
|
| 65 |
+
)
|
| 66 |
+
""",
|
| 67 |
+
"""
|
| 68 |
+
CREATE TABLE IF NOT EXISTS ms_library_assets(
|
| 69 |
+
id TEXT PRIMARY KEY,
|
| 70 |
+
meeting_id TEXT,
|
| 71 |
+
source_id TEXT NOT NULL,
|
| 72 |
+
origin_type TEXT NOT NULL,
|
| 73 |
+
relative_path TEXT NOT NULL,
|
| 74 |
+
original_name TEXT NOT NULL,
|
| 75 |
+
kind TEXT NOT NULL,
|
| 76 |
+
role TEXT NOT NULL DEFAULT 'primary',
|
| 77 |
+
sha256 TEXT NOT NULL,
|
| 78 |
+
mime TEXT,
|
| 79 |
+
size_bytes INTEGER NOT NULL DEFAULT 0,
|
| 80 |
+
storage_ref TEXT,
|
| 81 |
+
media_duration_ms INTEGER,
|
| 82 |
+
source_available INTEGER NOT NULL DEFAULT 1,
|
| 83 |
+
metadata_json TEXT,
|
| 84 |
+
created_at REAL NOT NULL
|
| 85 |
+
)
|
| 86 |
+
""",
|
| 87 |
+
"""
|
| 88 |
+
CREATE TABLE IF NOT EXISTS ms_library_imports(
|
| 89 |
+
id TEXT PRIMARY KEY,
|
| 90 |
+
owner_user_id TEXT NOT NULL DEFAULT '',
|
| 91 |
+
source_id TEXT NOT NULL,
|
| 92 |
+
idempotency_key TEXT,
|
| 93 |
+
status TEXT NOT NULL,
|
| 94 |
+
manifest_json TEXT,
|
| 95 |
+
staging_dir TEXT,
|
| 96 |
+
error TEXT,
|
| 97 |
+
created_at REAL NOT NULL,
|
| 98 |
+
committed_at REAL
|
| 99 |
+
)
|
| 100 |
+
""",
|
| 101 |
+
"""
|
| 102 |
+
CREATE TABLE IF NOT EXISTS ms_library_jobs(
|
| 103 |
+
id TEXT PRIMARY KEY,
|
| 104 |
+
meeting_id TEXT NOT NULL,
|
| 105 |
+
type TEXT NOT NULL,
|
| 106 |
+
state TEXT NOT NULL,
|
| 107 |
+
stage TEXT,
|
| 108 |
+
progress REAL NOT NULL DEFAULT 0,
|
| 109 |
+
attempt INTEGER NOT NULL DEFAULT 0,
|
| 110 |
+
max_attempts INTEGER NOT NULL DEFAULT 2,
|
| 111 |
+
options_json TEXT,
|
| 112 |
+
diagnostic_json TEXT,
|
| 113 |
+
error TEXT,
|
| 114 |
+
lease_until REAL,
|
| 115 |
+
created_at REAL NOT NULL,
|
| 116 |
+
started_at REAL,
|
| 117 |
+
finished_at REAL
|
| 118 |
+
)
|
| 119 |
+
""",
|
| 120 |
+
"""
|
| 121 |
+
CREATE TABLE IF NOT EXISTS ms_meeting_project_links(
|
| 122 |
+
meeting_id TEXT NOT NULL,
|
| 123 |
+
project_id TEXT NOT NULL,
|
| 124 |
+
kind TEXT NOT NULL DEFAULT 'linked',
|
| 125 |
+
created_at REAL NOT NULL,
|
| 126 |
+
PRIMARY KEY (meeting_id, project_id)
|
| 127 |
+
)
|
| 128 |
+
""",
|
| 129 |
+
"""
|
| 130 |
+
CREATE TABLE IF NOT EXISTS ms_library_origin_links(
|
| 131 |
+
origin_type TEXT NOT NULL,
|
| 132 |
+
origin_id TEXT NOT NULL,
|
| 133 |
+
meeting_id TEXT NOT NULL,
|
| 134 |
+
created_at REAL NOT NULL,
|
| 135 |
+
PRIMARY KEY (origin_type, origin_id)
|
| 136 |
+
)
|
| 137 |
+
""",
|
| 138 |
+
"""
|
| 139 |
+
CREATE TABLE IF NOT EXISTS ms_transcript_versions(
|
| 140 |
+
id TEXT PRIMARY KEY,
|
| 141 |
+
meeting_id TEXT NOT NULL,
|
| 142 |
+
origin TEXT NOT NULL,
|
| 143 |
+
source_ref TEXT,
|
| 144 |
+
provider TEXT,
|
| 145 |
+
model TEXT,
|
| 146 |
+
language TEXT,
|
| 147 |
+
timing TEXT NOT NULL,
|
| 148 |
+
active INTEGER NOT NULL DEFAULT 0,
|
| 149 |
+
segment_count INTEGER NOT NULL DEFAULT 0,
|
| 150 |
+
note TEXT,
|
| 151 |
+
created_at REAL NOT NULL
|
| 152 |
+
)
|
| 153 |
+
""",
|
| 154 |
+
"""
|
| 155 |
+
CREATE TABLE IF NOT EXISTS ms_transcript_version_segments(
|
| 156 |
+
version_id TEXT NOT NULL,
|
| 157 |
+
seq INTEGER NOT NULL,
|
| 158 |
+
t0_ms INTEGER NOT NULL,
|
| 159 |
+
t1_ms INTEGER,
|
| 160 |
+
speaker TEXT,
|
| 161 |
+
text TEXT NOT NULL,
|
| 162 |
+
conf REAL,
|
| 163 |
+
PRIMARY KEY (version_id, seq)
|
| 164 |
+
)
|
| 165 |
+
""",
|
| 166 |
+
# What a folder scan has seen, so "finished writing" can be told from "still being
|
| 167 |
+
# written": a file is imported only once its size and mtime have held still.
|
| 168 |
+
"""
|
| 169 |
+
CREATE TABLE IF NOT EXISTS ms_library_sync_seen(
|
| 170 |
+
source_id TEXT NOT NULL,
|
| 171 |
+
relative_path TEXT NOT NULL,
|
| 172 |
+
size_bytes INTEGER NOT NULL,
|
| 173 |
+
mtime REAL NOT NULL,
|
| 174 |
+
first_seen_at REAL NOT NULL,
|
| 175 |
+
PRIMARY KEY (source_id, relative_path)
|
| 176 |
+
)
|
| 177 |
+
""",
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
_INDEXES = (
|
| 181 |
+
"CREATE INDEX IF NOT EXISTS ix_ms_lib_assets_meeting ON ms_library_assets(meeting_id, kind)",
|
| 182 |
+
"CREATE INDEX IF NOT EXISTS ix_ms_lib_assets_sha ON ms_library_assets(sha256)",
|
| 183 |
+
"CREATE INDEX IF NOT EXISTS ix_ms_lib_assets_source ON ms_library_assets(source_id, relative_path)",
|
| 184 |
+
"CREATE INDEX IF NOT EXISTS ix_ms_lib_jobs_state ON ms_library_jobs(state, created_at)",
|
| 185 |
+
"CREATE INDEX IF NOT EXISTS ix_ms_lib_jobs_meeting ON ms_library_jobs(meeting_id)",
|
| 186 |
+
"CREATE INDEX IF NOT EXISTS ix_ms_lib_links_project ON ms_meeting_project_links(project_id)",
|
| 187 |
+
"CREATE INDEX IF NOT EXISTS ix_ms_lib_origin_meeting ON ms_library_origin_links(meeting_id)",
|
| 188 |
+
"CREATE INDEX IF NOT EXISTS ix_ms_tv_meeting ON ms_transcript_versions(meeting_id, created_at)",
|
| 189 |
+
"CREATE UNIQUE INDEX IF NOT EXISTS ux_ms_lib_imports_key ON ms_library_imports(owner_user_id, idempotency_key)",
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
#: The states a transcript can be in. Recording and transcript are separate fields: an
|
| 193 |
+
#: imported recording keeps its audio whatever happens to its transcription.
|
| 194 |
+
TRANSCRIPT_STATES = (
|
| 195 |
+
"none", "imported", "queued", "transcribing", "ready", "ready_warnings",
|
| 196 |
+
"failed", "needs_setup", "no_speech", "transcript_only",
|
| 197 |
+
)
|
| 198 |
+
RECORDING_STATES = ("none", "awaiting", "recording", "present", "unavailable")
|
| 199 |
+
JOB_STATES = ("queued", "running", "done", "failed", "cancelled", "needs_setup")
|
| 200 |
+
|
| 201 |
+
_migrated_paths: set = set()
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _connect():
|
| 205 |
+
return store._connect()
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _now() -> float:
|
| 209 |
+
return time.time()
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _dumps(value: Any) -> Optional[str]:
|
| 213 |
+
return None if value is None else json.dumps(value)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _loads(raw: Optional[str], default: Any = None) -> Any:
|
| 217 |
+
if not raw:
|
| 218 |
+
return default
|
| 219 |
+
try:
|
| 220 |
+
return json.loads(raw)
|
| 221 |
+
except (TypeError, ValueError):
|
| 222 |
+
return default
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def migrate() -> None:
|
| 226 |
+
"""Create MeetingSense's tables (idempotent) and the library's beside them, then seed
|
| 227 |
+
project links from the ``project_id`` meetings already carry. Safe to run every start."""
|
| 228 |
+
store.migrate()
|
| 229 |
+
con = _connect()
|
| 230 |
+
try:
|
| 231 |
+
cur = con.cursor()
|
| 232 |
+
for statement in _TABLES:
|
| 233 |
+
cur.execute(statement)
|
| 234 |
+
for statement in _INDEXES:
|
| 235 |
+
cur.execute(statement)
|
| 236 |
+
# Every meeting recorded in a project is linked to it — once, idempotently, and the
|
| 237 |
+
# ms_meetings.project_id column stays exactly as it was.
|
| 238 |
+
cur.execute(
|
| 239 |
+
"INSERT OR IGNORE INTO ms_meeting_project_links(meeting_id, project_id, kind, created_at)"
|
| 240 |
+
" SELECT id, project_id, 'primary', started_at FROM ms_meetings"
|
| 241 |
+
" WHERE project_id IS NOT NULL AND project_id != ''"
|
| 242 |
+
)
|
| 243 |
+
con.commit()
|
| 244 |
+
finally:
|
| 245 |
+
con.close()
|
| 246 |
+
# Meeting intelligence keeps its own tables beside these; created only when its flag is
|
| 247 |
+
# on, so an install that never turns it on never gains them.
|
| 248 |
+
from .config import load_library_config # noqa: PLC0415
|
| 249 |
+
|
| 250 |
+
if load_library_config().intelligence:
|
| 251 |
+
from ..intelligence import store as intelligence_store # noqa: PLC0415
|
| 252 |
+
|
| 253 |
+
intelligence_store.migrate()
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def ensure_migrated() -> None:
|
| 257 |
+
"""``migrate`` once per database file per process (the routes call this on every request)."""
|
| 258 |
+
try:
|
| 259 |
+
from ...storage import _get_db_path # noqa: PLC0415
|
| 260 |
+
|
| 261 |
+
key = _get_db_path()
|
| 262 |
+
except Exception: # noqa: BLE001
|
| 263 |
+
key = "default"
|
| 264 |
+
key = f"{key}:{id(store._connect)}"
|
| 265 |
+
if key in _migrated_paths:
|
| 266 |
+
return
|
| 267 |
+
migrate()
|
| 268 |
+
_migrated_paths.add(key)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
# ── entries ─────────────────────────────────────────────────────────────────
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def _entry_row(row) -> Dict[str, Any]:
|
| 275 |
+
out = dict(row)
|
| 276 |
+
out["outputs"] = _loads(out.pop("outputs_json", None), None)
|
| 277 |
+
out["raw_metadata"] = _loads(out.pop("raw_metadata_json", None), None)
|
| 278 |
+
out["warnings"] = _loads(out.pop("warnings_json", None), []) or []
|
| 279 |
+
return out
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def create_meeting_entry(
|
| 283 |
+
*,
|
| 284 |
+
title: str,
|
| 285 |
+
origin_type: str,
|
| 286 |
+
owner_user_id: str = "",
|
| 287 |
+
started_at: Optional[float] = None,
|
| 288 |
+
ended_at: Optional[float] = None,
|
| 289 |
+
status: str = "ended",
|
| 290 |
+
source: Optional[str] = None,
|
| 291 |
+
project_id: Optional[str] = None,
|
| 292 |
+
category: Optional[str] = None,
|
| 293 |
+
description: Optional[str] = None,
|
| 294 |
+
scheduled_for: Optional[float] = None,
|
| 295 |
+
recording_plan: Optional[str] = None,
|
| 296 |
+
recording_state: str = "none",
|
| 297 |
+
transcript_state: str = "none",
|
| 298 |
+
duration_ms: Optional[int] = None,
|
| 299 |
+
outputs: Any = None,
|
| 300 |
+
raw_metadata: Any = None,
|
| 301 |
+
warnings: Optional[Sequence[str]] = None,
|
| 302 |
+
meeting_id: Optional[str] = None,
|
| 303 |
+
) -> str:
|
| 304 |
+
"""Create a finished (or scheduled) meeting **without** a live session.
|
| 305 |
+
|
| 306 |
+
``store.create_meeting`` opens a row as ``live``, which is right for the recorder and
|
| 307 |
+
wrong here: an imported recording is already over, and a scheduled meeting has not
|
| 308 |
+
started. Both get a meeting row of their own with no conversation behind it.
|
| 309 |
+
"""
|
| 310 |
+
mid = meeting_id or uuid.uuid4().hex
|
| 311 |
+
now = _now()
|
| 312 |
+
start = float(started_at if started_at is not None else (scheduled_for or now))
|
| 313 |
+
con = _connect()
|
| 314 |
+
try:
|
| 315 |
+
con.execute(
|
| 316 |
+
"INSERT INTO ms_meetings(id, conversation_id, project_id, title, source, started_at,"
|
| 317 |
+
" ended_at, audio_mode, retention, status) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 'text', ?)",
|
| 318 |
+
(mid, project_id or None, title, source, start, ended_at, "import", status),
|
| 319 |
+
)
|
| 320 |
+
con.execute(
|
| 321 |
+
"INSERT INTO ms_library_entries(meeting_id, origin_type, owner_user_id, category,"
|
| 322 |
+
" description, scheduled_for, recording_plan, recording_state, transcript_state,"
|
| 323 |
+
" summary_state, duration_ms, media_present, outputs_json, raw_metadata_json,"
|
| 324 |
+
" warnings_json, created_at, updated_at)"
|
| 325 |
+
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'none', ?, ?, ?, ?, ?, ?, ?)",
|
| 326 |
+
(
|
| 327 |
+
mid, origin_type, owner_user_id or "", category, description, scheduled_for,
|
| 328 |
+
recording_plan, recording_state, transcript_state, duration_ms,
|
| 329 |
+
1 if recording_state == "present" else 0, _dumps(outputs), _dumps(raw_metadata),
|
| 330 |
+
_dumps(list(warnings or [])), now, now,
|
| 331 |
+
),
|
| 332 |
+
)
|
| 333 |
+
if project_id:
|
| 334 |
+
con.execute(
|
| 335 |
+
"INSERT OR IGNORE INTO ms_meeting_project_links(meeting_id, project_id, kind, created_at)"
|
| 336 |
+
" VALUES (?, ?, 'primary', ?)",
|
| 337 |
+
(mid, project_id, now),
|
| 338 |
+
)
|
| 339 |
+
con.commit()
|
| 340 |
+
finally:
|
| 341 |
+
con.close()
|
| 342 |
+
return mid
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def ensure_entry(meeting_id: str, *, origin_type: str = "meetingsense", owner_user_id: str = "") -> None:
|
| 346 |
+
"""Give a meeting that predates the library an entry row (for edits like a category)."""
|
| 347 |
+
now = _now()
|
| 348 |
+
con = _connect()
|
| 349 |
+
try:
|
| 350 |
+
con.execute(
|
| 351 |
+
"INSERT OR IGNORE INTO ms_library_entries(meeting_id, origin_type, owner_user_id,"
|
| 352 |
+
" created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
| 353 |
+
(meeting_id, origin_type, owner_user_id or "", now, now),
|
| 354 |
+
)
|
| 355 |
+
con.commit()
|
| 356 |
+
finally:
|
| 357 |
+
con.close()
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
_ENTRY_FIELDS = {
|
| 361 |
+
"category", "description", "scheduled_for", "recording_plan", "recording_state",
|
| 362 |
+
"transcript_state", "summary_state", "duration_ms", "media_present", "hidden_at",
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def update_entry(meeting_id: str, *, outputs: Any = None, raw_metadata: Any = None,
|
| 367 |
+
warnings: Optional[Sequence[str]] = None, **fields: Any) -> None:
|
| 368 |
+
sets: List[str] = []
|
| 369 |
+
args: List[Any] = []
|
| 370 |
+
for key, value in fields.items():
|
| 371 |
+
if key not in _ENTRY_FIELDS:
|
| 372 |
+
raise ValueError(f"not an entry field: {key}")
|
| 373 |
+
sets.append(f"{key} = ?")
|
| 374 |
+
args.append(value)
|
| 375 |
+
if outputs is not None:
|
| 376 |
+
sets.append("outputs_json = ?")
|
| 377 |
+
args.append(_dumps(outputs))
|
| 378 |
+
if raw_metadata is not None:
|
| 379 |
+
sets.append("raw_metadata_json = ?")
|
| 380 |
+
args.append(_dumps(raw_metadata))
|
| 381 |
+
if warnings is not None:
|
| 382 |
+
sets.append("warnings_json = ?")
|
| 383 |
+
args.append(_dumps(list(warnings)))
|
| 384 |
+
if not sets:
|
| 385 |
+
return
|
| 386 |
+
sets.append("updated_at = ?")
|
| 387 |
+
args.append(_now())
|
| 388 |
+
con = _connect()
|
| 389 |
+
try:
|
| 390 |
+
con.execute(f"UPDATE ms_library_entries SET {', '.join(sets)} WHERE meeting_id = ?", args + [meeting_id])
|
| 391 |
+
con.commit()
|
| 392 |
+
finally:
|
| 393 |
+
con.close()
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def add_warning(meeting_id: str, warning: str) -> None:
|
| 397 |
+
entry = get_entry(meeting_id)
|
| 398 |
+
if entry is None:
|
| 399 |
+
return
|
| 400 |
+
warnings = list(entry.get("warnings") or [])
|
| 401 |
+
if warning not in warnings:
|
| 402 |
+
warnings.append(warning)
|
| 403 |
+
update_entry(meeting_id, warnings=warnings)
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def get_entry(meeting_id: str) -> Optional[Dict[str, Any]]:
|
| 407 |
+
con = _connect()
|
| 408 |
+
try:
|
| 409 |
+
row = con.execute("SELECT * FROM ms_library_entries WHERE meeting_id = ?", (meeting_id,)).fetchone()
|
| 410 |
+
return _entry_row(row) if row else None
|
| 411 |
+
finally:
|
| 412 |
+
con.close()
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def entries_by_id(meeting_ids: Iterable[str]) -> Dict[str, Dict[str, Any]]:
|
| 416 |
+
ids = list(meeting_ids)
|
| 417 |
+
if not ids:
|
| 418 |
+
return {}
|
| 419 |
+
out: Dict[str, Dict[str, Any]] = {}
|
| 420 |
+
con = _connect()
|
| 421 |
+
try:
|
| 422 |
+
for i in range(0, len(ids), 500):
|
| 423 |
+
part = ids[i:i + 500]
|
| 424 |
+
rows = con.execute(
|
| 425 |
+
f"SELECT * FROM ms_library_entries WHERE meeting_id IN ({','.join('?' * len(part))})", part
|
| 426 |
+
).fetchall()
|
| 427 |
+
for row in rows:
|
| 428 |
+
out[row["meeting_id"]] = _entry_row(row)
|
| 429 |
+
finally:
|
| 430 |
+
con.close()
|
| 431 |
+
return out
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
# ── project links ───────────────────────────────────────────────────────────
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def project_links(meeting_ids: Iterable[str]) -> Dict[str, List[Dict[str, Any]]]:
|
| 438 |
+
ids = list(meeting_ids)
|
| 439 |
+
out: Dict[str, List[Dict[str, Any]]] = {}
|
| 440 |
+
if not ids:
|
| 441 |
+
return out
|
| 442 |
+
con = _connect()
|
| 443 |
+
try:
|
| 444 |
+
for i in range(0, len(ids), 500):
|
| 445 |
+
part = ids[i:i + 500]
|
| 446 |
+
rows = con.execute(
|
| 447 |
+
"SELECT * FROM ms_meeting_project_links WHERE meeting_id IN"
|
| 448 |
+
f" ({','.join('?' * len(part))}) ORDER BY kind = 'primary' DESC, created_at ASC",
|
| 449 |
+
part,
|
| 450 |
+
).fetchall()
|
| 451 |
+
for row in rows:
|
| 452 |
+
out.setdefault(row["meeting_id"], []).append(dict(row))
|
| 453 |
+
finally:
|
| 454 |
+
con.close()
|
| 455 |
+
return out
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def set_project_links(meeting_id: str, project_ids: Sequence[str]) -> List[str]:
|
| 459 |
+
"""Make a meeting's linked projects exactly ``project_ids``. Never copies the meeting.
|
| 460 |
+
|
| 461 |
+
The primary link (the project a meeting was recorded in) is kept as ``primary`` if it
|
| 462 |
+
stays; ``ms_meetings.project_id`` is left as it was, which keeps every pre-library reader
|
| 463 |
+
of that column answering what it always answered.
|
| 464 |
+
"""
|
| 465 |
+
wanted = [p for p in dict.fromkeys(str(p).strip() for p in project_ids) if p]
|
| 466 |
+
now = _now()
|
| 467 |
+
con = _connect()
|
| 468 |
+
try:
|
| 469 |
+
existing = {r["project_id"] for r in con.execute(
|
| 470 |
+
"SELECT project_id FROM ms_meeting_project_links WHERE meeting_id = ?", (meeting_id,)
|
| 471 |
+
).fetchall()}
|
| 472 |
+
for pid in existing - set(wanted):
|
| 473 |
+
con.execute("DELETE FROM ms_meeting_project_links WHERE meeting_id = ? AND project_id = ?", (meeting_id, pid))
|
| 474 |
+
for pid in wanted:
|
| 475 |
+
if pid not in existing:
|
| 476 |
+
con.execute(
|
| 477 |
+
"INSERT INTO ms_meeting_project_links(meeting_id, project_id, kind, created_at)"
|
| 478 |
+
" VALUES (?, ?, 'linked', ?)",
|
| 479 |
+
(meeting_id, pid, now),
|
| 480 |
+
)
|
| 481 |
+
con.commit()
|
| 482 |
+
finally:
|
| 483 |
+
con.close()
|
| 484 |
+
return wanted
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
# ── origin links (dedup) ────────────────────────────────────────────────────
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def origin_meeting(origin_type: str, origin_id: str) -> Optional[str]:
|
| 491 |
+
con = _connect()
|
| 492 |
+
try:
|
| 493 |
+
row = con.execute(
|
| 494 |
+
"SELECT meeting_id FROM ms_library_origin_links WHERE origin_type = ? AND origin_id = ?",
|
| 495 |
+
(origin_type, origin_id),
|
| 496 |
+
).fetchone()
|
| 497 |
+
return row["meeting_id"] if row else None
|
| 498 |
+
finally:
|
| 499 |
+
con.close()
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def link_origin(origin_type: str, origin_id: str, meeting_id: str) -> None:
|
| 503 |
+
con = _connect()
|
| 504 |
+
try:
|
| 505 |
+
con.execute(
|
| 506 |
+
"INSERT OR IGNORE INTO ms_library_origin_links(origin_type, origin_id, meeting_id, created_at)"
|
| 507 |
+
" VALUES (?, ?, ?, ?)",
|
| 508 |
+
(origin_type, origin_id, meeting_id, _now()),
|
| 509 |
+
)
|
| 510 |
+
con.commit()
|
| 511 |
+
finally:
|
| 512 |
+
con.close()
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
# ── assets ──────────────────────────────────────────────────────────────────
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
def add_asset(**fields: Any) -> str:
|
| 519 |
+
aid = fields.pop("id", None) or uuid.uuid4().hex
|
| 520 |
+
metadata = fields.pop("metadata", None)
|
| 521 |
+
cols = [
|
| 522 |
+
"meeting_id", "source_id", "origin_type", "relative_path", "original_name", "kind", "role",
|
| 523 |
+
"sha256", "mime", "size_bytes", "storage_ref", "media_duration_ms",
|
| 524 |
+
]
|
| 525 |
+
values = [fields.get(c) for c in cols]
|
| 526 |
+
if values[cols.index("role")] is None:
|
| 527 |
+
values[cols.index("role")] = "primary"
|
| 528 |
+
if values[cols.index("size_bytes")] is None:
|
| 529 |
+
values[cols.index("size_bytes")] = 0
|
| 530 |
+
con = _connect()
|
| 531 |
+
try:
|
| 532 |
+
con.execute(
|
| 533 |
+
f"INSERT INTO ms_library_assets(id, {', '.join(cols)}, metadata_json, created_at)"
|
| 534 |
+
f" VALUES (?, {', '.join('?' * len(cols))}, ?, ?)",
|
| 535 |
+
[aid] + values + [_dumps(metadata), _now()],
|
| 536 |
+
)
|
| 537 |
+
con.commit()
|
| 538 |
+
finally:
|
| 539 |
+
con.close()
|
| 540 |
+
return aid
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
def _asset_row(row) -> Dict[str, Any]:
|
| 544 |
+
out = dict(row)
|
| 545 |
+
out["metadata"] = _loads(out.pop("metadata_json", None), {}) or {}
|
| 546 |
+
return out
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
def assets_for(meeting_id: str) -> List[Dict[str, Any]]:
|
| 550 |
+
con = _connect()
|
| 551 |
+
try:
|
| 552 |
+
rows = con.execute(
|
| 553 |
+
"SELECT * FROM ms_library_assets WHERE meeting_id = ? ORDER BY kind, role = 'primary' DESC, created_at",
|
| 554 |
+
(meeting_id,),
|
| 555 |
+
).fetchall()
|
| 556 |
+
return [_asset_row(r) for r in rows]
|
| 557 |
+
finally:
|
| 558 |
+
con.close()
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def get_asset(asset_id: str) -> Optional[Dict[str, Any]]:
|
| 562 |
+
con = _connect()
|
| 563 |
+
try:
|
| 564 |
+
row = con.execute("SELECT * FROM ms_library_assets WHERE id = ?", (asset_id,)).fetchone()
|
| 565 |
+
return _asset_row(row) if row else None
|
| 566 |
+
finally:
|
| 567 |
+
con.close()
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
def update_asset(asset_id: str, *, metadata: Any = None, **fields: Any) -> None:
|
| 571 |
+
allowed = {"relative_path", "source_available", "media_duration_ms", "storage_ref", "meeting_id"}
|
| 572 |
+
sets, args = [], []
|
| 573 |
+
for key, value in fields.items():
|
| 574 |
+
if key not in allowed:
|
| 575 |
+
raise ValueError(f"not an asset field: {key}")
|
| 576 |
+
sets.append(f"{key} = ?")
|
| 577 |
+
args.append(value)
|
| 578 |
+
if metadata is not None:
|
| 579 |
+
sets.append("metadata_json = ?")
|
| 580 |
+
args.append(_dumps(metadata))
|
| 581 |
+
if not sets:
|
| 582 |
+
return
|
| 583 |
+
con = _connect()
|
| 584 |
+
try:
|
| 585 |
+
con.execute(f"UPDATE ms_library_assets SET {', '.join(sets)} WHERE id = ?", args + [asset_id])
|
| 586 |
+
con.commit()
|
| 587 |
+
finally:
|
| 588 |
+
con.close()
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
def assets_with_sha(sha256: str) -> List[Dict[str, Any]]:
|
| 592 |
+
con = _connect()
|
| 593 |
+
try:
|
| 594 |
+
rows = con.execute(
|
| 595 |
+
"SELECT * FROM ms_library_assets WHERE sha256 = ? AND meeting_id IS NOT NULL", (sha256,)
|
| 596 |
+
).fetchall()
|
| 597 |
+
return [_asset_row(r) for r in rows]
|
| 598 |
+
finally:
|
| 599 |
+
con.close()
|
| 600 |
+
|
| 601 |
+
|
| 602 |
+
def assets_for_source(source_id: str) -> List[Dict[str, Any]]:
|
| 603 |
+
con = _connect()
|
| 604 |
+
try:
|
| 605 |
+
rows = con.execute("SELECT * FROM ms_library_assets WHERE source_id = ?", (source_id,)).fetchall()
|
| 606 |
+
return [_asset_row(r) for r in rows]
|
| 607 |
+
finally:
|
| 608 |
+
con.close()
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
# ── imports ─────────────────────────────────────────────────────────────────
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
def create_import(*, owner_user_id: str, source_id: str, manifest: Any, staging_dir: str,
|
| 615 |
+
idempotency_key: Optional[str] = None, import_id: Optional[str] = None) -> str:
|
| 616 |
+
iid = import_id or uuid.uuid4().hex
|
| 617 |
+
con = _connect()
|
| 618 |
+
try:
|
| 619 |
+
con.execute(
|
| 620 |
+
"INSERT INTO ms_library_imports(id, owner_user_id, source_id, idempotency_key, status,"
|
| 621 |
+
" manifest_json, staging_dir, created_at) VALUES (?, ?, ?, ?, 'previewed', ?, ?, ?)",
|
| 622 |
+
(iid, owner_user_id or "", source_id, idempotency_key, _dumps(manifest), staging_dir, _now()),
|
| 623 |
+
)
|
| 624 |
+
con.commit()
|
| 625 |
+
finally:
|
| 626 |
+
con.close()
|
| 627 |
+
return iid
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
def get_import(import_id: str) -> Optional[Dict[str, Any]]:
|
| 631 |
+
con = _connect()
|
| 632 |
+
try:
|
| 633 |
+
row = con.execute("SELECT * FROM ms_library_imports WHERE id = ?", (import_id,)).fetchone()
|
| 634 |
+
if not row:
|
| 635 |
+
return None
|
| 636 |
+
out = dict(row)
|
| 637 |
+
out["manifest"] = _loads(out.pop("manifest_json", None), {}) or {}
|
| 638 |
+
return out
|
| 639 |
+
finally:
|
| 640 |
+
con.close()
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
def import_by_key(owner_user_id: str, key: str) -> Optional[Dict[str, Any]]:
|
| 644 |
+
con = _connect()
|
| 645 |
+
try:
|
| 646 |
+
row = con.execute(
|
| 647 |
+
"SELECT id FROM ms_library_imports WHERE owner_user_id = ? AND idempotency_key = ?",
|
| 648 |
+
(owner_user_id or "", key),
|
| 649 |
+
).fetchone()
|
| 650 |
+
finally:
|
| 651 |
+
con.close()
|
| 652 |
+
return get_import(row["id"]) if row else None
|
| 653 |
+
|
| 654 |
+
|
| 655 |
+
def update_import(import_id: str, *, status: Optional[str] = None, manifest: Any = None,
|
| 656 |
+
error: Optional[str] = None, committed: bool = False,
|
| 657 |
+
idempotency_key: Optional[str] = None) -> None:
|
| 658 |
+
sets, args = [], []
|
| 659 |
+
if status is not None:
|
| 660 |
+
sets.append("status = ?")
|
| 661 |
+
args.append(status)
|
| 662 |
+
if manifest is not None:
|
| 663 |
+
sets.append("manifest_json = ?")
|
| 664 |
+
args.append(_dumps(manifest))
|
| 665 |
+
if error is not None:
|
| 666 |
+
sets.append("error = ?")
|
| 667 |
+
args.append(error)
|
| 668 |
+
if committed:
|
| 669 |
+
sets.append("committed_at = ?")
|
| 670 |
+
args.append(_now())
|
| 671 |
+
if idempotency_key is not None:
|
| 672 |
+
sets.append("idempotency_key = ?")
|
| 673 |
+
args.append(idempotency_key)
|
| 674 |
+
if not sets:
|
| 675 |
+
return
|
| 676 |
+
con = _connect()
|
| 677 |
+
try:
|
| 678 |
+
con.execute(f"UPDATE ms_library_imports SET {', '.join(sets)} WHERE id = ?", args + [import_id])
|
| 679 |
+
con.commit()
|
| 680 |
+
finally:
|
| 681 |
+
con.close()
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
# ── jobs ────────────────────────────────────────────────────────────────────
|
| 685 |
+
|
| 686 |
+
|
| 687 |
+
def create_job(meeting_id: str, type_: str, *, options: Any = None, max_attempts: int = 2) -> str:
|
| 688 |
+
"""Queue a job, unless an identical one is already queued or running (idempotent)."""
|
| 689 |
+
con = _connect()
|
| 690 |
+
try:
|
| 691 |
+
row = con.execute(
|
| 692 |
+
"SELECT id FROM ms_library_jobs WHERE meeting_id = ? AND type = ? AND state IN ('queued', 'running')",
|
| 693 |
+
(meeting_id, type_),
|
| 694 |
+
).fetchone()
|
| 695 |
+
if row:
|
| 696 |
+
return row["id"]
|
| 697 |
+
jid = uuid.uuid4().hex
|
| 698 |
+
con.execute(
|
| 699 |
+
"INSERT INTO ms_library_jobs(id, meeting_id, type, state, stage, progress, attempt,"
|
| 700 |
+
" max_attempts, options_json, created_at) VALUES (?, ?, ?, 'queued', 'queued', 0, 0, ?, ?, ?)",
|
| 701 |
+
(jid, meeting_id, type_, max_attempts, _dumps(options), _now()),
|
| 702 |
+
)
|
| 703 |
+
con.commit()
|
| 704 |
+
return jid
|
| 705 |
+
finally:
|
| 706 |
+
con.close()
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
def _job_row(row) -> Dict[str, Any]:
|
| 710 |
+
out = dict(row)
|
| 711 |
+
out["options"] = _loads(out.pop("options_json", None), {}) or {}
|
| 712 |
+
out["diagnostic"] = _loads(out.pop("diagnostic_json", None), {}) or {}
|
| 713 |
+
return out
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
def get_job(job_id: str) -> Optional[Dict[str, Any]]:
|
| 717 |
+
con = _connect()
|
| 718 |
+
try:
|
| 719 |
+
row = con.execute("SELECT * FROM ms_library_jobs WHERE id = ?", (job_id,)).fetchone()
|
| 720 |
+
return _job_row(row) if row else None
|
| 721 |
+
finally:
|
| 722 |
+
con.close()
|
| 723 |
+
|
| 724 |
+
|
| 725 |
+
def list_jobs(*, meeting_id: Optional[str] = None, states: Optional[Sequence[str]] = None,
|
| 726 |
+
limit: int = 100) -> List[Dict[str, Any]]:
|
| 727 |
+
sql = "SELECT * FROM ms_library_jobs WHERE 1=1"
|
| 728 |
+
args: List[Any] = []
|
| 729 |
+
if meeting_id:
|
| 730 |
+
sql += " AND meeting_id = ?"
|
| 731 |
+
args.append(meeting_id)
|
| 732 |
+
if states:
|
| 733 |
+
sql += f" AND state IN ({','.join('?' * len(states))})"
|
| 734 |
+
args.extend(states)
|
| 735 |
+
sql += " ORDER BY created_at DESC LIMIT ?"
|
| 736 |
+
args.append(int(limit))
|
| 737 |
+
con = _connect()
|
| 738 |
+
try:
|
| 739 |
+
return [_job_row(r) for r in con.execute(sql, args).fetchall()]
|
| 740 |
+
finally:
|
| 741 |
+
con.close()
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
def lease_next_job(*, lease_s: float = 600.0) -> Optional[Dict[str, Any]]:
|
| 745 |
+
"""Take the oldest queued job (or one whose lease ran out — a crashed worker's)."""
|
| 746 |
+
now = _now()
|
| 747 |
+
con = _connect()
|
| 748 |
+
try:
|
| 749 |
+
con.execute("BEGIN IMMEDIATE")
|
| 750 |
+
row = con.execute(
|
| 751 |
+
"SELECT * FROM ms_library_jobs WHERE state = 'queued'"
|
| 752 |
+
" OR (state = 'running' AND lease_until IS NOT NULL AND lease_until < ?)"
|
| 753 |
+
" ORDER BY created_at ASC LIMIT 1",
|
| 754 |
+
(now,),
|
| 755 |
+
).fetchone()
|
| 756 |
+
if not row:
|
| 757 |
+
con.commit()
|
| 758 |
+
return None
|
| 759 |
+
con.execute(
|
| 760 |
+
"UPDATE ms_library_jobs SET state = 'running', attempt = attempt + 1, lease_until = ?,"
|
| 761 |
+
" started_at = COALESCE(started_at, ?), error = NULL WHERE id = ?",
|
| 762 |
+
(now + lease_s, now, row["id"]),
|
| 763 |
+
)
|
| 764 |
+
con.commit()
|
| 765 |
+
finally:
|
| 766 |
+
con.close()
|
| 767 |
+
return get_job(row["id"])
|
| 768 |
+
|
| 769 |
+
|
| 770 |
+
def update_job(job_id: str, *, state: Optional[str] = None, stage: Optional[str] = None,
|
| 771 |
+
progress: Optional[float] = None, error: Optional[str] = None,
|
| 772 |
+
diagnostic: Any = None, lease_s: Optional[float] = None, finished: bool = False) -> None:
|
| 773 |
+
sets, args = [], []
|
| 774 |
+
if state is not None:
|
| 775 |
+
sets.append("state = ?")
|
| 776 |
+
args.append(state)
|
| 777 |
+
if stage is not None:
|
| 778 |
+
sets.append("stage = ?")
|
| 779 |
+
args.append(stage)
|
| 780 |
+
if progress is not None:
|
| 781 |
+
sets.append("progress = ?")
|
| 782 |
+
args.append(max(0.0, min(1.0, float(progress))))
|
| 783 |
+
if error is not None:
|
| 784 |
+
sets.append("error = ?")
|
| 785 |
+
args.append(error)
|
| 786 |
+
if diagnostic is not None:
|
| 787 |
+
sets.append("diagnostic_json = ?")
|
| 788 |
+
args.append(_dumps(diagnostic))
|
| 789 |
+
if lease_s is not None:
|
| 790 |
+
sets.append("lease_until = ?")
|
| 791 |
+
args.append(_now() + lease_s)
|
| 792 |
+
if finished:
|
| 793 |
+
sets.append("finished_at = ?")
|
| 794 |
+
args.append(_now())
|
| 795 |
+
sets.append("lease_until = NULL")
|
| 796 |
+
if not sets:
|
| 797 |
+
return
|
| 798 |
+
con = _connect()
|
| 799 |
+
try:
|
| 800 |
+
con.execute(f"UPDATE ms_library_jobs SET {', '.join(sets)} WHERE id = ?", args + [job_id])
|
| 801 |
+
con.commit()
|
| 802 |
+
finally:
|
| 803 |
+
con.close()
|
| 804 |
+
|
| 805 |
+
|
| 806 |
+
def job_state(job_id: str) -> Optional[str]:
|
| 807 |
+
con = _connect()
|
| 808 |
+
try:
|
| 809 |
+
row = con.execute("SELECT state FROM ms_library_jobs WHERE id = ?", (job_id,)).fetchone()
|
| 810 |
+
return row["state"] if row else None
|
| 811 |
+
finally:
|
| 812 |
+
con.close()
|
| 813 |
+
|
| 814 |
+
|
| 815 |
+
# ── transcript versions ─────────────────────────────────────────────────────
|
| 816 |
+
|
| 817 |
+
|
| 818 |
+
def create_version(meeting_id: str, segments: Sequence[Dict[str, Any]], *, origin: str, timing: str,
|
| 819 |
+
source_ref: Optional[str] = None, provider: Optional[str] = None,
|
| 820 |
+
model: Optional[str] = None, language: Optional[str] = None,
|
| 821 |
+
note: Optional[str] = None) -> str:
|
| 822 |
+
"""Store an immutable transcript version. Not shown until it is published.
|
| 823 |
+
|
| 824 |
+
The first version stored for a meeting that already has a transcript (one recorded live)
|
| 825 |
+
is preceded by a ``live`` snapshot of that transcript, so history reads in the order
|
| 826 |
+
things happened and the recorded words are never lost to an edit.
|
| 827 |
+
"""
|
| 828 |
+
if origin != "live":
|
| 829 |
+
_snapshot_live(meeting_id)
|
| 830 |
+
vid = uuid.uuid4().hex
|
| 831 |
+
rows = []
|
| 832 |
+
for seq, seg in enumerate(s for s in segments if (s.get("text") or "").strip()):
|
| 833 |
+
rows.append((
|
| 834 |
+
vid, seq, int(seg.get("t0_ms") or 0),
|
| 835 |
+
None if seg.get("t1_ms") is None else int(seg["t1_ms"]),
|
| 836 |
+
seg.get("speaker") or None, str(seg["text"]).strip(), seg.get("conf"),
|
| 837 |
+
))
|
| 838 |
+
con = _connect()
|
| 839 |
+
try:
|
| 840 |
+
con.execute(
|
| 841 |
+
"INSERT INTO ms_transcript_versions(id, meeting_id, origin, source_ref, provider, model,"
|
| 842 |
+
" language, timing, active, segment_count, note, created_at)"
|
| 843 |
+
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)",
|
| 844 |
+
(vid, meeting_id, origin, source_ref, provider, model, language, timing, len(rows), note, _now()),
|
| 845 |
+
)
|
| 846 |
+
con.executemany(
|
| 847 |
+
"INSERT INTO ms_transcript_version_segments(version_id, seq, t0_ms, t1_ms, speaker, text, conf)"
|
| 848 |
+
" VALUES (?, ?, ?, ?, ?, ?, ?)",
|
| 849 |
+
rows,
|
| 850 |
+
)
|
| 851 |
+
con.commit()
|
| 852 |
+
finally:
|
| 853 |
+
con.close()
|
| 854 |
+
return vid
|
| 855 |
+
|
| 856 |
+
|
| 857 |
+
def _snapshot_live(meeting_id: str) -> None:
|
| 858 |
+
con = _connect()
|
| 859 |
+
try:
|
| 860 |
+
has_versions = con.execute(
|
| 861 |
+
"SELECT 1 FROM ms_transcript_versions WHERE meeting_id = ? LIMIT 1", (meeting_id,)).fetchone()
|
| 862 |
+
finally:
|
| 863 |
+
con.close()
|
| 864 |
+
if has_versions:
|
| 865 |
+
return
|
| 866 |
+
current = store.get_segments(meeting_id)
|
| 867 |
+
if not current:
|
| 868 |
+
return
|
| 869 |
+
snapshot = [{"t0_ms": s["t0_ms"], "t1_ms": s["t1_ms"], "speaker": s.get("speaker"),
|
| 870 |
+
"text": s["text"], "conf": s.get("conf")} for s in current]
|
| 871 |
+
vid = create_version(meeting_id, snapshot, origin="live", timing="measured",
|
| 872 |
+
note="The transcript recorded live, kept when another version was added.")
|
| 873 |
+
con = _connect()
|
| 874 |
+
try: # it is what is shown right now
|
| 875 |
+
con.execute("UPDATE ms_transcript_versions SET active = 1 WHERE id = ?", (vid,))
|
| 876 |
+
con.commit()
|
| 877 |
+
finally:
|
| 878 |
+
con.close()
|
| 879 |
+
|
| 880 |
+
|
| 881 |
+
def list_versions(meeting_id: str) -> List[Dict[str, Any]]:
|
| 882 |
+
con = _connect()
|
| 883 |
+
try:
|
| 884 |
+
rows = con.execute(
|
| 885 |
+
"SELECT * FROM ms_transcript_versions WHERE meeting_id = ? ORDER BY created_at ASC", (meeting_id,)
|
| 886 |
+
).fetchall()
|
| 887 |
+
return [dict(r) for r in rows]
|
| 888 |
+
finally:
|
| 889 |
+
con.close()
|
| 890 |
+
|
| 891 |
+
|
| 892 |
+
def version_segments(version_id: str) -> List[Dict[str, Any]]:
|
| 893 |
+
con = _connect()
|
| 894 |
+
try:
|
| 895 |
+
rows = con.execute(
|
| 896 |
+
"SELECT * FROM ms_transcript_version_segments WHERE version_id = ? ORDER BY seq", (version_id,)
|
| 897 |
+
).fetchall()
|
| 898 |
+
return [dict(r) for r in rows]
|
| 899 |
+
finally:
|
| 900 |
+
con.close()
|
| 901 |
+
|
| 902 |
+
|
| 903 |
+
def publish_version(meeting_id: str, version_id: str) -> int:
|
| 904 |
+
"""Make a version the meeting's transcript. Returns the segments now shown.
|
| 905 |
+
|
| 906 |
+
``ms_segments`` is what every existing reader uses — the card, export, summaries,
|
| 907 |
+
"ask", search — so publishing writes the version there. Whatever was there before is
|
| 908 |
+
first kept as a version of its own (``live`` when the recorder wrote it), so publishing
|
| 909 |
+
never loses a line: the previous transcript is one publish away.
|
| 910 |
+
"""
|
| 911 |
+
versions = {v["id"]: v for v in list_versions(meeting_id)}
|
| 912 |
+
if version_id not in versions:
|
| 913 |
+
raise KeyError(version_id)
|
| 914 |
+
rows = version_segments(version_id)
|
| 915 |
+
con = _connect()
|
| 916 |
+
try:
|
| 917 |
+
con.execute("DELETE FROM ms_segments WHERE meeting_id = ?", (meeting_id,))
|
| 918 |
+
con.executemany(
|
| 919 |
+
"INSERT INTO ms_segments(id, meeting_id, t0_ms, t1_ms, speaker, text, conf, seq)"
|
| 920 |
+
" VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
| 921 |
+
[(uuid.uuid4().hex, meeting_id, r["t0_ms"], r["t1_ms"], r["speaker"], r["text"], r["conf"], r["seq"])
|
| 922 |
+
for r in rows],
|
| 923 |
+
)
|
| 924 |
+
con.execute("UPDATE ms_transcript_versions SET active = 0 WHERE meeting_id = ?", (meeting_id,))
|
| 925 |
+
con.execute("UPDATE ms_transcript_versions SET active = 1 WHERE id = ?", (version_id,))
|
| 926 |
+
con.commit()
|
| 927 |
+
finally:
|
| 928 |
+
con.close()
|
| 929 |
+
return len(rows)
|
| 930 |
+
|
| 931 |
+
|
| 932 |
+
# ── folder sources ──────────────────────────────────────────────────────────
|
| 933 |
+
|
| 934 |
+
|
| 935 |
+
def _source_row(row) -> Dict[str, Any]:
|
| 936 |
+
out = dict(row)
|
| 937 |
+
out["counts"] = _loads(out.pop("counts_json", None), {}) or {}
|
| 938 |
+
out["enabled"] = bool(out.get("enabled"))
|
| 939 |
+
return out
|
| 940 |
+
|
| 941 |
+
|
| 942 |
+
def create_source(*, kind: str, display_name: str, path: str, resolved_path: str,
|
| 943 |
+
owner_user_id: str = "", project_id: Optional[str] = None, enabled: bool = True) -> str:
|
| 944 |
+
sid = uuid.uuid4().hex
|
| 945 |
+
con = _connect()
|
| 946 |
+
try:
|
| 947 |
+
con.execute(
|
| 948 |
+
"INSERT INTO ms_library_sources(id, kind, display_name, path, resolved_path, owner_user_id,"
|
| 949 |
+
" enabled, project_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
| 950 |
+
(sid, kind, display_name, path, resolved_path, owner_user_id or "", 1 if enabled else 0,
|
| 951 |
+
project_id, _now()),
|
| 952 |
+
)
|
| 953 |
+
con.commit()
|
| 954 |
+
finally:
|
| 955 |
+
con.close()
|
| 956 |
+
return sid
|
| 957 |
+
|
| 958 |
+
|
| 959 |
+
def list_sources() -> List[Dict[str, Any]]:
|
| 960 |
+
con = _connect()
|
| 961 |
+
try:
|
| 962 |
+
return [_source_row(r) for r in con.execute(
|
| 963 |
+
"SELECT * FROM ms_library_sources ORDER BY created_at ASC").fetchall()]
|
| 964 |
+
finally:
|
| 965 |
+
con.close()
|
| 966 |
+
|
| 967 |
+
|
| 968 |
+
def get_source(source_id: str) -> Optional[Dict[str, Any]]:
|
| 969 |
+
con = _connect()
|
| 970 |
+
try:
|
| 971 |
+
row = con.execute("SELECT * FROM ms_library_sources WHERE id = ?", (source_id,)).fetchone()
|
| 972 |
+
return _source_row(row) if row else None
|
| 973 |
+
finally:
|
| 974 |
+
con.close()
|
| 975 |
+
|
| 976 |
+
|
| 977 |
+
def update_source(source_id: str, *, counts: Any = None, **fields: Any) -> None:
|
| 978 |
+
allowed = {"display_name", "enabled", "project_id", "last_scan_at", "last_success_at", "last_error"}
|
| 979 |
+
sets, args = [], []
|
| 980 |
+
for key, value in fields.items():
|
| 981 |
+
if key not in allowed:
|
| 982 |
+
raise ValueError(f"not a source field: {key}")
|
| 983 |
+
sets.append(f"{key} = ?")
|
| 984 |
+
args.append(int(value) if key == "enabled" else value)
|
| 985 |
+
if counts is not None:
|
| 986 |
+
sets.append("counts_json = ?")
|
| 987 |
+
args.append(_dumps(counts))
|
| 988 |
+
if not sets:
|
| 989 |
+
return
|
| 990 |
+
con = _connect()
|
| 991 |
+
try:
|
| 992 |
+
con.execute(f"UPDATE ms_library_sources SET {', '.join(sets)} WHERE id = ?", args + [source_id])
|
| 993 |
+
con.commit()
|
| 994 |
+
finally:
|
| 995 |
+
con.close()
|
| 996 |
+
|
| 997 |
+
|
| 998 |
+
def delete_source(source_id: str) -> None:
|
| 999 |
+
"""Disconnect a folder. Meetings imported from it stay — they are HomePilot's copies."""
|
| 1000 |
+
con = _connect()
|
| 1001 |
+
try:
|
| 1002 |
+
con.execute("DELETE FROM ms_library_sources WHERE id = ?", (source_id,))
|
| 1003 |
+
con.execute("DELETE FROM ms_library_sync_seen WHERE source_id = ?", (source_id,))
|
| 1004 |
+
con.commit()
|
| 1005 |
+
finally:
|
| 1006 |
+
con.close()
|
| 1007 |
+
|
| 1008 |
+
|
| 1009 |
+
def observe(source_id: str, relative_path: str, size_bytes: int, mtime: float) -> Dict[str, Any]:
|
| 1010 |
+
"""Record a file sighting. ``changed`` is True when it differs from the last sighting."""
|
| 1011 |
+
now = _now()
|
| 1012 |
+
con = _connect()
|
| 1013 |
+
try:
|
| 1014 |
+
row = con.execute(
|
| 1015 |
+
"SELECT * FROM ms_library_sync_seen WHERE source_id = ? AND relative_path = ?",
|
| 1016 |
+
(source_id, relative_path),
|
| 1017 |
+
).fetchone()
|
| 1018 |
+
if row and int(row["size_bytes"]) == int(size_bytes) and float(row["mtime"]) == float(mtime):
|
| 1019 |
+
return {**dict(row), "changed": False}
|
| 1020 |
+
con.execute(
|
| 1021 |
+
"INSERT OR REPLACE INTO ms_library_sync_seen(source_id, relative_path, size_bytes, mtime, first_seen_at)"
|
| 1022 |
+
" VALUES (?, ?, ?, ?, ?)",
|
| 1023 |
+
(source_id, relative_path, int(size_bytes), float(mtime), now),
|
| 1024 |
+
)
|
| 1025 |
+
con.commit()
|
| 1026 |
+
return {"source_id": source_id, "relative_path": relative_path, "size_bytes": size_bytes,
|
| 1027 |
+
"mtime": mtime, "first_seen_at": now, "changed": row is not None}
|
| 1028 |
+
finally:
|
| 1029 |
+
con.close()
|
| 1030 |
+
|
| 1031 |
+
|
| 1032 |
+
# ── deletion helpers ────────────────────────────────────────────────────────
|
| 1033 |
+
|
| 1034 |
+
|
| 1035 |
+
def delete_library_rows(meeting_id: str) -> Dict[str, int]:
|
| 1036 |
+
"""Remove the library's rows for a meeting (MeetingSense removes its own)."""
|
| 1037 |
+
con = _connect()
|
| 1038 |
+
try:
|
| 1039 |
+
counts = {}
|
| 1040 |
+
for table, column in (
|
| 1041 |
+
("ms_library_assets", "meeting_id"), ("ms_library_jobs", "meeting_id"),
|
| 1042 |
+
("ms_meeting_project_links", "meeting_id"), ("ms_library_origin_links", "meeting_id"),
|
| 1043 |
+
("ms_library_entries", "meeting_id"),
|
| 1044 |
+
):
|
| 1045 |
+
cur = con.execute(f"DELETE FROM {table} WHERE {column} = ?", (meeting_id,))
|
| 1046 |
+
counts[table] = int(cur.rowcount or 0)
|
| 1047 |
+
vids = [r["id"] for r in con.execute(
|
| 1048 |
+
"SELECT id FROM ms_transcript_versions WHERE meeting_id = ?", (meeting_id,)).fetchall()]
|
| 1049 |
+
for vid in vids:
|
| 1050 |
+
con.execute("DELETE FROM ms_transcript_version_segments WHERE version_id = ?", (vid,))
|
| 1051 |
+
cur = con.execute("DELETE FROM ms_transcript_versions WHERE meeting_id = ?", (meeting_id,))
|
| 1052 |
+
counts["ms_transcript_versions"] = int(cur.rowcount or 0)
|
| 1053 |
+
con.commit()
|
| 1054 |
+
finally:
|
| 1055 |
+
con.close()
|
| 1056 |
+
try:
|
| 1057 |
+
from ..intelligence import store as intelligence_store # noqa: PLC0415
|
| 1058 |
+
|
| 1059 |
+
counts.update(intelligence_store.delete_for(meeting_id))
|
| 1060 |
+
except Exception: # noqa: BLE001 — the tables exist only once the flag has been on
|
| 1061 |
+
pass
|
| 1062 |
+
return counts
|
backend/app/meetingsense/library/importer.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Importing recordings and transcripts into the library — preview first, then commit.
|
| 2 |
+
|
| 3 |
+
Preview looks at everything and changes nothing: which meetings the files contain, how long
|
| 4 |
+
the audio really is, whether a transcript is usable, whether the recording is silent, whether
|
| 5 |
+
it is already in the library, and whether it belongs to a meeting scheduled for that time.
|
| 6 |
+
Commit does what the person confirmed, once — repeating it with the same idempotency key
|
| 7 |
+
returns the first result instead of importing twice.
|
| 8 |
+
|
| 9 |
+
Imported files are **copied** into HomePilot's own storage. Sources are read, never written.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import hashlib
|
| 15 |
+
import shutil
|
| 16 |
+
import time
|
| 17 |
+
import uuid
|
| 18 |
+
from datetime import datetime
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import Any, Dict, List, Optional, Sequence
|
| 21 |
+
|
| 22 |
+
from . import db, media, parsers
|
| 23 |
+
from .adapters import Candidate, discover
|
| 24 |
+
from .config import load_library_config, media_root
|
| 25 |
+
|
| 26 |
+
#: How far apart a recording and a scheduled meeting can start and still be proposed as the
|
| 27 |
+
#: same meeting. Ambiguous matches are offered, never applied without a confirmation.
|
| 28 |
+
MATCH_WINDOW_S = 45 * 60
|
| 29 |
+
#: Loudness is measured during preview only for recordings up to this long; longer ones are
|
| 30 |
+
#: checked by the transcription job, so a preview of a three-hour file stays quick.
|
| 31 |
+
PREVIEW_LOUDNESS_MAX_MS = 20 * 60 * 1000
|
| 32 |
+
|
| 33 |
+
_SOURCE_LABELS = {"airecord": "Insta360 / AIRecord", "audio": "Audio upload", "transcript": "Transcript"}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def sha256_file(path: Path) -> str:
|
| 37 |
+
h = hashlib.sha256()
|
| 38 |
+
with open(path, "rb") as fh:
|
| 39 |
+
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
| 40 |
+
h.update(chunk)
|
| 41 |
+
return h.hexdigest()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _iso(ts: Optional[float]) -> Optional[str]:
|
| 45 |
+
return datetime.fromtimestamp(ts).isoformat(timespec="seconds") if ts else None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def scheduled_matches(started_at: Optional[float], *, exclude: Sequence[str] = ()) -> List[Dict[str, Any]]:
|
| 49 |
+
"""Scheduled meetings still waiting for a recording that start near ``started_at``."""
|
| 50 |
+
if not started_at:
|
| 51 |
+
return []
|
| 52 |
+
con = db._connect()
|
| 53 |
+
try:
|
| 54 |
+
rows = con.execute(
|
| 55 |
+
"SELECT e.meeting_id, e.scheduled_for, m.title FROM ms_library_entries e"
|
| 56 |
+
" JOIN ms_meetings m ON m.id = e.meeting_id"
|
| 57 |
+
" WHERE e.recording_state = 'awaiting' AND e.scheduled_for IS NOT NULL AND e.hidden_at IS NULL"
|
| 58 |
+
" AND ABS(e.scheduled_for - ?) <= ? ORDER BY ABS(e.scheduled_for - ?) ASC",
|
| 59 |
+
(started_at, MATCH_WINDOW_S, started_at),
|
| 60 |
+
).fetchall()
|
| 61 |
+
finally:
|
| 62 |
+
con.close()
|
| 63 |
+
return [{"meeting_id": r["meeting_id"], "title": r["title"], "scheduled_for": r["scheduled_for"],
|
| 64 |
+
"minutes_apart": round(abs(r["scheduled_for"] - started_at) / 60)}
|
| 65 |
+
for r in rows if r["meeting_id"] not in exclude]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def analyse(cand: Candidate, *, quick: bool = True) -> Dict[str, Any]:
|
| 69 |
+
"""Describe one candidate meeting for the preview. Reads files; writes nothing."""
|
| 70 |
+
item: Dict[str, Any] = {
|
| 71 |
+
"key": cand.key,
|
| 72 |
+
"kind": cand.kind,
|
| 73 |
+
"source_label": _SOURCE_LABELS.get(cand.kind, cand.kind),
|
| 74 |
+
"title": cand.title,
|
| 75 |
+
"started_at": cand.started_at,
|
| 76 |
+
"ended_at": cand.ended_at,
|
| 77 |
+
"time_source": cand.time_source,
|
| 78 |
+
"files": [f.as_dict() for f in cand.files],
|
| 79 |
+
"ignored_files": [f.as_dict() for f in cand.ignored],
|
| 80 |
+
"warnings": list(cand.warnings),
|
| 81 |
+
"origin": list(cand.origin) if cand.origin else None,
|
| 82 |
+
"duplicate_of": None,
|
| 83 |
+
"scheduled_matches": [],
|
| 84 |
+
"audio": None,
|
| 85 |
+
"transcript": None,
|
| 86 |
+
"outcome": "import",
|
| 87 |
+
"transcript_state": "none",
|
| 88 |
+
}
|
| 89 |
+
if cand.raw_metadata.get("description"):
|
| 90 |
+
item["description"] = cand.raw_metadata["description"]
|
| 91 |
+
|
| 92 |
+
if cand.audio is not None:
|
| 93 |
+
info = media.probe(cand.audio.path)
|
| 94 |
+
with cand.audio.path.open("rb") as fh:
|
| 95 |
+
sniffed = media.sniff_audio(fh.read(16))
|
| 96 |
+
audio: Dict[str, Any] = {"name": cand.audio.as_dict()["name"], "size": cand.audio.size,
|
| 97 |
+
"mime": sniffed, "readable": bool(info.get("ok"))}
|
| 98 |
+
if info.get("ok"):
|
| 99 |
+
audio.update({k: info.get(k) for k in ("duration_ms", "codec", "sample_rate", "channels")})
|
| 100 |
+
if cand.claimed_duration_ms or (cand.started_at and cand.ended_at):
|
| 101 |
+
_note_time_mismatch(item, cand, info.get("duration_ms") or 0)
|
| 102 |
+
if not quick or (info.get("duration_ms") or 0) <= PREVIEW_LOUDNESS_MAX_MS:
|
| 103 |
+
level = media.loudness(cand.audio.path)
|
| 104 |
+
audio["max_db"] = level.get("max_db")
|
| 105 |
+
audio["mean_db"] = level.get("mean_db")
|
| 106 |
+
audio["silent"] = media.is_silent(level)
|
| 107 |
+
else:
|
| 108 |
+
audio["error"] = info.get("error")
|
| 109 |
+
item["warnings"].append(info.get("error") or "The recording could not be read.")
|
| 110 |
+
audio["sha256"] = sha256_file(cand.audio.path)
|
| 111 |
+
item["audio"] = audio
|
| 112 |
+
|
| 113 |
+
if cand.transcript is not None:
|
| 114 |
+
parsed = parsers.parse_transcript(cand.transcript.path.name, cand.transcript.path.read_bytes())
|
| 115 |
+
item["transcript"] = {
|
| 116 |
+
"name": cand.transcript.as_dict()["name"],
|
| 117 |
+
"format": parsed.format,
|
| 118 |
+
"segments": len(parsed.segments),
|
| 119 |
+
"usable": parsed.usable,
|
| 120 |
+
"timing": parsed.timing,
|
| 121 |
+
"speakers": sorted({s["speaker"] for s in parsed.segments if s.get("speaker")})[:12],
|
| 122 |
+
"preview": " ".join(s["text"] for s in parsed.segments[:3])[:240],
|
| 123 |
+
}
|
| 124 |
+
item["warnings"].extend(parsed.warnings)
|
| 125 |
+
if not parsed.usable and cand.kind == "airecord":
|
| 126 |
+
item["warnings"].append("The recorder's transcript is empty — it has to be transcribed.")
|
| 127 |
+
|
| 128 |
+
# Outcome — what will happen to the transcript.
|
| 129 |
+
audio = item["audio"] or {}
|
| 130 |
+
transcript = item["transcript"] or {}
|
| 131 |
+
if transcript.get("usable"):
|
| 132 |
+
item["transcript_state"] = "imported"
|
| 133 |
+
elif cand.audio is None:
|
| 134 |
+
item["transcript_state"] = "transcript_only" if transcript else "none"
|
| 135 |
+
if not transcript.get("usable"):
|
| 136 |
+
item["outcome"] = "skip"
|
| 137 |
+
item["warnings"].append("Nothing usable to import: the transcript is empty.")
|
| 138 |
+
elif not audio.get("readable"):
|
| 139 |
+
item["transcript_state"] = "failed"
|
| 140 |
+
elif audio.get("silent"):
|
| 141 |
+
item["transcript_state"] = "no_speech"
|
| 142 |
+
item["warnings"].append(
|
| 143 |
+
f"No speech detected — the recording is silent (loudest moment {audio.get('max_db')} dB)."
|
| 144 |
+
)
|
| 145 |
+
else:
|
| 146 |
+
item["transcript_state"] = "needs_transcription"
|
| 147 |
+
if cand.audio is not None and transcript.get("usable"):
|
| 148 |
+
item["transcript_state"] = "imported"
|
| 149 |
+
|
| 150 |
+
# Already in the library?
|
| 151 |
+
existing = None
|
| 152 |
+
if cand.origin:
|
| 153 |
+
existing = db.origin_meeting(*cand.origin)
|
| 154 |
+
if not existing and audio.get("sha256"):
|
| 155 |
+
hits = db.assets_with_sha(audio["sha256"])
|
| 156 |
+
existing = hits[0]["meeting_id"] if hits else None
|
| 157 |
+
if existing:
|
| 158 |
+
item["duplicate_of"] = existing
|
| 159 |
+
item["outcome"] = "skip"
|
| 160 |
+
else:
|
| 161 |
+
item["scheduled_matches"] = scheduled_matches(cand.started_at)
|
| 162 |
+
if len(item["scheduled_matches"]) == 1 and cand.audio is not None:
|
| 163 |
+
item["outcome"] = "attach"
|
| 164 |
+
item["attach_to"] = item["scheduled_matches"][0]["meeting_id"]
|
| 165 |
+
return item
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _note_time_mismatch(item: Dict[str, Any], cand: Candidate, media_ms: int) -> None:
|
| 169 |
+
claims = []
|
| 170 |
+
if cand.claimed_duration_ms:
|
| 171 |
+
claims.append(("the recorder's duration", cand.claimed_duration_ms))
|
| 172 |
+
if cand.started_at and cand.ended_at and cand.ended_at > cand.started_at:
|
| 173 |
+
claims.append(("its start/end times", int((cand.ended_at - cand.started_at) * 1000)))
|
| 174 |
+
off = [(label, ms) for label, ms in claims if abs(ms - media_ms) > max(2000, media_ms * 0.1)]
|
| 175 |
+
if off:
|
| 176 |
+
parts = ", ".join(f"{label} say {ms / 1000:.0f} s" for label, ms in off)
|
| 177 |
+
item["warnings"].append(
|
| 178 |
+
f"The recording plays for {media_ms / 1000:.1f} s; {parts}. The playable length is used "
|
| 179 |
+
"and the original values are kept."
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ── preview / commit for uploads ────────────────────────────────────────────
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def staging_dir(import_id: str) -> Path:
|
| 187 |
+
return media_root() / "staging" / import_id
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def preview_tree(tree: Path) -> Dict[str, Any]:
|
| 191 |
+
candidates = discover(tree)
|
| 192 |
+
items = [analyse(c) for c in candidates]
|
| 193 |
+
to_transcribe_ms = sum((i.get("audio") or {}).get("duration_ms") or 0
|
| 194 |
+
for i in items if i["transcript_state"] == "needs_transcription" and i["outcome"] != "skip")
|
| 195 |
+
return {
|
| 196 |
+
"items": items,
|
| 197 |
+
"totals": {
|
| 198 |
+
"meetings": len(items),
|
| 199 |
+
"duplicates": sum(1 for i in items if i["duplicate_of"]),
|
| 200 |
+
"needs_transcription": sum(1 for i in items if i["transcript_state"] == "needs_transcription"),
|
| 201 |
+
"audio_minutes_to_transcribe": round(to_transcribe_ms / 60000, 1),
|
| 202 |
+
},
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def commit_import(import_row: Dict[str, Any], choices: Sequence[Dict[str, Any]], *,
|
| 207 |
+
owner_user_id: str, project_id: Optional[str] = None) -> Dict[str, Any]:
|
| 208 |
+
"""Create (or attach) the meetings a person confirmed in the preview."""
|
| 209 |
+
tree = Path(import_row["staging_dir"]) / "tree"
|
| 210 |
+
by_key = {c.key: c for c in discover(tree)}
|
| 211 |
+
manifest = import_row.get("manifest") or {}
|
| 212 |
+
analysed = {i["key"]: i for i in manifest.get("items") or []}
|
| 213 |
+
wanted = {str(c.get("key")): c for c in choices or [] if c.get("key")}
|
| 214 |
+
if not wanted:
|
| 215 |
+
# No explicit choices: the preview's own defaults.
|
| 216 |
+
wanted = {k: {"key": k, "action": v.get("outcome"), "attach_to": v.get("attach_to")} for k, v in analysed.items()}
|
| 217 |
+
results: List[Dict[str, Any]] = []
|
| 218 |
+
for key, choice in wanted.items():
|
| 219 |
+
cand = by_key.get(key)
|
| 220 |
+
item = analysed.get(key) or {}
|
| 221 |
+
action = str(choice.get("action") or item.get("outcome") or "import")
|
| 222 |
+
if cand is None:
|
| 223 |
+
results.append({"key": key, "status": "missing"})
|
| 224 |
+
continue
|
| 225 |
+
if action == "skip":
|
| 226 |
+
results.append({"key": key, "status": "skipped", "meeting_id": item.get("duplicate_of")})
|
| 227 |
+
continue
|
| 228 |
+
result = ingest(
|
| 229 |
+
cand, source_id="upload", origin_type="import", owner_user_id=owner_user_id,
|
| 230 |
+
title=str(choice.get("title") or "").strip() or None,
|
| 231 |
+
project_id=str(choice.get("project_id") or project_id or "").strip() or None,
|
| 232 |
+
category=str(choice.get("category") or "").strip() or None,
|
| 233 |
+
attach_to=str(choice.get("attach_to") or "").strip() or None if action == "attach" else None,
|
| 234 |
+
transcribe=bool(choice.get("transcribe", True)),
|
| 235 |
+
analysed=item,
|
| 236 |
+
)
|
| 237 |
+
results.append({"key": key, **result})
|
| 238 |
+
return {"results": results}
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
# ── ingest (shared with folder sync) ────────────────────────────────────────
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def _copy_into(meeting_id: str, src: Path, name: str) -> str:
|
| 245 |
+
"""Copy a file into the meeting's own folder; return its path relative to the media root."""
|
| 246 |
+
folder = media_root() / meeting_id
|
| 247 |
+
folder.mkdir(parents=True, exist_ok=True)
|
| 248 |
+
safe = name.replace("/", "_").replace("\\", "_").strip() or "file"
|
| 249 |
+
target = folder / safe
|
| 250 |
+
if target.exists():
|
| 251 |
+
target = folder / f"{uuid.uuid4().hex[:8]}_{safe}"
|
| 252 |
+
shutil.copy2(src, target)
|
| 253 |
+
return target.relative_to(media_root()).as_posix()
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def ingest(cand: Candidate, *, source_id: str, origin_type: str, owner_user_id: str = "",
|
| 257 |
+
title: Optional[str] = None, project_id: Optional[str] = None, category: Optional[str] = None,
|
| 258 |
+
attach_to: Optional[str] = None, transcribe: bool = True,
|
| 259 |
+
analysed: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
| 260 |
+
"""Make one candidate a library meeting (or add its recording to a scheduled one)."""
|
| 261 |
+
item = analysed or analyse(cand)
|
| 262 |
+
if item.get("duplicate_of") and not attach_to:
|
| 263 |
+
return {"status": "duplicate", "meeting_id": item["duplicate_of"]}
|
| 264 |
+
audio = item.get("audio") or {}
|
| 265 |
+
duration_ms = audio.get("duration_ms") or cand.claimed_duration_ms
|
| 266 |
+
started_at = cand.started_at or time.time()
|
| 267 |
+
ended_at = started_at + duration_ms / 1000.0 if duration_ms else cand.ended_at
|
| 268 |
+
warnings = list(dict.fromkeys(item.get("warnings") or []))
|
| 269 |
+
source_name = "insta360" if cand.kind == "airecord" else ("transcript" if cand.kind == "transcript" else "upload")
|
| 270 |
+
if origin_type == "sync":
|
| 271 |
+
source_name = "insta360" if cand.kind == "airecord" else "folder"
|
| 272 |
+
|
| 273 |
+
if attach_to and db.get_entry(attach_to):
|
| 274 |
+
meeting_id = attach_to
|
| 275 |
+
con = db._connect()
|
| 276 |
+
try:
|
| 277 |
+
con.execute("UPDATE ms_meetings SET started_at = ?, ended_at = ?, status = 'ended',"
|
| 278 |
+
" source = COALESCE(source, ?) WHERE id = ?",
|
| 279 |
+
(started_at, ended_at, source_name, meeting_id))
|
| 280 |
+
con.commit()
|
| 281 |
+
finally:
|
| 282 |
+
con.close()
|
| 283 |
+
db.update_entry(meeting_id, recording_state="present", duration_ms=duration_ms, media_present=1,
|
| 284 |
+
warnings=warnings, raw_metadata=cand.raw_metadata or None)
|
| 285 |
+
status = "attached"
|
| 286 |
+
else:
|
| 287 |
+
meeting_id = db.create_meeting_entry(
|
| 288 |
+
title=title or cand.title, origin_type=origin_type, owner_user_id=owner_user_id,
|
| 289 |
+
started_at=started_at, ended_at=ended_at, status="ended", source=source_name,
|
| 290 |
+
project_id=project_id, category=category or ("Imported" if origin_type == "import" else "Synced"),
|
| 291 |
+
description=item.get("description"),
|
| 292 |
+
recording_state="present" if cand.audio is not None else "none",
|
| 293 |
+
transcript_state="none", duration_ms=duration_ms, raw_metadata=cand.raw_metadata or None,
|
| 294 |
+
warnings=warnings,
|
| 295 |
+
)
|
| 296 |
+
status = "created"
|
| 297 |
+
if cand.origin:
|
| 298 |
+
db.link_origin(cand.origin[0], cand.origin[1], meeting_id)
|
| 299 |
+
|
| 300 |
+
# Files: the recording, the transcript and the recorder's metadata — copies.
|
| 301 |
+
if cand.audio is not None:
|
| 302 |
+
ref = _copy_into(meeting_id, cand.audio.path, cand.audio.path.name)
|
| 303 |
+
db.add_asset(meeting_id=meeting_id, source_id=source_id, origin_type=origin_type,
|
| 304 |
+
relative_path=cand.audio.rel, original_name=cand.audio.path.name, kind="audio",
|
| 305 |
+
role="primary", sha256=audio.get("sha256") or sha256_file(cand.audio.path),
|
| 306 |
+
mime=audio.get("mime"), size_bytes=cand.audio.size, storage_ref=ref,
|
| 307 |
+
media_duration_ms=audio.get("duration_ms"),
|
| 308 |
+
metadata={k: audio.get(k) for k in ("codec", "sample_rate", "channels", "max_db", "mean_db")
|
| 309 |
+
if audio.get(k) is not None} | {"source_mtime": cand.audio.mtime})
|
| 310 |
+
parsed = None
|
| 311 |
+
if cand.transcript is not None:
|
| 312 |
+
raw = cand.transcript.path.read_bytes()
|
| 313 |
+
parsed = parsers.parse_transcript(cand.transcript.path.name, raw)
|
| 314 |
+
ref = _copy_into(meeting_id, cand.transcript.path, cand.transcript.path.name)
|
| 315 |
+
db.add_asset(meeting_id=meeting_id, source_id=source_id, origin_type=origin_type,
|
| 316 |
+
relative_path=cand.transcript.rel, original_name=cand.transcript.path.name,
|
| 317 |
+
kind="transcript", role="primary", sha256=hashlib.sha256(raw).hexdigest(),
|
| 318 |
+
mime="text/plain", size_bytes=cand.transcript.size, storage_ref=ref,
|
| 319 |
+
metadata={"format": parsed.format, "segments": len(parsed.segments), "usable": parsed.usable})
|
| 320 |
+
for meta in cand.metadata_files:
|
| 321 |
+
ref = _copy_into(meeting_id, meta.path, meta.path.name)
|
| 322 |
+
db.add_asset(meeting_id=meeting_id, source_id=source_id, origin_type=origin_type,
|
| 323 |
+
relative_path=meta.rel, original_name=meta.path.name, kind="metadata", role="secondary",
|
| 324 |
+
sha256=sha256_file(meta.path), mime="application/json", size_bytes=meta.size,
|
| 325 |
+
storage_ref=ref)
|
| 326 |
+
|
| 327 |
+
# Transcript state.
|
| 328 |
+
state = "none"
|
| 329 |
+
if parsed is not None and parsed.usable:
|
| 330 |
+
segments = parsers.monotonic(parsed.segments)
|
| 331 |
+
vid = db.create_version(meeting_id, segments, origin="imported", timing=parsed.timing,
|
| 332 |
+
source_ref=cand.transcript.rel, note=f"Imported from {cand.transcript.path.name}")
|
| 333 |
+
db.publish_version(meeting_id, vid)
|
| 334 |
+
state = "transcript_only" if cand.audio is None else "imported"
|
| 335 |
+
if cand.audio is None and parsed.timing == "imported":
|
| 336 |
+
# No recording to measure: the transcript's own times give the length.
|
| 337 |
+
span_ms = max((s.get("t1_ms") or s.get("t0_ms") or 0) for s in segments)
|
| 338 |
+
if span_ms > 0:
|
| 339 |
+
db.update_entry(meeting_id, duration_ms=int(span_ms))
|
| 340 |
+
con = db._connect()
|
| 341 |
+
try:
|
| 342 |
+
con.execute("UPDATE ms_meetings SET ended_at = started_at + ? WHERE id = ?",
|
| 343 |
+
(span_ms / 1000.0, meeting_id))
|
| 344 |
+
con.commit()
|
| 345 |
+
finally:
|
| 346 |
+
con.close()
|
| 347 |
+
_queue_followups(meeting_id)
|
| 348 |
+
elif cand.audio is not None:
|
| 349 |
+
if not audio.get("readable", True):
|
| 350 |
+
state = "failed"
|
| 351 |
+
elif audio.get("silent"):
|
| 352 |
+
state = "no_speech"
|
| 353 |
+
elif transcribe and load_library_config().processing:
|
| 354 |
+
db.create_job(meeting_id, "transcribe")
|
| 355 |
+
state = "queued"
|
| 356 |
+
db.update_entry(meeting_id, transcript_state=state)
|
| 357 |
+
return {"status": status, "meeting_id": meeting_id, "transcript_state": state}
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def _queue_followups(meeting_id: str) -> None:
|
| 361 |
+
"""After a transcript lands: make it searchable, then (if a model answers) summarise."""
|
| 362 |
+
if not load_library_config().processing:
|
| 363 |
+
return
|
| 364 |
+
db.create_job(meeting_id, "index", max_attempts=1)
|
| 365 |
+
entry = db.get_entry(meeting_id) or {}
|
| 366 |
+
outputs = entry.get("outputs") or {}
|
| 367 |
+
if outputs.get("summary", True):
|
| 368 |
+
db.create_job(meeting_id, "summarize", max_attempts=1, options={"outputs": outputs})
|
| 369 |
+
db.update_entry(meeting_id, summary_state="pending")
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def cleanup_staging(import_id: str) -> None:
|
| 373 |
+
shutil.rmtree(staging_dir(import_id), ignore_errors=True)
|
backend/app/meetingsense/library/jobs.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Background processing for library meetings — transcribe, index, summarise.
|
| 2 |
+
|
| 3 |
+
A durable queue in SQLite (``ms_library_jobs``) worked by one in-process worker. Durable
|
| 4 |
+
because a three-hour recording outlives a request: a job survives a restart (an expired
|
| 5 |
+
lease is picked up again) and its progress can be shown while it runs.
|
| 6 |
+
|
| 7 |
+
Transcription uses **HomePilot's own speech provider** — ``get_meeting_stt_provider()``,
|
| 8 |
+
the same local-first choice live meetings use. Nothing here ships audio anywhere: when no
|
| 9 |
+
local model is installed the job stops at *Needs speech-to-text setup* and the recording
|
| 10 |
+
waits, stored and playable, for the user to install one.
|
| 11 |
+
|
| 12 |
+
One job at a time, and transcription steps aside while a live meeting is recording so an
|
| 13 |
+
import never makes a meeting in progress fall behind.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import asyncio
|
| 19 |
+
import logging
|
| 20 |
+
import math
|
| 21 |
+
import os
|
| 22 |
+
import re
|
| 23 |
+
import time
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Any, Dict, List, Optional
|
| 26 |
+
|
| 27 |
+
from . import db, media
|
| 28 |
+
from .config import load_library_config, media_root
|
| 29 |
+
|
| 30 |
+
log = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
_worker: Optional[asyncio.Task] = None
|
| 33 |
+
_wake: Optional[asyncio.Event] = None
|
| 34 |
+
|
| 35 |
+
#: Seconds of overlap between transcription chunks — enough that a word cut by the chunk
|
| 36 |
+
#: boundary is heard whole in one of them.
|
| 37 |
+
CHUNK_OVERLAP_S = 1.0
|
| 38 |
+
#: Words per notes window when an imported transcript is turned into notes.
|
| 39 |
+
NOTES_WINDOW_WORDS = 600
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class JobCancelled(Exception):
|
| 43 |
+
pass
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class JobFailed(Exception):
|
| 47 |
+
"""A failure that retrying will not fix (unreadable file, missing asset)."""
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def wake() -> None:
|
| 51 |
+
if _wake is not None:
|
| 52 |
+
_wake.set()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def start_worker() -> bool:
|
| 56 |
+
"""Start the worker if the library and processing are on. Safe to call more than once."""
|
| 57 |
+
global _worker, _wake
|
| 58 |
+
cfg = load_library_config()
|
| 59 |
+
if not (cfg.enabled and cfg.processing) or os.getenv("MEETINGS_LIBRARY_WORKER", "1").strip().lower() in ("0", "false", "off"):
|
| 60 |
+
return False
|
| 61 |
+
if _worker is not None and not _worker.done():
|
| 62 |
+
return True
|
| 63 |
+
try:
|
| 64 |
+
loop = asyncio.get_running_loop()
|
| 65 |
+
except RuntimeError:
|
| 66 |
+
return False
|
| 67 |
+
_wake = asyncio.Event()
|
| 68 |
+
_worker = loop.create_task(_loop())
|
| 69 |
+
return True
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
async def stop_worker() -> None:
|
| 73 |
+
global _worker
|
| 74 |
+
if _worker is not None:
|
| 75 |
+
_worker.cancel()
|
| 76 |
+
try:
|
| 77 |
+
await _worker
|
| 78 |
+
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
| 79 |
+
pass
|
| 80 |
+
_worker = None
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
async def _loop() -> None:
|
| 84 |
+
db.ensure_migrated()
|
| 85 |
+
while True:
|
| 86 |
+
try:
|
| 87 |
+
job = await asyncio.to_thread(db.lease_next_job)
|
| 88 |
+
except Exception: # noqa: BLE001 — a locked database is a reason to wait, not to die
|
| 89 |
+
log.exception("meetings library: could not lease a job")
|
| 90 |
+
job = None
|
| 91 |
+
if job is None:
|
| 92 |
+
assert _wake is not None
|
| 93 |
+
_wake.clear()
|
| 94 |
+
try:
|
| 95 |
+
await asyncio.wait_for(_wake.wait(), timeout=3.0)
|
| 96 |
+
except asyncio.TimeoutError:
|
| 97 |
+
pass
|
| 98 |
+
continue
|
| 99 |
+
await run_job(job)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
async def run_job(job: Dict[str, Any]) -> str:
|
| 103 |
+
"""Run one leased job to an end state. Returns that state."""
|
| 104 |
+
handlers = {"transcribe": _transcribe, "index": _index, "summarize": _summarize, "analyze": _analyze,
|
| 105 |
+
"layout": _layout}
|
| 106 |
+
handler = handlers.get(job["type"])
|
| 107 |
+
if handler is None:
|
| 108 |
+
db.update_job(job["id"], state="failed", error=f"unknown job type {job['type']}", finished=True)
|
| 109 |
+
return "failed"
|
| 110 |
+
try:
|
| 111 |
+
state = await handler(job) or "done"
|
| 112 |
+
db.update_job(job["id"], state=state, stage=state, progress=1.0 if state == "done" else None, finished=True)
|
| 113 |
+
return state
|
| 114 |
+
except JobCancelled:
|
| 115 |
+
db.update_job(job["id"], state="cancelled", stage="cancelled", finished=True)
|
| 116 |
+
if job["type"] == "transcribe":
|
| 117 |
+
db.update_entry(job["meeting_id"], transcript_state="none")
|
| 118 |
+
return "cancelled"
|
| 119 |
+
except Exception as exc: # noqa: BLE001
|
| 120 |
+
message = str(exc) or type(exc).__name__
|
| 121 |
+
log.warning("meetings library: job %s (%s) failed: %s", job["id"], job["type"], message)
|
| 122 |
+
retry = not isinstance(exc, JobFailed) and int(job.get("attempt") or 1) < int(job.get("max_attempts") or 1)
|
| 123 |
+
if retry:
|
| 124 |
+
db.update_job(job["id"], state="queued", stage="retrying", error=message)
|
| 125 |
+
return "queued"
|
| 126 |
+
db.update_job(job["id"], state="failed", stage="failed", error=message, finished=True)
|
| 127 |
+
if job["type"] == "transcribe":
|
| 128 |
+
db.update_entry(job["meeting_id"], transcript_state="failed")
|
| 129 |
+
elif job["type"] == "summarize":
|
| 130 |
+
db.update_entry(job["meeting_id"], summary_state="failed")
|
| 131 |
+
return "failed"
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _check_cancel(job_id: str) -> None:
|
| 135 |
+
if db.job_state(job_id) == "cancelled":
|
| 136 |
+
raise JobCancelled()
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _primary_audio(meeting_id: str) -> Optional[Dict[str, Any]]:
|
| 140 |
+
for asset in db.assets_for(meeting_id):
|
| 141 |
+
if asset["kind"] == "audio" and asset.get("storage_ref"):
|
| 142 |
+
return asset
|
| 143 |
+
return None
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _live_meeting_running() -> bool:
|
| 147 |
+
try:
|
| 148 |
+
from .. import session as session_mod # noqa: PLC0415
|
| 149 |
+
|
| 150 |
+
return any(getattr(s, "state", None) == session_mod.MeetingState.LIVE
|
| 151 |
+
for s in list(session_mod._SESSIONS.values()))
|
| 152 |
+
except Exception: # noqa: BLE001
|
| 153 |
+
return False
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def stt_setup_hint() -> Dict[str, Any]:
|
| 157 |
+
"""Why transcription cannot run here, from the same probe the status route uses."""
|
| 158 |
+
try:
|
| 159 |
+
from ..routes import stt_capability # noqa: PLC0415
|
| 160 |
+
|
| 161 |
+
cap = stt_capability()
|
| 162 |
+
except Exception as exc: # noqa: BLE001
|
| 163 |
+
cap = {"available": False, "hint": f"Speech providers unavailable: {exc}"}
|
| 164 |
+
return {"available": bool(cap.get("available")), "provider": cap.get("provider"),
|
| 165 |
+
"hint": cap.get("hint"), "offer_remote": bool(cap.get("offer_remote"))}
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _get_provider():
|
| 169 |
+
from ...voice.providers import get_meeting_stt_provider # noqa: PLC0415
|
| 170 |
+
|
| 171 |
+
return get_meeting_stt_provider()
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
async def _transcribe(job: Dict[str, Any]) -> str:
|
| 175 |
+
meeting_id = job["meeting_id"]
|
| 176 |
+
job_id = job["id"]
|
| 177 |
+
asset = _primary_audio(meeting_id)
|
| 178 |
+
if asset is None:
|
| 179 |
+
raise JobFailed("This meeting has no recording to transcribe.")
|
| 180 |
+
path = media_root() / asset["storage_ref"]
|
| 181 |
+
if not path.is_file():
|
| 182 |
+
raise JobFailed("The meeting's recording is missing from HomePilot's storage.")
|
| 183 |
+
|
| 184 |
+
db.update_job(job_id, stage="validating", progress=0.0, lease_s=600)
|
| 185 |
+
info = await asyncio.to_thread(media.probe, path)
|
| 186 |
+
if not info.get("ok"):
|
| 187 |
+
raise JobFailed(info.get("error") or "The recording could not be read.")
|
| 188 |
+
duration_s = max(0.1, (info.get("duration_ms") or 0) / 1000.0)
|
| 189 |
+
|
| 190 |
+
level = await asyncio.to_thread(media.loudness, path)
|
| 191 |
+
if media.is_silent(level):
|
| 192 |
+
db.update_entry(meeting_id, transcript_state="no_speech")
|
| 193 |
+
db.add_warning(meeting_id, f"No speech detected — the recording is silent (loudest moment {level.get('max_db')} dB).")
|
| 194 |
+
db.update_job(job_id, diagnostic={"silent": True, **level})
|
| 195 |
+
return "done"
|
| 196 |
+
|
| 197 |
+
provider = _get_provider()
|
| 198 |
+
if not getattr(provider, "available", False):
|
| 199 |
+
hint = stt_setup_hint()
|
| 200 |
+
db.update_entry(meeting_id, transcript_state="needs_setup")
|
| 201 |
+
db.update_job(job_id, stage="needs_setup", diagnostic=hint,
|
| 202 |
+
error=hint.get("hint") or "No local speech-to-text model is installed.")
|
| 203 |
+
return "needs_setup"
|
| 204 |
+
|
| 205 |
+
db.update_entry(meeting_id, transcript_state="transcribing")
|
| 206 |
+
cfg = load_library_config()
|
| 207 |
+
chunk_s = float(cfg.stt_chunk_s)
|
| 208 |
+
chunks = max(1, math.ceil(duration_s / chunk_s))
|
| 209 |
+
segments: List[Dict[str, Any]] = []
|
| 210 |
+
last_end_ms = -1
|
| 211 |
+
started = time.time()
|
| 212 |
+
for index in range(chunks):
|
| 213 |
+
_check_cancel(job_id)
|
| 214 |
+
while _live_meeting_running(): # a live meeting has the model first
|
| 215 |
+
db.update_job(job_id, stage="waiting for live meeting", lease_s=600)
|
| 216 |
+
await asyncio.sleep(5)
|
| 217 |
+
_check_cancel(job_id)
|
| 218 |
+
start_s = index * chunk_s
|
| 219 |
+
length_s = min(chunk_s + CHUNK_OVERLAP_S, duration_s - start_s)
|
| 220 |
+
if length_s <= 0.05:
|
| 221 |
+
break
|
| 222 |
+
db.update_job(job_id, stage="decoding", progress=index / chunks, lease_s=900)
|
| 223 |
+
wav = await asyncio.to_thread(media.decode_chunk, path, start_s=start_s, length_s=length_s)
|
| 224 |
+
db.update_job(job_id, stage="transcribing", progress=(index + 0.2) / chunks, lease_s=900)
|
| 225 |
+
spans = await provider.transcribe_segments(wav, fmt="wav", duration_s=length_s)
|
| 226 |
+
for span in spans or []:
|
| 227 |
+
text = (span.get("text") or "").strip()
|
| 228 |
+
if not text:
|
| 229 |
+
continue
|
| 230 |
+
t0 = int(round((float(span.get("t0") or 0.0) + start_s) * 1000))
|
| 231 |
+
t1_raw = span.get("t1")
|
| 232 |
+
t1 = None if t1_raw is None else int(round((float(t1_raw) + start_s) * 1000))
|
| 233 |
+
# The overlap: a line the previous chunk already heard is not written twice.
|
| 234 |
+
if t0 < last_end_ms - 200:
|
| 235 |
+
continue
|
| 236 |
+
segments.append({"t0_ms": t0, "t1_ms": t1, "speaker": None, "text": text,
|
| 237 |
+
"conf": span.get("conf")})
|
| 238 |
+
last_end_ms = max(last_end_ms, t1 if t1 is not None else t0)
|
| 239 |
+
db.update_job(job_id, progress=(index + 1) / chunks,
|
| 240 |
+
diagnostic={"chunks": chunks, "done": index + 1, "segments": len(segments),
|
| 241 |
+
"provider": getattr(provider, "name", None),
|
| 242 |
+
"elapsed_s": round(time.time() - started, 1)})
|
| 243 |
+
|
| 244 |
+
_check_cancel(job_id)
|
| 245 |
+
db.update_job(job_id, stage="persisting")
|
| 246 |
+
if not segments:
|
| 247 |
+
db.update_entry(meeting_id, transcript_state="no_speech")
|
| 248 |
+
db.add_warning(meeting_id, "No speech was recognised in the recording.")
|
| 249 |
+
return "done"
|
| 250 |
+
timing = "measured" if getattr(provider, "supports_segments", False) else "approximate"
|
| 251 |
+
model_name = os.getenv("WHISPER_MODEL", "").strip() or getattr(provider, "model_name", None)
|
| 252 |
+
vid = db.create_version(meeting_id, segments, origin="stt", timing=timing,
|
| 253 |
+
provider=getattr(provider, "name", None), model=model_name,
|
| 254 |
+
note="Transcribed on this computer.")
|
| 255 |
+
db.publish_version(meeting_id, vid)
|
| 256 |
+
db.update_entry(meeting_id, transcript_state="ready" if timing == "measured" else "ready_warnings")
|
| 257 |
+
from .importer import _queue_followups # noqa: PLC0415
|
| 258 |
+
|
| 259 |
+
_queue_followups(meeting_id)
|
| 260 |
+
return "done"
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
async def _index(job: Dict[str, Any]) -> str:
|
| 264 |
+
from .. import retrieval # noqa: PLC0415
|
| 265 |
+
|
| 266 |
+
rows = await asyncio.to_thread(retrieval.index_meeting, job["meeting_id"])
|
| 267 |
+
db.update_job(job["id"], diagnostic={"indexed_rows": rows})
|
| 268 |
+
return "done"
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _model_call(options: Dict[str, Any]):
|
| 272 |
+
from ..notes_engine import call_model # noqa: PLC0415
|
| 273 |
+
|
| 274 |
+
target = options.get("model_target") or {}
|
| 275 |
+
|
| 276 |
+
async def call(messages, **kw):
|
| 277 |
+
return await call_model(messages, provider=target.get("provider", ""), model=target.get("model", ""),
|
| 278 |
+
base_url=target.get("base_url", ""), **kw)
|
| 279 |
+
|
| 280 |
+
return call
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _only_outline(document: Dict[str, Any]) -> bool:
|
| 284 |
+
"""True for the document minutes writes when no model answered (its first line says so)."""
|
| 285 |
+
text = (document.get("text") or "").lstrip()
|
| 286 |
+
return bool(document.get("degraded")) and text.startswith("*No language model was reachable")
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
async def _summarize(job: Dict[str, Any]) -> str:
|
| 290 |
+
"""Minutes, then decisions and action items — if a model answers. Never fakes either."""
|
| 291 |
+
from .. import minutes as minutes_mod # noqa: PLC0415
|
| 292 |
+
from .. import store # noqa: PLC0415
|
| 293 |
+
from ..notes_engine import NotesEngine # noqa: PLC0415
|
| 294 |
+
|
| 295 |
+
meeting_id = job["meeting_id"]
|
| 296 |
+
options = job.get("options") or {}
|
| 297 |
+
call = _model_call(options)
|
| 298 |
+
outputs = (options.get("outputs") or {})
|
| 299 |
+
db.update_job(job["id"], stage="summarising", progress=0.1, lease_s=1800)
|
| 300 |
+
opts = None
|
| 301 |
+
style = outputs.get("style")
|
| 302 |
+
length = outputs.get("length")
|
| 303 |
+
if style or length:
|
| 304 |
+
opts = minutes_mod.options_from({"style": style or "minutes", "length": length or "standard"})
|
| 305 |
+
document = await minutes_mod.autogenerate(meeting_id, call=call, options=opts)
|
| 306 |
+
if document is None or _only_outline(document):
|
| 307 |
+
# An outline assembled from the transcript is kept (the Summary tab shows it as one),
|
| 308 |
+
# but it is not a summary, and the meeting must not say it has one.
|
| 309 |
+
db.update_entry(meeting_id, summary_state="unavailable")
|
| 310 |
+
db.update_job(job["id"], diagnostic={"reason": "No language model answered, or there is no transcript."})
|
| 311 |
+
_queue_analysis(meeting_id, options)
|
| 312 |
+
return "done"
|
| 313 |
+
|
| 314 |
+
if outputs.get("actions", True) and not store.get_notes(meeting_id):
|
| 315 |
+
db.update_job(job["id"], stage="notes", progress=0.6)
|
| 316 |
+
engine = NotesEngine(meeting_id, call=call, max_words=NOTES_WINDOW_WORDS)
|
| 317 |
+
window: List[Dict[str, Any]] = []
|
| 318 |
+
words = 0
|
| 319 |
+
for seg in store.get_segments(meeting_id):
|
| 320 |
+
window.append(seg)
|
| 321 |
+
words += len((seg.get("text") or "").split())
|
| 322 |
+
if words >= NOTES_WINDOW_WORDS:
|
| 323 |
+
engine.add(window)
|
| 324 |
+
await engine.run(force=True)
|
| 325 |
+
window, words = [], 0
|
| 326 |
+
if window:
|
| 327 |
+
engine.add(window)
|
| 328 |
+
await engine.run(force=True)
|
| 329 |
+
db.update_entry(meeting_id, summary_state="ready")
|
| 330 |
+
_queue_analysis(meeting_id, options)
|
| 331 |
+
return "done"
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def _queue_analysis(meeting_id: str, options: Dict[str, Any]) -> Optional[str]:
|
| 335 |
+
"""After a summary, the typed analysis — only with ``MEETINGS_INTELLIGENCE`` on. The
|
| 336 |
+
model target the summary used is carried along so both talk to the same model."""
|
| 337 |
+
if not load_library_config().intelligence:
|
| 338 |
+
return None
|
| 339 |
+
jid = db.create_job(meeting_id, "analyze", max_attempts=1,
|
| 340 |
+
options={"model_target": (options or {}).get("model_target") or {}})
|
| 341 |
+
wake()
|
| 342 |
+
return jid
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def _active_version_id(meeting_id: str) -> Optional[str]:
|
| 346 |
+
for version in db.list_versions(meeting_id):
|
| 347 |
+
if version.get("active"):
|
| 348 |
+
return version.get("id")
|
| 349 |
+
return None
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
async def _analyze(job: Dict[str, Any]) -> str:
|
| 353 |
+
"""The typed analysis, the visual page and the suggested questions for one meeting.
|
| 354 |
+
|
| 355 |
+
Runs on the model lane the summary used; with no model it still finishes, with an
|
| 356 |
+
extractive analysis that says so. ``ValueError("no_transcript")`` is a permanent failure
|
| 357 |
+
for this transcript, not something a retry fixes.
|
| 358 |
+
"""
|
| 359 |
+
from ..intelligence import service as intelligence # noqa: PLC0415
|
| 360 |
+
|
| 361 |
+
meeting_id = job["meeting_id"]
|
| 362 |
+
options = job.get("options") or {}
|
| 363 |
+
cfg = load_library_config()
|
| 364 |
+
call = _model_call(options) if cfg.processing else None
|
| 365 |
+
db.update_job(job["id"], stage="analysing", progress=0.05, lease_s=1800)
|
| 366 |
+
|
| 367 |
+
def progress(stage: str, fraction: float) -> None:
|
| 368 |
+
db.update_job(job["id"], stage=stage, progress=fraction, lease_s=1800)
|
| 369 |
+
|
| 370 |
+
try:
|
| 371 |
+
result = await intelligence.run_analysis(
|
| 372 |
+
meeting_id, call=call, model=(options.get("model_target") or {}).get("model"),
|
| 373 |
+
transcript_version_id=_active_version_id(meeting_id), progress=progress,
|
| 374 |
+
)
|
| 375 |
+
except ValueError as exc:
|
| 376 |
+
if str(exc) == "no_transcript":
|
| 377 |
+
raise JobFailed("There is no transcript to analyse yet.") from exc
|
| 378 |
+
raise
|
| 379 |
+
report = (result.get("report") or {})
|
| 380 |
+
retitled = _maybe_retitle(meeting_id, result) if cfg.auto_title else None
|
| 381 |
+
db.update_job(job["id"], diagnostic={"degraded": result.get("degraded"), "kept": report.get("kept"),
|
| 382 |
+
"dropped": report.get("dropped"), "analysis_id": result.get("id"),
|
| 383 |
+
"retitled": retitled})
|
| 384 |
+
return "done"
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
# A recorder default or the library's own fallback: a keyword followed by nothing but dates,
|
| 388 |
+
# times and separators — "Imported meeting · Oct 10, 12:31", "Recording 12", "Meeting".
|
| 389 |
+
_GENERIC_TITLE = re.compile(
|
| 390 |
+
r"^(?:imported meeting|new recording|recording|transcript|meeting|untitled|audio|rec)"
|
| 391 |
+
r"(?:[\s·._\-:∶,/]*(?:\d+|[a-z]{3,9}\.? \d{1,2}|\d{1,2}:\d{2}|am|pm))*$", re.IGNORECASE)
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def generic_title(title: str) -> bool:
|
| 395 |
+
"""True for a title that names nothing: empty, only a timestamp (a recorder's file name),
|
| 396 |
+
or a default such as "Imported meeting · Oct 10, 12:31"."""
|
| 397 |
+
from .adapters import display_title, time_from_name # noqa: PLC0415
|
| 398 |
+
|
| 399 |
+
text = " ".join((title or "").split())
|
| 400 |
+
if not text:
|
| 401 |
+
return True
|
| 402 |
+
if time_from_name(text) and display_title(text, fallback_prefix="~time-only~").startswith("~time-only~"):
|
| 403 |
+
return True
|
| 404 |
+
return bool(_GENERIC_TITLE.match(text))
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def _maybe_retitle(meeting_id: str, result: Dict[str, Any]) -> Optional[str]:
|
| 408 |
+
"""Give a meeting whose title names nothing the title its analysis found. Only after a
|
| 409 |
+
model answered (an extractive analysis keeps the recorded title), only over a generic
|
| 410 |
+
title, never over one a person typed. Returns the new title, or None."""
|
| 411 |
+
from .. import store as ms_store # noqa: PLC0415
|
| 412 |
+
|
| 413 |
+
analysis = result.get("analysis") or {}
|
| 414 |
+
new = " ".join(str(analysis.get("title") or "").split())[:120].strip(" .")
|
| 415 |
+
if result.get("degraded") or not new or generic_title(new):
|
| 416 |
+
return None
|
| 417 |
+
meeting = ms_store.get_meeting(meeting_id) or {}
|
| 418 |
+
old = str(meeting.get("title") or "")
|
| 419 |
+
if not generic_title(old) or new.lower() == old.strip().lower():
|
| 420 |
+
return None
|
| 421 |
+
ms_store.update_meeting(meeting_id, {"title": new})
|
| 422 |
+
log.info("meetings: %s titled from its analysis: %r -> %r", meeting_id, old, new)
|
| 423 |
+
return new
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
async def _layout(job: Dict[str, Any]) -> str:
|
| 427 |
+
"""Write the Executive Brief again for the latest analysis, without re-extracting."""
|
| 428 |
+
from ..intelligence import service as intelligence # noqa: PLC0415
|
| 429 |
+
|
| 430 |
+
options = job.get("options") or {}
|
| 431 |
+
call = _model_call(options) if load_library_config().processing else None
|
| 432 |
+
db.update_job(job["id"], stage="writing the brief", progress=0.2, lease_s=900)
|
| 433 |
+
try:
|
| 434 |
+
result = await intelligence.recompose(job["meeting_id"], call=call)
|
| 435 |
+
except ValueError as exc:
|
| 436 |
+
if str(exc) == "no_analysis":
|
| 437 |
+
raise JobFailed("There is no analysis to lay out yet.") from exc
|
| 438 |
+
raise
|
| 439 |
+
brief = ((result.get("visual") or {}).get("brief") or {})
|
| 440 |
+
db.update_job(job["id"], diagnostic={"polished": brief.get("polished"), "source_count": brief.get("source_count")})
|
| 441 |
+
return "done"
|
backend/app/meetingsense/library/media.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audio helpers for imported meetings — probing, silence detection, decoding, waveforms.
|
| 2 |
+
|
| 3 |
+
FFmpeg does the decoding (HomePilot already needs it for Creator Studio), always on a copy
|
| 4 |
+
the library owns and never on the source file. Every call has a timeout and none of them
|
| 5 |
+
raises into a request: a file FFmpeg cannot read is reported as unreadable, which is an
|
| 6 |
+
answer the import preview can show.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import shutil
|
| 13 |
+
import subprocess
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Any, Dict, List, Optional
|
| 17 |
+
|
| 18 |
+
AUDIO_EXTENSIONS = (".mp3", ".m4a", ".wav", ".ogg", ".oga", ".opus", ".flac", ".webm", ".aac", ".mp4")
|
| 19 |
+
|
| 20 |
+
#: Content signatures — the extension is a hint, these are the check.
|
| 21 |
+
_MAGIC = (
|
| 22 |
+
(b"ID3", "audio/mpeg"),
|
| 23 |
+
(b"\xff\xfb", "audio/mpeg"),
|
| 24 |
+
(b"\xff\xf3", "audio/mpeg"),
|
| 25 |
+
(b"\xff\xf2", "audio/mpeg"),
|
| 26 |
+
(b"OggS", "audio/ogg"),
|
| 27 |
+
(b"fLaC", "audio/flac"),
|
| 28 |
+
(b"\x1a\x45\xdf\xa3", "audio/webm"),
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
#: Loudest moment below this means nobody spoke: a recorder left on in a quiet room, or a
|
| 32 |
+
#: file that is technically audio and contains nothing. Not transcribed; reported as such.
|
| 33 |
+
SILENCE_MAX_DB = -55.0
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def sniff_audio(head: bytes) -> Optional[str]:
|
| 37 |
+
"""The audio MIME type a file's first bytes prove, or None."""
|
| 38 |
+
for magic, mime in _MAGIC:
|
| 39 |
+
if head.startswith(magic):
|
| 40 |
+
return mime
|
| 41 |
+
if head[:4] == b"RIFF" and head[8:12] == b"WAVE":
|
| 42 |
+
return "audio/wav"
|
| 43 |
+
if head[4:8] == b"ftyp":
|
| 44 |
+
return "audio/mp4"
|
| 45 |
+
if len(head) >= 2 and head[0] == 0xFF and (head[1] & 0xF6) == 0xF0:
|
| 46 |
+
return "audio/aac"
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def ffmpeg_available() -> bool:
|
| 51 |
+
return bool(shutil.which("ffmpeg") and shutil.which("ffprobe"))
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _run(args: List[str], *, timeout: float, input_bytes: Optional[bytes] = None) -> subprocess.CompletedProcess:
|
| 55 |
+
return subprocess.run(args, input=input_bytes, capture_output=True, timeout=timeout, check=False)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def probe(path: Path) -> Dict[str, Any]:
|
| 59 |
+
"""Duration, codec, rate and channels as FFprobe measures them (not as metadata claims)."""
|
| 60 |
+
if not shutil.which("ffprobe"):
|
| 61 |
+
return {"ok": False, "error": "FFmpeg is not installed on this computer."}
|
| 62 |
+
try:
|
| 63 |
+
res = _run(
|
| 64 |
+
["ffprobe", "-v", "error", "-show_entries",
|
| 65 |
+
"format=duration,bit_rate:stream=codec_type,codec_name,sample_rate,channels",
|
| 66 |
+
"-of", "json", str(path)],
|
| 67 |
+
timeout=30,
|
| 68 |
+
)
|
| 69 |
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
| 70 |
+
return {"ok": False, "error": f"Could not read the audio ({type(exc).__name__})."}
|
| 71 |
+
if res.returncode != 0:
|
| 72 |
+
return {"ok": False, "error": "This file is not audio FFmpeg can read."}
|
| 73 |
+
try:
|
| 74 |
+
body = json.loads(res.stdout or b"{}")
|
| 75 |
+
except ValueError:
|
| 76 |
+
return {"ok": False, "error": "This file is not audio FFmpeg can read."}
|
| 77 |
+
streams = [s for s in body.get("streams") or [] if s.get("codec_type") == "audio"]
|
| 78 |
+
if not streams:
|
| 79 |
+
return {"ok": False, "error": "The file has no audio track."}
|
| 80 |
+
stream = streams[0]
|
| 81 |
+
try:
|
| 82 |
+
duration_ms = int(round(float((body.get("format") or {}).get("duration") or 0) * 1000))
|
| 83 |
+
except ValueError:
|
| 84 |
+
duration_ms = 0
|
| 85 |
+
return {
|
| 86 |
+
"ok": True,
|
| 87 |
+
"duration_ms": duration_ms,
|
| 88 |
+
"codec": stream.get("codec_name"),
|
| 89 |
+
"sample_rate": int(stream.get("sample_rate") or 0) or None,
|
| 90 |
+
"channels": stream.get("channels"),
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def loudness(path: Path) -> Dict[str, Optional[float]]:
|
| 95 |
+
"""Mean and peak level in dB (FFmpeg ``volumedetect``)."""
|
| 96 |
+
if not shutil.which("ffmpeg"):
|
| 97 |
+
return {"mean_db": None, "max_db": None}
|
| 98 |
+
try:
|
| 99 |
+
res = _run(["ffmpeg", "-hide_banner", "-nostats", "-i", str(path), "-af", "volumedetect",
|
| 100 |
+
"-vn", "-f", "null", "-"], timeout=600)
|
| 101 |
+
except (OSError, subprocess.TimeoutExpired):
|
| 102 |
+
return {"mean_db": None, "max_db": None}
|
| 103 |
+
text = (res.stderr or b"").decode("utf-8", errors="replace")
|
| 104 |
+
|
| 105 |
+
def grab(key: str) -> Optional[float]:
|
| 106 |
+
for line in text.splitlines():
|
| 107 |
+
if f"{key}:" in line:
|
| 108 |
+
try:
|
| 109 |
+
return float(line.split(f"{key}:")[1].strip().split()[0])
|
| 110 |
+
except (IndexError, ValueError):
|
| 111 |
+
return None
|
| 112 |
+
return None
|
| 113 |
+
|
| 114 |
+
return {"mean_db": grab("mean_volume"), "max_db": grab("max_volume")}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def is_silent(level: Dict[str, Optional[float]]) -> bool:
|
| 118 |
+
peak = level.get("max_db")
|
| 119 |
+
return peak is not None and peak < SILENCE_MAX_DB
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def decode_chunk(path: Path, *, start_s: float, length_s: float) -> bytes:
|
| 123 |
+
"""One stretch of the recording as 16 kHz mono 16-bit WAV — what speech models expect.
|
| 124 |
+
|
| 125 |
+
Resampled from the library's copy; the original file is never rewritten.
|
| 126 |
+
"""
|
| 127 |
+
res = _run(
|
| 128 |
+
["ffmpeg", "-hide_banner", "-nostats", "-loglevel", "error", "-ss", f"{max(0.0, start_s):.3f}",
|
| 129 |
+
"-t", f"{max(0.1, length_s):.3f}", "-i", str(path), "-vn", "-ac", "1", "-ar", "16000",
|
| 130 |
+
"-sample_fmt", "s16", "-f", "wav", "-"],
|
| 131 |
+
timeout=max(120.0, length_s * 2),
|
| 132 |
+
)
|
| 133 |
+
if res.returncode != 0:
|
| 134 |
+
raise RuntimeError((res.stderr or b"").decode("utf-8", errors="replace").strip()[:300] or "decode failed")
|
| 135 |
+
return res.stdout
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def peaks(path: Path, *, bars: int = 400) -> List[float]:
|
| 139 |
+
"""A waveform: ``bars`` values 0..1 from 8 kHz mono PCM. Empty when it cannot be read."""
|
| 140 |
+
if not shutil.which("ffmpeg"):
|
| 141 |
+
return []
|
| 142 |
+
try:
|
| 143 |
+
res = _run(["ffmpeg", "-hide_banner", "-nostats", "-loglevel", "error", "-i", str(path), "-vn",
|
| 144 |
+
"-ac", "1", "-ar", "8000", "-f", "s16le", "-"], timeout=600)
|
| 145 |
+
except (OSError, subprocess.TimeoutExpired):
|
| 146 |
+
return []
|
| 147 |
+
data = res.stdout or b""
|
| 148 |
+
count = len(data) // 2
|
| 149 |
+
if res.returncode != 0 or count == 0:
|
| 150 |
+
return []
|
| 151 |
+
import array # noqa: PLC0415
|
| 152 |
+
|
| 153 |
+
samples = array.array("h")
|
| 154 |
+
samples.frombytes(data[: count * 2])
|
| 155 |
+
if sys.byteorder == "big": # pragma: no cover — little-endian PCM from FFmpeg
|
| 156 |
+
samples.byteswap()
|
| 157 |
+
step = max(1, count // bars)
|
| 158 |
+
out: List[float] = []
|
| 159 |
+
for i in range(0, count, step):
|
| 160 |
+
window = samples[i:i + step]
|
| 161 |
+
if not window:
|
| 162 |
+
break
|
| 163 |
+
out.append(max(abs(min(window)), abs(max(window))) / 32768.0)
|
| 164 |
+
if len(out) >= bars:
|
| 165 |
+
break
|
| 166 |
+
top = max(out) if out else 0.0
|
| 167 |
+
# Normalised for display, but a silent file stays flat rather than being blown up to noise.
|
| 168 |
+
if top > 0.02:
|
| 169 |
+
out = [round(v / top, 3) for v in out]
|
| 170 |
+
else:
|
| 171 |
+
out = [round(v, 3) for v in out]
|
| 172 |
+
return out
|
backend/app/meetingsense/library/parsers.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reading transcripts people bring in — SRT, WebVTT, plain text / Markdown, JSON.
|
| 2 |
+
|
| 3 |
+
Each parser returns a :class:`Parsed` with the segments it found and *how much it knows*:
|
| 4 |
+
``timing`` is ``imported`` when the file carried timestamps and ``none`` when it did not. A
|
| 5 |
+
transcript without times is still a transcript — it is stored, searched and summarised — but
|
| 6 |
+
nothing pretends its lines happened at a particular second.
|
| 7 |
+
|
| 8 |
+
Speakers are kept exactly as written. Nothing here invents a name for an unlabelled line.
|
| 9 |
+
|
| 10 |
+
An empty result is reported as empty. A JSON file whose ``Result_info`` is ``[]`` (an
|
| 11 |
+
Insta360 export with no recognised speech) parses to zero segments and the importer says
|
| 12 |
+
"Needs transcription" — never "Transcribed".
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import re
|
| 19 |
+
from dataclasses import dataclass, field
|
| 20 |
+
from typing import Any, Dict, Iterable, List, Optional
|
| 21 |
+
|
| 22 |
+
Segment = Dict[str, Any]
|
| 23 |
+
|
| 24 |
+
TEXT_EXTENSIONS = (".txt", ".md", ".markdown")
|
| 25 |
+
TRANSCRIPT_EXTENSIONS = (".srt", ".vtt", ".json") + TEXT_EXTENSIONS
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class Parsed:
|
| 30 |
+
segments: List[Segment] = field(default_factory=list)
|
| 31 |
+
timing: str = "none" # "imported" when times came from the file
|
| 32 |
+
format: str = ""
|
| 33 |
+
warnings: List[str] = field(default_factory=list)
|
| 34 |
+
|
| 35 |
+
@property
|
| 36 |
+
def usable(self) -> bool:
|
| 37 |
+
return any((s.get("text") or "").strip() for s in self.segments)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
_TS = re.compile(r"(?:(\d{1,2}):)?(\d{1,2}):(\d{2})(?:[.,](\d{1,3}))?")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def parse_timestamp(value: str) -> Optional[int]:
|
| 44 |
+
"""``01:02:03,456`` / ``02:03.4`` / ``1:02:03`` → milliseconds, or None."""
|
| 45 |
+
m = _TS.fullmatch(value.strip())
|
| 46 |
+
if not m:
|
| 47 |
+
return None
|
| 48 |
+
h, mi, s, frac = m.groups()
|
| 49 |
+
ms = int((frac or "0").ljust(3, "0")[:3])
|
| 50 |
+
return ((int(h or 0) * 60 + int(mi)) * 60 + int(s)) * 1000 + ms
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _clean(text: str) -> str:
|
| 54 |
+
text = re.sub(r"<[^>]+>", "", text) # VTT voice/style tags
|
| 55 |
+
return re.sub(r"\s+", " ", text).strip()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
_SPEAKER_PREFIX = re.compile(r"^([A-Z][\w .'\-]{0,40}?|Speaker\s*\d+|SPEAKER_\d+)\s*:\s+(.+)$")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _split_speaker(text: str):
|
| 62 |
+
m = _SPEAKER_PREFIX.match(text)
|
| 63 |
+
if m and len(m.group(1).split()) <= 4:
|
| 64 |
+
return m.group(1).strip(), m.group(2).strip()
|
| 65 |
+
return None, text
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def parse_srt(raw: str) -> Parsed:
|
| 69 |
+
out = Parsed(format="srt", timing="imported")
|
| 70 |
+
blocks = re.split(r"\n\s*\n", raw.replace("\r\n", "\n").replace("\r", "\n").strip())
|
| 71 |
+
for block in blocks:
|
| 72 |
+
lines = [ln for ln in block.split("\n") if ln.strip()]
|
| 73 |
+
if not lines:
|
| 74 |
+
continue
|
| 75 |
+
if lines[0].strip().isdigit():
|
| 76 |
+
lines = lines[1:]
|
| 77 |
+
if not lines or "-->" not in lines[0]:
|
| 78 |
+
continue
|
| 79 |
+
start, _, end = lines[0].partition("-->")
|
| 80 |
+
t0 = parse_timestamp(start)
|
| 81 |
+
t1 = parse_timestamp(end.split()[0] if end.split() else "")
|
| 82 |
+
text = _clean(" ".join(lines[1:]))
|
| 83 |
+
if t0 is None or not text:
|
| 84 |
+
continue
|
| 85 |
+
speaker, text = _split_speaker(text)
|
| 86 |
+
out.segments.append({"t0_ms": t0, "t1_ms": t1, "speaker": speaker, "text": text, "conf": None})
|
| 87 |
+
return out
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def parse_vtt(raw: str) -> Parsed:
|
| 91 |
+
out = Parsed(format="vtt", timing="imported")
|
| 92 |
+
text = raw.replace("\r\n", "\n").replace("\r", "\n")
|
| 93 |
+
if text.startswith(""):
|
| 94 |
+
text = text[1:]
|
| 95 |
+
for block in re.split(r"\n\s*\n", text.strip()):
|
| 96 |
+
lines = [ln for ln in block.split("\n") if ln.strip()]
|
| 97 |
+
if not lines or lines[0].startswith(("WEBVTT", "NOTE", "STYLE", "REGION")):
|
| 98 |
+
continue
|
| 99 |
+
idx = next((i for i, ln in enumerate(lines) if "-->" in ln), None)
|
| 100 |
+
if idx is None:
|
| 101 |
+
continue
|
| 102 |
+
start, _, rest = lines[idx].partition("-->")
|
| 103 |
+
t0 = parse_timestamp(start)
|
| 104 |
+
t1 = parse_timestamp(rest.split()[0] if rest.split() else "")
|
| 105 |
+
body = " ".join(lines[idx + 1:])
|
| 106 |
+
voice = re.match(r"<v(?:\.[\w.]+)?\s+([^>]+)>", body)
|
| 107 |
+
speaker = voice.group(1).strip() if voice else None
|
| 108 |
+
body = _clean(body)
|
| 109 |
+
if not speaker:
|
| 110 |
+
speaker, body = _split_speaker(body)
|
| 111 |
+
if t0 is None or not body:
|
| 112 |
+
continue
|
| 113 |
+
out.segments.append({"t0_ms": t0, "t1_ms": t1, "speaker": speaker, "text": body, "conf": None})
|
| 114 |
+
return out
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# "[00:01:23] Alice: hello", "00:01:23 - Alice: hello", "Alice (01:23): hello", "(01:23) hello"
|
| 118 |
+
_LINE_TIMED = (
|
| 119 |
+
re.compile(r"^\[?(\d{1,2}:\d{2}(?::\d{2})?(?:[.,]\d{1,3})?)\]?\s*[-–—]?\s*(.*)$"),
|
| 120 |
+
re.compile(r"^(.{1,40}?)\s*[\[(](\d{1,2}:\d{2}(?::\d{2})?)[\])]\s*:?\s*(.*)$"),
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def parse_text(raw: str, *, fmt: str = "txt") -> Parsed:
|
| 125 |
+
"""Plain text or Markdown. Timestamps at the start of a line are read; others are not."""
|
| 126 |
+
out = Parsed(format=fmt)
|
| 127 |
+
timed = 0
|
| 128 |
+
paragraph: List[str] = []
|
| 129 |
+
|
| 130 |
+
def flush():
|
| 131 |
+
if paragraph:
|
| 132 |
+
text = _clean(" ".join(paragraph))
|
| 133 |
+
if text:
|
| 134 |
+
speaker, text = _split_speaker(text)
|
| 135 |
+
out.segments.append({"t0_ms": 0, "t1_ms": None, "speaker": speaker, "text": text, "conf": None})
|
| 136 |
+
paragraph.clear()
|
| 137 |
+
|
| 138 |
+
for line in raw.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
| 139 |
+
stripped = line.strip()
|
| 140 |
+
if fmt in ("md", "markdown"):
|
| 141 |
+
stripped = re.sub(r"^(#{1,6}\s+|[-*+]\s+|>\s+)", "", stripped)
|
| 142 |
+
stripped = stripped.replace("**", "").replace("__", "")
|
| 143 |
+
if not stripped:
|
| 144 |
+
flush()
|
| 145 |
+
continue
|
| 146 |
+
m = _LINE_TIMED[0].match(stripped)
|
| 147 |
+
if m and parse_timestamp(m.group(1)) is not None and m.group(2).strip():
|
| 148 |
+
flush()
|
| 149 |
+
speaker, text = _split_speaker(_clean(m.group(2)))
|
| 150 |
+
out.segments.append({"t0_ms": parse_timestamp(m.group(1)), "t1_ms": None,
|
| 151 |
+
"speaker": speaker, "text": text, "conf": None})
|
| 152 |
+
timed += 1
|
| 153 |
+
continue
|
| 154 |
+
m = _LINE_TIMED[1].match(stripped)
|
| 155 |
+
if m and parse_timestamp(m.group(2)) is not None and m.group(3).strip():
|
| 156 |
+
flush()
|
| 157 |
+
out.segments.append({"t0_ms": parse_timestamp(m.group(2)), "t1_ms": None,
|
| 158 |
+
"speaker": m.group(1).strip(), "text": _clean(m.group(3)), "conf": None})
|
| 159 |
+
timed += 1
|
| 160 |
+
continue
|
| 161 |
+
paragraph.append(stripped)
|
| 162 |
+
flush()
|
| 163 |
+
if timed and timed >= max(1, len(out.segments) // 2):
|
| 164 |
+
out.timing = "imported"
|
| 165 |
+
_fill_ends(out.segments)
|
| 166 |
+
return out
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _fill_ends(segments: List[Segment]) -> None:
|
| 170 |
+
"""An end is the next start when the file only gave starts. Last one stays unknown."""
|
| 171 |
+
for cur, nxt in zip(segments, segments[1:]):
|
| 172 |
+
if cur.get("t1_ms") is None and nxt.get("t0_ms") is not None and nxt["t0_ms"] >= cur["t0_ms"]:
|
| 173 |
+
cur["t1_ms"] = nxt["t0_ms"]
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _num_ms(value: Any, *, seconds: bool) -> Optional[int]:
|
| 177 |
+
if value is None or isinstance(value, bool):
|
| 178 |
+
return None
|
| 179 |
+
if isinstance(value, str):
|
| 180 |
+
ts = parse_timestamp(value)
|
| 181 |
+
if ts is not None:
|
| 182 |
+
return ts
|
| 183 |
+
try:
|
| 184 |
+
value = float(value)
|
| 185 |
+
except ValueError:
|
| 186 |
+
return None
|
| 187 |
+
if isinstance(value, (int, float)):
|
| 188 |
+
return int(round(value * 1000)) if seconds else int(round(value))
|
| 189 |
+
return None
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
_TEXT_KEYS = ("text", "content", "sentence", "transcript", "onebest", "words_text")
|
| 193 |
+
_START_KEYS = ("start", "begin", "bg", "start_time", "startTime", "t0", "from", "offset")
|
| 194 |
+
_END_KEYS = ("end", "ed", "end_time", "endTime", "t1", "to")
|
| 195 |
+
_SPEAKER_KEYS = ("speaker", "spk", "speaker_name", "speakerName", "speaker_id", "role", "name")
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _looks_seconds(items: List[Dict[str, Any]], key: str) -> bool:
|
| 199 |
+
"""Whisper writes seconds (floats); many exporters write milliseconds (big ints)."""
|
| 200 |
+
values = [i.get(key) for i in items if isinstance(i.get(key), (int, float))]
|
| 201 |
+
if not values:
|
| 202 |
+
return True
|
| 203 |
+
if any(isinstance(v, float) and not float(v).is_integer() for v in values):
|
| 204 |
+
return True
|
| 205 |
+
return max(values) < 36_000 # under ten hours if read as seconds
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _segments_from_items(items: Iterable[Any]) -> List[Segment]:
|
| 209 |
+
items = [i for i in items if isinstance(i, dict)]
|
| 210 |
+
if not items:
|
| 211 |
+
return []
|
| 212 |
+
start_key = next((k for k in _START_KEYS if any(k in i for i in items)), None)
|
| 213 |
+
end_key = next((k for k in _END_KEYS if any(k in i for i in items)), None)
|
| 214 |
+
seconds = _looks_seconds(items, start_key) if start_key else True
|
| 215 |
+
out: List[Segment] = []
|
| 216 |
+
for item in items:
|
| 217 |
+
text = next((item[k] for k in _TEXT_KEYS if isinstance(item.get(k), str) and item[k].strip()), "")
|
| 218 |
+
if not text and isinstance(item.get("words"), list):
|
| 219 |
+
text = " ".join(str(w.get("word") or w.get("text") or "") for w in item["words"] if isinstance(w, dict))
|
| 220 |
+
text = _clean(text)
|
| 221 |
+
if not text:
|
| 222 |
+
continue
|
| 223 |
+
speaker = next((str(item[k]).strip() for k in _SPEAKER_KEYS if item.get(k) not in (None, "")), None)
|
| 224 |
+
out.append({
|
| 225 |
+
"t0_ms": _num_ms(item.get(start_key), seconds=seconds) if start_key else None,
|
| 226 |
+
"t1_ms": _num_ms(item.get(end_key), seconds=seconds) if end_key else None,
|
| 227 |
+
"speaker": speaker,
|
| 228 |
+
"text": text,
|
| 229 |
+
"conf": item.get("confidence") if isinstance(item.get("confidence"), (int, float)) else None,
|
| 230 |
+
})
|
| 231 |
+
return out
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def parse_json(raw: str) -> Parsed:
|
| 235 |
+
out = Parsed(format="json")
|
| 236 |
+
try:
|
| 237 |
+
body = json.loads(raw)
|
| 238 |
+
except ValueError:
|
| 239 |
+
out.warnings.append("The JSON file could not be read.")
|
| 240 |
+
return out
|
| 241 |
+
items: Any = None
|
| 242 |
+
if isinstance(body, list):
|
| 243 |
+
items = body
|
| 244 |
+
elif isinstance(body, dict):
|
| 245 |
+
# Whisper verbose_json, generic {segments|utterances|transcript}, Insta360 AIRecord.
|
| 246 |
+
for key in ("segments", "utterances", "transcript", "results", "Result_info", "result_info", "sentences"):
|
| 247 |
+
value = body.get(key)
|
| 248 |
+
if isinstance(value, list):
|
| 249 |
+
items = value
|
| 250 |
+
break
|
| 251 |
+
if isinstance(value, dict) and isinstance(value.get("segments"), list):
|
| 252 |
+
items = value["segments"]
|
| 253 |
+
break
|
| 254 |
+
if items is None and isinstance(body.get("text"), str) and body["text"].strip():
|
| 255 |
+
out.segments = [{"t0_ms": 0, "t1_ms": None, "speaker": None, "text": _clean(body["text"]), "conf": None}]
|
| 256 |
+
return out
|
| 257 |
+
if items is None:
|
| 258 |
+
out.warnings.append("No transcript was found in the JSON file.")
|
| 259 |
+
return out
|
| 260 |
+
segments = _segments_from_items(items)
|
| 261 |
+
if segments and all(s["t0_ms"] is not None for s in segments):
|
| 262 |
+
out.timing = "imported"
|
| 263 |
+
_fill_ends(segments)
|
| 264 |
+
for s in segments:
|
| 265 |
+
if s["t0_ms"] is None:
|
| 266 |
+
s["t0_ms"] = 0
|
| 267 |
+
out.segments = segments
|
| 268 |
+
return out
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def parse_transcript(name: str, raw: bytes) -> Parsed:
|
| 272 |
+
"""Pick a parser by extension (the content is text in every case)."""
|
| 273 |
+
text = raw.decode("utf-8-sig", errors="replace")
|
| 274 |
+
lower = name.lower()
|
| 275 |
+
if lower.endswith(".srt"):
|
| 276 |
+
return parse_srt(text)
|
| 277 |
+
if lower.endswith(".vtt"):
|
| 278 |
+
return parse_vtt(text)
|
| 279 |
+
if lower.endswith(".json"):
|
| 280 |
+
return parse_json(text)
|
| 281 |
+
if lower.endswith((".md", ".markdown")):
|
| 282 |
+
return parse_text(text, fmt="md")
|
| 283 |
+
return parse_text(text, fmt="txt")
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def monotonic(segments: List[Segment]) -> List[Segment]:
|
| 287 |
+
"""Sort by start and clamp ends that run backwards; keeps every line."""
|
| 288 |
+
ordered = sorted(segments, key=lambda s: (int(s.get("t0_ms") or 0)))
|
| 289 |
+
for seg in ordered:
|
| 290 |
+
if seg.get("t1_ms") is not None and seg["t1_ms"] < seg["t0_ms"]:
|
| 291 |
+
seg["t1_ms"] = seg["t0_ms"]
|
| 292 |
+
return ordered
|
backend/app/meetingsense/library/projection.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The Meetings Library — one list over every kind of meeting HomePilot has.
|
| 2 |
+
|
| 3 |
+
Projected, not copied: a meeting recorded live, an imported recording, a synced Insta360
|
| 4 |
+
folder and a scheduled meeting are all rows in ``ms_meetings`` (with the library's companion
|
| 5 |
+
row beside the later three), and a Teams room is read from the Teams store as it is. Each
|
| 6 |
+
appears once, under a stable ``library_id``, and opens in the place it belongs to.
|
| 7 |
+
|
| 8 |
+
Teams rooms are persona conversations, not recordings: they are listed as such (no audio,
|
| 9 |
+
transcript-only), only once a room has had a real exchange, and they open in Teams.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
from typing import Any, Dict, List, Optional, Sequence
|
| 16 |
+
|
| 17 |
+
from . import db
|
| 18 |
+
|
| 19 |
+
PAGE_MAX = 60
|
| 20 |
+
#: A Teams room becomes a library item once it has had at least this many messages — a
|
| 21 |
+
#: room that was created and never used is not a meeting.
|
| 22 |
+
TEAMS_MIN_MESSAGES = 4
|
| 23 |
+
|
| 24 |
+
TABS = ("all", "project", "recorded", "imported", "unsorted", "processing")
|
| 25 |
+
SORTS = ("newest", "oldest", "title", "duration")
|
| 26 |
+
_PROCESSING = {"queued", "transcribing", "needs_setup", "failed"}
|
| 27 |
+
|
| 28 |
+
_SOURCE_LABELS = {
|
| 29 |
+
"insta360": "Insta360 Import",
|
| 30 |
+
"upload": "Upload",
|
| 31 |
+
"folder": "Folder Sync",
|
| 32 |
+
"transcript": "Transcript Only",
|
| 33 |
+
"teams_room": "Teams room",
|
| 34 |
+
"scheduled": "Scheduled",
|
| 35 |
+
}
|
| 36 |
+
_LIVE_SOURCE_LABELS = {
|
| 37 |
+
"zoom": "Zoom", "meet": "Google Meet", "teams": "Microsoft Teams", "webex": "Webex",
|
| 38 |
+
"slack": "Slack", "discord": "Discord", "local": "This computer",
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _projects_by_id() -> Dict[str, Dict[str, Any]]:
|
| 43 |
+
try:
|
| 44 |
+
from ... import projects as projects_mod # noqa: PLC0415
|
| 45 |
+
|
| 46 |
+
out = {p.get("id"): p for p in projects_mod.list_all_projects() if p.get("id")}
|
| 47 |
+
for p in projects_mod.get_example_projects():
|
| 48 |
+
out.setdefault(p["id"], p)
|
| 49 |
+
return out
|
| 50 |
+
except Exception: # noqa: BLE001
|
| 51 |
+
return {}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _latest_notes(meeting_ids: Sequence[str]) -> Dict[str, Dict[str, Any]]:
|
| 55 |
+
out: Dict[str, Dict[str, Any]] = {}
|
| 56 |
+
if not meeting_ids:
|
| 57 |
+
return out
|
| 58 |
+
con = db._connect()
|
| 59 |
+
try:
|
| 60 |
+
for i in range(0, len(meeting_ids), 500):
|
| 61 |
+
part = list(meeting_ids[i:i + 500])
|
| 62 |
+
rows = con.execute(
|
| 63 |
+
"SELECT n.meeting_id, n.json FROM ms_notes n JOIN (SELECT meeting_id, MAX(version) v FROM ms_notes"
|
| 64 |
+
f" WHERE meeting_id IN ({','.join('?' * len(part))}) GROUP BY meeting_id) x"
|
| 65 |
+
" ON x.meeting_id = n.meeting_id AND x.v = n.version",
|
| 66 |
+
part,
|
| 67 |
+
).fetchall()
|
| 68 |
+
for r in rows:
|
| 69 |
+
try:
|
| 70 |
+
out[r["meeting_id"]] = json.loads(r["json"]) or {}
|
| 71 |
+
except (TypeError, ValueError):
|
| 72 |
+
pass
|
| 73 |
+
finally:
|
| 74 |
+
con.close()
|
| 75 |
+
return out
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _segment_counts() -> Dict[str, int]:
|
| 79 |
+
con = db._connect()
|
| 80 |
+
try:
|
| 81 |
+
return {r["meeting_id"]: r["c"] for r in con.execute(
|
| 82 |
+
"SELECT meeting_id, COUNT(*) c FROM ms_segments GROUP BY meeting_id").fetchall()}
|
| 83 |
+
finally:
|
| 84 |
+
con.close()
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _live_ids() -> set:
|
| 88 |
+
try:
|
| 89 |
+
from .. import session as session_mod # noqa: PLC0415
|
| 90 |
+
|
| 91 |
+
return {mid for mid, s in session_mod._SESSIONS.items()
|
| 92 |
+
if getattr(s, "state", None) == session_mod.MeetingState.LIVE}
|
| 93 |
+
except Exception: # noqa: BLE001
|
| 94 |
+
return set()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _summary_line(notes: Dict[str, Any], entry: Dict[str, Any]) -> str:
|
| 98 |
+
text = (entry or {}).get("description") or ""
|
| 99 |
+
if not text:
|
| 100 |
+
body = notes.get("notes") if isinstance(notes.get("notes"), dict) else notes
|
| 101 |
+
text = str((body or {}).get("summary") or "")
|
| 102 |
+
text = " ".join(text.split())
|
| 103 |
+
return text[:220]
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def meeting_items(viewer: str = "") -> List[Dict[str, Any]]:
|
| 107 |
+
"""Every MeetingSense / library meeting the viewer may see, as library items."""
|
| 108 |
+
con = db._connect()
|
| 109 |
+
try:
|
| 110 |
+
rows = [dict(r) for r in con.execute("SELECT * FROM ms_meetings").fetchall()]
|
| 111 |
+
finally:
|
| 112 |
+
con.close()
|
| 113 |
+
ids = [r["id"] for r in rows]
|
| 114 |
+
entries = db.entries_by_id(ids)
|
| 115 |
+
links = db.project_links(ids)
|
| 116 |
+
projects = _projects_by_id()
|
| 117 |
+
notes = _latest_notes(ids)
|
| 118 |
+
counts = _segment_counts()
|
| 119 |
+
live = _live_ids()
|
| 120 |
+
items: List[Dict[str, Any]] = []
|
| 121 |
+
for row in rows:
|
| 122 |
+
entry = entries.get(row["id"]) or {}
|
| 123 |
+
if entry.get("hidden_at"):
|
| 124 |
+
continue
|
| 125 |
+
owner = entry.get("owner_user_id") or ""
|
| 126 |
+
if viewer and owner and owner != viewer:
|
| 127 |
+
continue
|
| 128 |
+
items.append(_item(row, entry, links.get(row["id"], []), projects, notes.get(row["id"], {}),
|
| 129 |
+
counts.get(row["id"], 0), row["id"] in live))
|
| 130 |
+
return items
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _item(row: Dict[str, Any], entry: Dict[str, Any], links: List[Dict[str, Any]],
|
| 134 |
+
projects: Dict[str, Dict[str, Any]], notes: Dict[str, Any], segments: int, live: bool) -> Dict[str, Any]:
|
| 135 |
+
origin = entry.get("origin_type") or "meetingsense"
|
| 136 |
+
status = row.get("status") or ""
|
| 137 |
+
if origin == "meetingsense":
|
| 138 |
+
recording = "recording" if status in ("live", "suspended") else "none"
|
| 139 |
+
transcript = "live" if status in ("live", "suspended") else ("ready" if segments else "none")
|
| 140 |
+
source_kind = "meetingsense"
|
| 141 |
+
source_label = _LIVE_SOURCE_LABELS.get((row.get("source") or "").lower(), "HomePilot")
|
| 142 |
+
else:
|
| 143 |
+
recording = entry.get("recording_state") or "none"
|
| 144 |
+
transcript = entry.get("transcript_state") or "none"
|
| 145 |
+
source_kind = row.get("source") or ("scheduled" if origin == "scheduled" else "upload")
|
| 146 |
+
source_label = _SOURCE_LABELS.get(source_kind, source_kind.title())
|
| 147 |
+
project_list = []
|
| 148 |
+
for link in links:
|
| 149 |
+
p = projects.get(link["project_id"]) or {}
|
| 150 |
+
project_list.append({"id": link["project_id"], "name": p.get("name") or "Missing project",
|
| 151 |
+
"kind": link.get("kind"), "missing": not p})
|
| 152 |
+
category = entry.get("category")
|
| 153 |
+
if not category:
|
| 154 |
+
category = "Project Meeting" if project_list else ("Recorded" if origin == "meetingsense" else "Meeting")
|
| 155 |
+
started = row.get("started_at")
|
| 156 |
+
ended = row.get("ended_at")
|
| 157 |
+
duration_ms = entry.get("duration_ms")
|
| 158 |
+
if duration_ms is None and started and ended and ended > started:
|
| 159 |
+
duration_ms = int((ended - started) * 1000)
|
| 160 |
+
return {
|
| 161 |
+
"library_id": row["id"],
|
| 162 |
+
"meeting_id": row["id"],
|
| 163 |
+
"origin_type": origin,
|
| 164 |
+
"origin_id": row["id"],
|
| 165 |
+
"title": (row.get("title") or "").strip() or "Untitled meeting",
|
| 166 |
+
"description": _summary_line(notes, entry),
|
| 167 |
+
"category": category,
|
| 168 |
+
"started_at": started,
|
| 169 |
+
"ended_at": ended,
|
| 170 |
+
"scheduled_for": entry.get("scheduled_for"),
|
| 171 |
+
"duration_ms": duration_ms,
|
| 172 |
+
"projects": project_list,
|
| 173 |
+
"source": {"kind": source_kind, "label": source_label},
|
| 174 |
+
"media_present": bool(entry.get("media_present")),
|
| 175 |
+
"recording_state": recording,
|
| 176 |
+
"transcript_state": transcript,
|
| 177 |
+
"summary_state": entry.get("summary_state") or ("ready" if notes else "none"),
|
| 178 |
+
"segments": segments,
|
| 179 |
+
"conversation_id": row.get("conversation_id"),
|
| 180 |
+
"status": status,
|
| 181 |
+
"live": live,
|
| 182 |
+
"warnings": entry.get("warnings") or [],
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def teams_items() -> List[Dict[str, Any]]:
|
| 187 |
+
"""Teams rooms that held a real conversation — read-only, opened in Teams."""
|
| 188 |
+
try:
|
| 189 |
+
from ...teams import rooms as rooms_mod # noqa: PLC0415
|
| 190 |
+
|
| 191 |
+
rooms = rooms_mod.list_rooms()
|
| 192 |
+
except Exception: # noqa: BLE001
|
| 193 |
+
return []
|
| 194 |
+
out = []
|
| 195 |
+
for room in rooms:
|
| 196 |
+
msgs = room.get("messages") or []
|
| 197 |
+
if len(msgs) < TEAMS_MIN_MESSAGES:
|
| 198 |
+
continue
|
| 199 |
+
first = msgs[0].get("timestamp") or room.get("created_at")
|
| 200 |
+
last = msgs[-1].get("timestamp") or room.get("updated_at")
|
| 201 |
+
out.append({
|
| 202 |
+
"library_id": f"teams_room:{room['id']}",
|
| 203 |
+
"meeting_id": None,
|
| 204 |
+
"origin_type": "teams_room",
|
| 205 |
+
"origin_id": room["id"],
|
| 206 |
+
"title": room.get("name") or "Team meeting",
|
| 207 |
+
"description": " ".join(str(room.get("topic") or room.get("description") or "").split())[:220],
|
| 208 |
+
"category": "Team Meeting",
|
| 209 |
+
"started_at": first,
|
| 210 |
+
"ended_at": last,
|
| 211 |
+
"scheduled_for": None,
|
| 212 |
+
"duration_ms": None,
|
| 213 |
+
"projects": [],
|
| 214 |
+
"source": {"kind": "teams_room", "label": "Teams room"},
|
| 215 |
+
"media_present": False,
|
| 216 |
+
"recording_state": "none",
|
| 217 |
+
"transcript_state": "transcript_only",
|
| 218 |
+
"summary_state": "none",
|
| 219 |
+
"segments": len(msgs),
|
| 220 |
+
"conversation_id": None,
|
| 221 |
+
"status": room.get("status") or "",
|
| 222 |
+
"live": False,
|
| 223 |
+
"warnings": [],
|
| 224 |
+
})
|
| 225 |
+
return out
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _matches_query(item: Dict[str, Any], q: str, transcript_hits: set) -> bool:
|
| 229 |
+
if not q:
|
| 230 |
+
return True
|
| 231 |
+
hay = " ".join([
|
| 232 |
+
item.get("title") or "", item.get("description") or "", item.get("category") or "",
|
| 233 |
+
(item.get("source") or {}).get("label") or "", " ".join(p["name"] for p in item.get("projects") or []),
|
| 234 |
+
]).lower()
|
| 235 |
+
return all(word in hay for word in q.lower().split()) or (item.get("meeting_id") in transcript_hits)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _transcript_hits(q: str) -> set:
|
| 239 |
+
q = " ".join(q.split())
|
| 240 |
+
if len(q) < 3:
|
| 241 |
+
return set()
|
| 242 |
+
con = db._connect()
|
| 243 |
+
try:
|
| 244 |
+
return {r["meeting_id"] for r in con.execute(
|
| 245 |
+
"SELECT DISTINCT meeting_id FROM ms_segments WHERE text LIKE ? LIMIT 500", (f"%{q}%",)).fetchall()}
|
| 246 |
+
finally:
|
| 247 |
+
con.close()
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def _in_tab(item: Dict[str, Any], tab: str) -> bool:
|
| 251 |
+
if tab == "project":
|
| 252 |
+
return bool(item["projects"])
|
| 253 |
+
if tab == "recorded":
|
| 254 |
+
return item["origin_type"] in ("meetingsense",) or (item["origin_type"] == "scheduled" and item["media_present"])
|
| 255 |
+
if tab == "imported":
|
| 256 |
+
return item["origin_type"] in ("import", "sync")
|
| 257 |
+
if tab == "unsorted":
|
| 258 |
+
return not item["projects"] and item["origin_type"] != "teams_room"
|
| 259 |
+
if tab == "processing":
|
| 260 |
+
return item["transcript_state"] in _PROCESSING or item["summary_state"] == "pending"
|
| 261 |
+
return True
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def list_library(*, viewer: str = "", tab: str = "all", q: str = "", source: str = "", project_id: str = "",
|
| 265 |
+
state: str = "", date_from: Optional[float] = None, date_to: Optional[float] = None,
|
| 266 |
+
sort: str = "newest", cursor: str = "", limit: int = 24) -> Dict[str, Any]:
|
| 267 |
+
items = meeting_items(viewer) + teams_items()
|
| 268 |
+
counts = {t: sum(1 for i in items if _in_tab(i, t)) for t in TABS}
|
| 269 |
+
hits = _transcript_hits(q) if q else set()
|
| 270 |
+
shown = [
|
| 271 |
+
i for i in items
|
| 272 |
+
if _in_tab(i, tab if tab in TABS else "all")
|
| 273 |
+
and _matches_query(i, q, hits)
|
| 274 |
+
and (not source or i["source"]["kind"] == source)
|
| 275 |
+
and (not project_id or any(p["id"] == project_id for p in i["projects"]))
|
| 276 |
+
and (not state or i["transcript_state"] == state)
|
| 277 |
+
and (date_from is None or (i.get("started_at") or 0) >= date_from)
|
| 278 |
+
and (date_to is None or (i.get("started_at") or 0) <= date_to)
|
| 279 |
+
]
|
| 280 |
+
key = {
|
| 281 |
+
"oldest": lambda i: (i.get("started_at") or 0),
|
| 282 |
+
"title": lambda i: (i.get("title") or "").lower(),
|
| 283 |
+
"duration": lambda i: -(i.get("duration_ms") or 0),
|
| 284 |
+
}.get(sort)
|
| 285 |
+
if key is None:
|
| 286 |
+
shown.sort(key=lambda i: (i.get("started_at") or 0), reverse=True)
|
| 287 |
+
else:
|
| 288 |
+
shown.sort(key=key)
|
| 289 |
+
try:
|
| 290 |
+
offset = max(0, int(cursor or 0))
|
| 291 |
+
except ValueError:
|
| 292 |
+
offset = 0
|
| 293 |
+
limit = max(1, min(int(limit or 24), PAGE_MAX))
|
| 294 |
+
page = shown[offset:offset + limit]
|
| 295 |
+
nxt = str(offset + limit) if offset + limit < len(shown) else None
|
| 296 |
+
sources = sorted({(i["source"]["kind"], i["source"]["label"]) for i in items})
|
| 297 |
+
return {
|
| 298 |
+
"items": page,
|
| 299 |
+
"next_cursor": nxt,
|
| 300 |
+
"total": len(shown),
|
| 301 |
+
"counts": counts,
|
| 302 |
+
"facets": {"sources": [{"kind": k, "label": label} for k, label in sources],
|
| 303 |
+
"projects": [{"id": pid, "name": name} for pid, name in sorted(
|
| 304 |
+
{(p["id"], p["name"]) for i in items for p in i["projects"]}, key=lambda x: x[1].lower())]},
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def find_item(library_id: str, viewer: str = "") -> Optional[Dict[str, Any]]:
|
| 309 |
+
if library_id.startswith("teams_room:"):
|
| 310 |
+
return next((i for i in teams_items() if i["library_id"] == library_id), None)
|
| 311 |
+
for item in meeting_items(viewer):
|
| 312 |
+
if item["library_id"] == library_id:
|
| 313 |
+
return item
|
| 314 |
+
return None
|
backend/app/meetingsense/library/routes.py
ADDED
|
@@ -0,0 +1,977 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP routes for the Meetings Library (all additive, all under ``/v1/meetingsense``).
|
| 2 |
+
|
| 3 |
+
Registered **before** MeetingSense's own router: its ``GET /v1/meetingsense/{meeting_id}``
|
| 4 |
+
would otherwise read ``library`` or ``imports`` as a meeting id. Every existing route keeps
|
| 5 |
+
its path and its payload.
|
| 6 |
+
|
| 7 |
+
Gated by ``MEETINGS_LIBRARY_UPGRADE`` (default off) — with it off these answer 404 and the
|
| 8 |
+
legacy Meetings view is what the app shows. ``/library/status`` always answers, so the
|
| 9 |
+
frontend can ask which one to show.
|
| 10 |
+
|
| 11 |
+
Who may see what: imported, synced and scheduled meetings belong to the user who created
|
| 12 |
+
them (the data-layer rule HomePilot uses elsewhere); meetings recorded live are visible as
|
| 13 |
+
they always were.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import asyncio
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
import re
|
| 22 |
+
import shutil
|
| 23 |
+
import uuid
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Any, Dict, List, Optional
|
| 26 |
+
|
| 27 |
+
from fastapi import APIRouter, Cookie, File, Form, Header, HTTPException, Query, Request, UploadFile
|
| 28 |
+
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
| 29 |
+
|
| 30 |
+
from .. import store
|
| 31 |
+
from ..config import load_config
|
| 32 |
+
from . import db, importer, jobs, media, projection, sync
|
| 33 |
+
from .config import load_library_config, media_root
|
| 34 |
+
|
| 35 |
+
router = APIRouter(tags=["meetings-library"])
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _viewer(authorization: Optional[str], session: Optional[str]) -> str:
|
| 39 |
+
try:
|
| 40 |
+
from ...inventory import _resolve_inventory_user_id # noqa: PLC0415
|
| 41 |
+
|
| 42 |
+
return _resolve_inventory_user_id(authorization or "", session)
|
| 43 |
+
except Exception: # noqa: BLE001
|
| 44 |
+
return ""
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _require_enabled() -> None:
|
| 48 |
+
if not (load_config().enabled and load_library_config().enabled):
|
| 49 |
+
raise HTTPException(status_code=404, detail="not found")
|
| 50 |
+
db.ensure_migrated()
|
| 51 |
+
# Started lazily as well as at startup: a test client or a reload may skip startup.
|
| 52 |
+
jobs.start_worker()
|
| 53 |
+
sync.start_sync_loop()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _error(status: int, code: str, message: str, hint: str = "") -> HTTPException:
|
| 57 |
+
return HTTPException(status_code=status, detail={"code": code, "message": message, "hint": hint})
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _owned_entry(meeting_id: str, viewer: str) -> Dict[str, Any]:
|
| 61 |
+
"""The meeting, if it exists and this viewer may act on it (404 otherwise, never 403 —
|
| 62 |
+
a stranger must not learn which ids exist)."""
|
| 63 |
+
meeting = store.get_meeting(meeting_id)
|
| 64 |
+
if meeting is None:
|
| 65 |
+
raise HTTPException(status_code=404, detail="not found")
|
| 66 |
+
entry = db.get_entry(meeting_id) or {}
|
| 67 |
+
owner = entry.get("owner_user_id") or ""
|
| 68 |
+
# Fail closed: an owned meeting is never shown to an empty viewer. On a single-user
|
| 69 |
+
# install the viewer resolves to that user, so this only bites when several accounts
|
| 70 |
+
# exist and a request arrives with no token — which used to see everything.
|
| 71 |
+
if owner and (not viewer or owner != viewer):
|
| 72 |
+
raise HTTPException(status_code=404, detail="not found")
|
| 73 |
+
return {"meeting": meeting, "entry": entry}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _require_intelligence() -> None:
|
| 77 |
+
"""The analysis routes exist only with ``MEETINGS_INTELLIGENCE`` on (404 otherwise, like
|
| 78 |
+
every other gated route, so a caller cannot tell a flag from a missing id)."""
|
| 79 |
+
if not load_library_config().intelligence:
|
| 80 |
+
raise HTTPException(status_code=404, detail="not found")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ── status ──────────────────────────────────────────────────────────────────
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@router.get("/v1/meetingsense/library/status")
|
| 87 |
+
async def library_status() -> Dict[str, Any]:
|
| 88 |
+
cfg = load_library_config()
|
| 89 |
+
body: Dict[str, Any] = {**cfg.as_dict(), "enabled": bool(cfg.enabled and load_config().enabled),
|
| 90 |
+
"ffmpeg": media.ffmpeg_available()}
|
| 91 |
+
if body["enabled"]:
|
| 92 |
+
try:
|
| 93 |
+
body["stt"] = jobs.stt_setup_hint()
|
| 94 |
+
except Exception: # noqa: BLE001
|
| 95 |
+
body["stt"] = {"available": False}
|
| 96 |
+
return body
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ── library ─────────────────────────────────────────────────────────────────
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@router.get("/v1/meetingsense/library")
|
| 103 |
+
async def library_list(
|
| 104 |
+
tab: str = Query("all"), q: str = Query(""), source: str = Query(""), project_id: str = Query(""),
|
| 105 |
+
state: str = Query(""), date_from: Optional[float] = Query(None), date_to: Optional[float] = Query(None),
|
| 106 |
+
sort: str = Query("newest"), cursor: str = Query(""), limit: int = Query(24),
|
| 107 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 108 |
+
) -> Dict[str, Any]:
|
| 109 |
+
_require_enabled()
|
| 110 |
+
viewer = _viewer(authorization, homepilot_session)
|
| 111 |
+
return await asyncio.to_thread(
|
| 112 |
+
projection.list_library, viewer=viewer, tab=tab, q=q.strip(), source=source, project_id=project_id,
|
| 113 |
+
state=state, date_from=date_from, date_to=date_to, sort=sort, cursor=cursor, limit=limit,
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _public_asset(asset: Dict[str, Any]) -> Dict[str, Any]:
|
| 118 |
+
"""An asset as the browser sees it: provenance yes, server paths no."""
|
| 119 |
+
return {k: asset.get(k) for k in (
|
| 120 |
+
"id", "kind", "role", "original_name", "relative_path", "sha256", "mime", "size_bytes",
|
| 121 |
+
"media_duration_ms", "source_id", "origin_type", "created_at", "metadata",
|
| 122 |
+
)} | {"source_available": bool(asset.get("source_available"))}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@router.get("/v1/meetingsense/library/item/{library_id}")
|
| 126 |
+
async def library_item(
|
| 127 |
+
library_id: str,
|
| 128 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 129 |
+
) -> Dict[str, Any]:
|
| 130 |
+
"""Everything the Meeting Detail page shows, for any kind of library item."""
|
| 131 |
+
_require_enabled()
|
| 132 |
+
viewer = _viewer(authorization, homepilot_session)
|
| 133 |
+
item = await asyncio.to_thread(projection.find_item, library_id, viewer)
|
| 134 |
+
if item is None:
|
| 135 |
+
raise HTTPException(status_code=404, detail="not found")
|
| 136 |
+
if item["origin_type"] == "teams_room":
|
| 137 |
+
from ...teams import rooms as rooms_mod # noqa: PLC0415
|
| 138 |
+
|
| 139 |
+
room = rooms_mod.get_room(item["origin_id"]) or {}
|
| 140 |
+
msgs = room.get("messages") or []
|
| 141 |
+
return {"item": item, "teams_room": {
|
| 142 |
+
"id": room.get("id"), "name": room.get("name"), "topic": room.get("topic"),
|
| 143 |
+
"participants": room.get("participant_ids") or [], "message_count": len(msgs),
|
| 144 |
+
"messages": [{"speaker": m.get("sender_name"), "text": m.get("content"), "t": m.get("timestamp")}
|
| 145 |
+
for m in msgs[:200]],
|
| 146 |
+
}}
|
| 147 |
+
meeting_id = item["meeting_id"]
|
| 148 |
+
from .. import minutes as minutes_mod # noqa: PLC0415
|
| 149 |
+
|
| 150 |
+
entry = db.get_entry(meeting_id) or {}
|
| 151 |
+
return {
|
| 152 |
+
"item": item,
|
| 153 |
+
"meeting": store.get_meeting(meeting_id),
|
| 154 |
+
"entry": {k: entry.get(k) for k in ("origin_type", "category", "description", "scheduled_for",
|
| 155 |
+
"recording_plan", "outputs", "raw_metadata", "warnings")},
|
| 156 |
+
"segments": store.get_segments(meeting_id),
|
| 157 |
+
"keyframes": store.get_keyframes(meeting_id),
|
| 158 |
+
"notes": store.get_notes(meeting_id),
|
| 159 |
+
"summaries": minutes_mod.summaries(meeting_id),
|
| 160 |
+
"assets": [_public_asset(a) for a in db.assets_for(meeting_id)],
|
| 161 |
+
"jobs": db.list_jobs(meeting_id=meeting_id, limit=20),
|
| 162 |
+
"versions": db.list_versions(meeting_id),
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
_EDITABLE = ("title", "category", "description", "scheduled_for")
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
@router.patch("/v1/meetingsense/library/item/{meeting_id}")
|
| 170 |
+
async def library_update(
|
| 171 |
+
meeting_id: str, body: Dict[str, Any],
|
| 172 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 173 |
+
) -> Dict[str, Any]:
|
| 174 |
+
_require_enabled()
|
| 175 |
+
found = _owned_entry(meeting_id, _viewer(authorization, homepilot_session))
|
| 176 |
+
body = body or {}
|
| 177 |
+
if "title" in body:
|
| 178 |
+
title = str(body.get("title") or "").strip()
|
| 179 |
+
if not title:
|
| 180 |
+
raise _error(400, "empty_title", "A meeting needs a title.")
|
| 181 |
+
store.update_meeting(meeting_id, {"title": title[:200]})
|
| 182 |
+
entry_fields = {}
|
| 183 |
+
for key in ("category", "description"):
|
| 184 |
+
if key in body:
|
| 185 |
+
entry_fields[key] = (str(body.get(key) or "").strip() or None)
|
| 186 |
+
if "scheduled_for" in body and body["scheduled_for"] is not None:
|
| 187 |
+
entry_fields["scheduled_for"] = float(body["scheduled_for"])
|
| 188 |
+
if entry_fields:
|
| 189 |
+
if not found["entry"]:
|
| 190 |
+
db.ensure_entry(meeting_id)
|
| 191 |
+
db.update_entry(meeting_id, **entry_fields)
|
| 192 |
+
return {"ok": True, "item": projection.find_item(meeting_id, _viewer(authorization, homepilot_session))}
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@router.post("/v1/meetingsense/library/meetings")
|
| 196 |
+
async def library_create_meeting(
|
| 197 |
+
body: Dict[str, Any],
|
| 198 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 199 |
+
) -> JSONResponse:
|
| 200 |
+
"""A meeting that exists before its recording: "Schedule for later" or "External recorder".
|
| 201 |
+
|
| 202 |
+
Its recording arrives later — by upload or from a synced folder, matched by time and
|
| 203 |
+
confirmed when the match is not certain.
|
| 204 |
+
"""
|
| 205 |
+
_require_enabled()
|
| 206 |
+
viewer = _viewer(authorization, homepilot_session)
|
| 207 |
+
body = body or {}
|
| 208 |
+
title = str(body.get("title") or "").strip()
|
| 209 |
+
if not title:
|
| 210 |
+
raise _error(400, "empty_title", "A meeting needs a title.")
|
| 211 |
+
plan = str(body.get("recording_plan") or "external")
|
| 212 |
+
if plan not in ("live", "external", "hybrid", "later"):
|
| 213 |
+
raise _error(400, "bad_plan", "recording_plan must be live, external, hybrid or later.")
|
| 214 |
+
try:
|
| 215 |
+
when = float(body.get("scheduled_for")) if body.get("scheduled_for") is not None else None
|
| 216 |
+
except (TypeError, ValueError):
|
| 217 |
+
raise _error(400, "bad_time", "scheduled_for must be a Unix time in seconds.") from None
|
| 218 |
+
outputs = body.get("outputs") if isinstance(body.get("outputs"), dict) else None
|
| 219 |
+
mid = db.create_meeting_entry(
|
| 220 |
+
title=title[:200], origin_type="scheduled", owner_user_id=viewer, started_at=when, status="scheduled",
|
| 221 |
+
source="scheduled", project_id=str(body.get("project_id") or "").strip() or None,
|
| 222 |
+
category=str(body.get("category") or "").strip() or None,
|
| 223 |
+
description=str(body.get("description") or "").strip() or None, scheduled_for=when,
|
| 224 |
+
recording_plan=plan, recording_state="awaiting", transcript_state="none", outputs=outputs,
|
| 225 |
+
)
|
| 226 |
+
return JSONResponse(status_code=201, content={"ok": True, "meeting_id": mid,
|
| 227 |
+
"item": projection.find_item(mid, viewer)})
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
@router.delete("/v1/meetingsense/library/item/{meeting_id}")
|
| 231 |
+
async def library_remove(
|
| 232 |
+
meeting_id: str, mode: str = Query("remove"),
|
| 233 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 234 |
+
) -> Dict[str, Any]:
|
| 235 |
+
"""``remove`` hides the meeting from the library (reversible); ``delete`` deletes its data.
|
| 236 |
+
|
| 237 |
+
A full delete removes the meeting's rows, its transcript versions and the copies
|
| 238 |
+
HomePilot made. The originals in a synced folder are never touched.
|
| 239 |
+
"""
|
| 240 |
+
_require_enabled()
|
| 241 |
+
_owned_entry(meeting_id, _viewer(authorization, homepilot_session))
|
| 242 |
+
if mode == "remove":
|
| 243 |
+
db.ensure_entry(meeting_id)
|
| 244 |
+
import time as _t # noqa: PLC0415
|
| 245 |
+
|
| 246 |
+
db.update_entry(meeting_id, hidden_at=_t.time())
|
| 247 |
+
return {"ok": True, "mode": "remove"}
|
| 248 |
+
if mode != "delete":
|
| 249 |
+
raise _error(400, "bad_mode", "mode must be remove or delete.")
|
| 250 |
+
from .. import retention as retention_mod # noqa: PLC0415
|
| 251 |
+
from .. import session as session_mod # noqa: PLC0415
|
| 252 |
+
|
| 253 |
+
session = session_mod.get(meeting_id)
|
| 254 |
+
if session is not None:
|
| 255 |
+
try:
|
| 256 |
+
await session.stop()
|
| 257 |
+
except Exception: # noqa: BLE001
|
| 258 |
+
pass
|
| 259 |
+
session_mod.unregister(meeting_id)
|
| 260 |
+
for job in db.list_jobs(meeting_id=meeting_id, states=("queued", "running")):
|
| 261 |
+
db.update_job(job["id"], state="cancelled", finished=True)
|
| 262 |
+
library_rows = db.delete_library_rows(meeting_id)
|
| 263 |
+
result = retention_mod.delete_meeting(meeting_id) or {}
|
| 264 |
+
folder = media_root() / meeting_id
|
| 265 |
+
removed_files = 0
|
| 266 |
+
if folder.is_dir() and folder.resolve().parent == media_root().resolve():
|
| 267 |
+
removed_files = sum(1 for p in folder.rglob("*") if p.is_file())
|
| 268 |
+
shutil.rmtree(folder, ignore_errors=True)
|
| 269 |
+
return {"ok": True, "mode": "delete", "removed": {**result, **library_rows, "files": removed_files}}
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
@router.put("/v1/meetingsense/library/item/{meeting_id}/projects")
|
| 273 |
+
async def library_project_links(
|
| 274 |
+
meeting_id: str, body: Dict[str, Any],
|
| 275 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 276 |
+
) -> Dict[str, Any]:
|
| 277 |
+
"""Link a meeting to projects — the same meeting in each, never a copy."""
|
| 278 |
+
_require_enabled()
|
| 279 |
+
_owned_entry(meeting_id, _viewer(authorization, homepilot_session))
|
| 280 |
+
ids = body.get("project_ids") if isinstance(body, dict) else None
|
| 281 |
+
if not isinstance(ids, list):
|
| 282 |
+
raise _error(400, "bad_projects", "project_ids must be a list.")
|
| 283 |
+
return {"ok": True, "project_ids": db.set_project_links(meeting_id, [str(i) for i in ids])}
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
# ── media ───────────────────────────────────────────────────────────────────
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def _asset_path(meeting_id: str, asset_id: str, viewer: str) -> tuple:
|
| 290 |
+
_owned_entry(meeting_id, viewer)
|
| 291 |
+
asset = db.get_asset(asset_id)
|
| 292 |
+
if not asset or asset.get("meeting_id") != meeting_id or not asset.get("storage_ref"):
|
| 293 |
+
raise HTTPException(status_code=404, detail="not found")
|
| 294 |
+
root = media_root().resolve()
|
| 295 |
+
path = (root / asset["storage_ref"]).resolve()
|
| 296 |
+
if root not in path.parents or not path.is_file():
|
| 297 |
+
raise HTTPException(status_code=404, detail="not found")
|
| 298 |
+
return asset, path
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
_RANGE = re.compile(r"bytes=(\d*)-(\d*)$")
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
@router.get("/v1/meetingsense/library/item/{meeting_id}/media/{asset_id}")
|
| 305 |
+
async def library_media(
|
| 306 |
+
meeting_id: str, asset_id: str, request: Request, download: int = Query(0),
|
| 307 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 308 |
+
token: str = Query(""),
|
| 309 |
+
) -> Response:
|
| 310 |
+
"""Stream a meeting's file, with byte ranges so the player can seek."""
|
| 311 |
+
_require_enabled()
|
| 312 |
+
viewer = _viewer(authorization or (f"Bearer {token}" if token else None), homepilot_session)
|
| 313 |
+
asset, path = _asset_path(meeting_id, asset_id, viewer)
|
| 314 |
+
size = path.stat().st_size
|
| 315 |
+
mime = asset.get("mime") or "application/octet-stream"
|
| 316 |
+
headers = {"Accept-Ranges": "bytes", "Cache-Control": "private, max-age=3600"}
|
| 317 |
+
if download:
|
| 318 |
+
safe = asset["original_name"].encode("ascii", "replace").decode().replace('"', "")
|
| 319 |
+
headers["Content-Disposition"] = f'attachment; filename="{safe}"'
|
| 320 |
+
rng = _RANGE.match(request.headers.get("range", "").strip())
|
| 321 |
+
start, end = 0, size - 1
|
| 322 |
+
status = 200
|
| 323 |
+
if rng and size:
|
| 324 |
+
a, b = rng.groups()
|
| 325 |
+
if a == "" and b:
|
| 326 |
+
start = max(0, size - int(b))
|
| 327 |
+
else:
|
| 328 |
+
start = int(a or 0)
|
| 329 |
+
end = min(size - 1, int(b)) if b else size - 1
|
| 330 |
+
if start > end or start >= size:
|
| 331 |
+
return Response(status_code=416, headers={"Content-Range": f"bytes */{size}"})
|
| 332 |
+
status = 206
|
| 333 |
+
headers["Content-Range"] = f"bytes {start}-{end}/{size}"
|
| 334 |
+
length = end - start + 1 if size else 0
|
| 335 |
+
headers["Content-Length"] = str(length)
|
| 336 |
+
|
| 337 |
+
def body():
|
| 338 |
+
with open(path, "rb") as fh:
|
| 339 |
+
fh.seek(start)
|
| 340 |
+
remaining = length
|
| 341 |
+
while remaining > 0:
|
| 342 |
+
chunk = fh.read(min(256 * 1024, remaining))
|
| 343 |
+
if not chunk:
|
| 344 |
+
break
|
| 345 |
+
remaining -= len(chunk)
|
| 346 |
+
yield chunk
|
| 347 |
+
|
| 348 |
+
return StreamingResponse(body(), status_code=status, media_type=mime, headers=headers)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
@router.get("/v1/meetingsense/library/item/{meeting_id}/media/{asset_id}/peaks")
|
| 352 |
+
async def library_peaks(
|
| 353 |
+
meeting_id: str, asset_id: str,
|
| 354 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 355 |
+
) -> Dict[str, Any]:
|
| 356 |
+
_require_enabled()
|
| 357 |
+
asset, path = _asset_path(meeting_id, asset_id, _viewer(authorization, homepilot_session))
|
| 358 |
+
meta = asset.get("metadata") or {}
|
| 359 |
+
if isinstance(meta.get("peaks"), list):
|
| 360 |
+
return {"peaks": meta["peaks"]}
|
| 361 |
+
values = await asyncio.to_thread(media.peaks, path)
|
| 362 |
+
db.update_asset(asset_id, metadata={**meta, "peaks": values})
|
| 363 |
+
return {"peaks": values}
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
# ── imports ─────────────────────────────────────────────────────────────────
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def _safe_name(name: str) -> str:
|
| 370 |
+
name = os.path.basename((name or "").replace("\\", "/")).strip() or "upload"
|
| 371 |
+
return re.sub(r"[\x00-\x1f]", "", name)[:200]
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
@router.post("/v1/meetingsense/imports/preview")
|
| 375 |
+
async def import_preview(
|
| 376 |
+
files: List[UploadFile] = File(...), project_id: str = Form(""),
|
| 377 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 378 |
+
) -> Dict[str, Any]:
|
| 379 |
+
"""Look at uploaded files and say what importing them would do. Changes nothing."""
|
| 380 |
+
_require_enabled()
|
| 381 |
+
viewer = _viewer(authorization, homepilot_session)
|
| 382 |
+
cfg = load_library_config()
|
| 383 |
+
max_bytes = cfg.max_upload_mb * 1024 * 1024
|
| 384 |
+
import_id = uuid.uuid4().hex
|
| 385 |
+
staging = importer.staging_dir(import_id)
|
| 386 |
+
raw_dir, tree = staging / "raw", staging / "tree"
|
| 387 |
+
raw_dir.mkdir(parents=True, exist_ok=True)
|
| 388 |
+
tree.mkdir(parents=True, exist_ok=True)
|
| 389 |
+
try:
|
| 390 |
+
for upload in files:
|
| 391 |
+
name = _safe_name(upload.filename or "")
|
| 392 |
+
target = raw_dir / name
|
| 393 |
+
written = 0
|
| 394 |
+
with open(target, "wb") as out:
|
| 395 |
+
while True:
|
| 396 |
+
chunk = await upload.read(1024 * 1024)
|
| 397 |
+
if not chunk:
|
| 398 |
+
break
|
| 399 |
+
written += len(chunk)
|
| 400 |
+
if written > max_bytes:
|
| 401 |
+
raise _error(413, "too_large", f"{name} is larger than {cfg.max_upload_mb} MB.")
|
| 402 |
+
out.write(chunk)
|
| 403 |
+
await upload.close()
|
| 404 |
+
with open(target, "rb") as fh:
|
| 405 |
+
head = fh.read(8)
|
| 406 |
+
if head.startswith(b"PK\x03\x04") or name.lower().endswith(".zip"):
|
| 407 |
+
from .adapters import UnsafeArchive, extract_zip # noqa: PLC0415
|
| 408 |
+
|
| 409 |
+
try:
|
| 410 |
+
await asyncio.to_thread(extract_zip, target, tree / Path(name).stem, max_total_bytes=max_bytes * 2)
|
| 411 |
+
except UnsafeArchive as exc:
|
| 412 |
+
raise _error(422, "unsafe_archive", str(exc)) from None
|
| 413 |
+
else:
|
| 414 |
+
shutil.copy2(target, tree / name)
|
| 415 |
+
manifest = await asyncio.to_thread(importer.preview_tree, tree)
|
| 416 |
+
except HTTPException:
|
| 417 |
+
shutil.rmtree(staging, ignore_errors=True)
|
| 418 |
+
raise
|
| 419 |
+
except Exception as exc: # noqa: BLE001
|
| 420 |
+
shutil.rmtree(staging, ignore_errors=True)
|
| 421 |
+
raise _error(422, "unreadable", f"The files could not be read: {exc}") from None
|
| 422 |
+
manifest["project_id"] = project_id.strip() or None
|
| 423 |
+
if not manifest["items"]:
|
| 424 |
+
shutil.rmtree(staging, ignore_errors=True)
|
| 425 |
+
raise _error(422, "nothing_found", "No recordings or transcripts were found in those files.",
|
| 426 |
+
"Supported: MP3, M4A, WAV, OGG, FLAC, WebM; TXT, MD, SRT, VTT, JSON; ZIP archives; Insta360 AIRecord folders.")
|
| 427 |
+
db.create_import(owner_user_id=viewer, source_id="upload", manifest=manifest, staging_dir=str(staging),
|
| 428 |
+
import_id=import_id)
|
| 429 |
+
return {"import_id": import_id, **manifest}
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
@router.post("/v1/meetingsense/imports")
|
| 433 |
+
async def import_commit(
|
| 434 |
+
body: Dict[str, Any],
|
| 435 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 436 |
+
) -> JSONResponse:
|
| 437 |
+
"""Do what the preview showed, as confirmed. Idempotent per ``idempotency_key``."""
|
| 438 |
+
_require_enabled()
|
| 439 |
+
viewer = _viewer(authorization, homepilot_session)
|
| 440 |
+
import_id = str((body or {}).get("import_id") or "")
|
| 441 |
+
key = str((body or {}).get("idempotency_key") or "").strip() or None
|
| 442 |
+
if key:
|
| 443 |
+
done = db.import_by_key(viewer, key)
|
| 444 |
+
if done and done["status"] == "committed":
|
| 445 |
+
return JSONResponse(status_code=200, content={"import_id": done["id"], "status": "committed",
|
| 446 |
+
**(done["manifest"].get("result") or {})})
|
| 447 |
+
row = db.get_import(import_id)
|
| 448 |
+
if row is None or (row.get("owner_user_id") or "") != (viewer or ""):
|
| 449 |
+
raise HTTPException(status_code=404, detail="not found")
|
| 450 |
+
if row["status"] == "committed":
|
| 451 |
+
return JSONResponse(status_code=200, content={"import_id": import_id, "status": "committed",
|
| 452 |
+
**(row["manifest"].get("result") or {})})
|
| 453 |
+
if row["status"] == "committing":
|
| 454 |
+
raise _error(409, "in_progress", "This import is already being committed.")
|
| 455 |
+
db.update_import(import_id, status="committing", idempotency_key=key)
|
| 456 |
+
try:
|
| 457 |
+
result = await asyncio.to_thread(
|
| 458 |
+
importer.commit_import, row, (body or {}).get("choices") or [], owner_user_id=viewer,
|
| 459 |
+
project_id=str((body or {}).get("project_id") or row["manifest"].get("project_id") or "").strip() or None,
|
| 460 |
+
)
|
| 461 |
+
except Exception as exc: # noqa: BLE001
|
| 462 |
+
db.update_import(import_id, status="failed", error=str(exc))
|
| 463 |
+
raise _error(500, "import_failed", f"The import failed: {exc}") from None
|
| 464 |
+
manifest = {**row["manifest"], "result": result}
|
| 465 |
+
db.update_import(import_id, status="committed", manifest=manifest, committed=True)
|
| 466 |
+
importer.cleanup_staging(import_id)
|
| 467 |
+
jobs.wake()
|
| 468 |
+
return JSONResponse(status_code=202, content={"import_id": import_id, "status": "committed", **result})
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
@router.get("/v1/meetingsense/imports/{import_id}")
|
| 472 |
+
async def import_get(
|
| 473 |
+
import_id: str,
|
| 474 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 475 |
+
) -> Dict[str, Any]:
|
| 476 |
+
_require_enabled()
|
| 477 |
+
row = db.get_import(import_id)
|
| 478 |
+
if row is None or (row.get("owner_user_id") or "") != (_viewer(authorization, homepilot_session) or ""):
|
| 479 |
+
raise HTTPException(status_code=404, detail="not found")
|
| 480 |
+
return {"import_id": row["id"], "status": row["status"], "error": row.get("error"), **row["manifest"]}
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
# ── folder sync ─────────────────────────────────────────────────────────────
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def _refused(exc: "sync.SyncRefused") -> HTTPException:
|
| 487 |
+
status = {"sync_not_enabled": 403, "not_allowed": 403, "not_found": 422, "not_a_folder": 422,
|
| 488 |
+
"missing_path": 400}.get(exc.code, 400)
|
| 489 |
+
return _error(status, exc.code, str(exc), exc.hint)
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def _visible(owner: str, viewer: str) -> bool:
|
| 493 |
+
return not viewer or not owner or owner == viewer
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
def _owned_source(source_id: str, viewer: str) -> Dict[str, Any]:
|
| 497 |
+
source = db.get_source(source_id)
|
| 498 |
+
if source is None or not _visible(source.get("owner_user_id") or "", viewer):
|
| 499 |
+
raise HTTPException(status_code=404, detail="not found")
|
| 500 |
+
return source
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
@router.get("/v1/meetingsense/sync-sources")
|
| 504 |
+
async def sync_list(
|
| 505 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 506 |
+
) -> Dict[str, Any]:
|
| 507 |
+
_require_enabled()
|
| 508 |
+
cfg = load_library_config()
|
| 509 |
+
viewer = _viewer(authorization, homepilot_session)
|
| 510 |
+
sources = [s for s in db.list_sources() if _visible(s.get("owner_user_id") or "", viewer)]
|
| 511 |
+
for s_ in sources:
|
| 512 |
+
s_.pop("resolved_path", None)
|
| 513 |
+
return {"sources": sources, "enabled": bool(cfg.sync_roots), "allowed_roots": list(cfg.sync_roots),
|
| 514 |
+
"suggestion": cfg.as_dict()["sync_suggestion"], "interval_s": cfg.sync_interval_s}
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
@router.post("/v1/meetingsense/sync-sources")
|
| 518 |
+
async def sync_add(
|
| 519 |
+
body: Dict[str, Any],
|
| 520 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 521 |
+
) -> JSONResponse:
|
| 522 |
+
_require_enabled()
|
| 523 |
+
try:
|
| 524 |
+
source = sync.add_source(str((body or {}).get("path") or ""),
|
| 525 |
+
display_name=str((body or {}).get("display_name") or ""),
|
| 526 |
+
owner_user_id=_viewer(authorization, homepilot_session),
|
| 527 |
+
project_id=str((body or {}).get("project_id") or "").strip() or None)
|
| 528 |
+
except sync.SyncRefused as exc:
|
| 529 |
+
raise _refused(exc) from None
|
| 530 |
+
source = dict(source)
|
| 531 |
+
source.pop("resolved_path", None)
|
| 532 |
+
return JSONResponse(status_code=201, content={"source": source})
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
@router.post("/v1/meetingsense/sync-sources/{source_id}/scan")
|
| 536 |
+
async def sync_scan(
|
| 537 |
+
source_id: str,
|
| 538 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 539 |
+
) -> JSONResponse:
|
| 540 |
+
_require_enabled()
|
| 541 |
+
_owned_source(source_id, _viewer(authorization, homepilot_session))
|
| 542 |
+
try:
|
| 543 |
+
counts = await sync.scan_async(source_id)
|
| 544 |
+
except sync.SyncRefused as exc:
|
| 545 |
+
raise _refused(exc) from None
|
| 546 |
+
source = db.get_source(source_id) or {}
|
| 547 |
+
source.pop("resolved_path", None)
|
| 548 |
+
return JSONResponse(status_code=202, content={"counts": counts, "source": source})
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
@router.patch("/v1/meetingsense/sync-sources/{source_id}")
|
| 552 |
+
async def sync_update(
|
| 553 |
+
source_id: str, body: Dict[str, Any],
|
| 554 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 555 |
+
) -> Dict[str, Any]:
|
| 556 |
+
_require_enabled()
|
| 557 |
+
_owned_source(source_id, _viewer(authorization, homepilot_session))
|
| 558 |
+
fields = {}
|
| 559 |
+
if "enabled" in (body or {}):
|
| 560 |
+
fields["enabled"] = bool(body["enabled"])
|
| 561 |
+
if "display_name" in (body or {}):
|
| 562 |
+
fields["display_name"] = str(body["display_name"] or "").strip() or "Folder"
|
| 563 |
+
if "project_id" in (body or {}):
|
| 564 |
+
fields["project_id"] = str(body["project_id"] or "").strip() or None
|
| 565 |
+
db.update_source(source_id, **fields)
|
| 566 |
+
source = db.get_source(source_id) or {}
|
| 567 |
+
source.pop("resolved_path", None)
|
| 568 |
+
return {"source": source}
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
@router.delete("/v1/meetingsense/sync-sources/{source_id}")
|
| 572 |
+
async def sync_delete(
|
| 573 |
+
source_id: str,
|
| 574 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 575 |
+
) -> Dict[str, Any]:
|
| 576 |
+
"""Disconnect the folder. Meetings already imported from it stay in the library."""
|
| 577 |
+
_require_enabled()
|
| 578 |
+
_owned_source(source_id, _viewer(authorization, homepilot_session))
|
| 579 |
+
db.delete_source(source_id)
|
| 580 |
+
return {"ok": True}
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
# ── jobs and transcripts ────────────────────────────────────────────────────
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
@router.get("/v1/meetingsense/jobs")
|
| 587 |
+
async def jobs_list(
|
| 588 |
+
meeting_id: str = Query(""), active: bool = Query(False), limit: int = Query(50),
|
| 589 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 590 |
+
) -> Dict[str, Any]:
|
| 591 |
+
_require_enabled()
|
| 592 |
+
viewer = _viewer(authorization, homepilot_session)
|
| 593 |
+
states = ("queued", "running", "needs_setup", "failed") if active else None
|
| 594 |
+
rows = db.list_jobs(meeting_id=meeting_id or None, states=states, limit=max(1, min(limit, 200)))
|
| 595 |
+
owners = {mid: (e.get("owner_user_id") or "") for mid, e in db.entries_by_id({j["meeting_id"] for j in rows}).items()}
|
| 596 |
+
rows = [j for j in rows if _visible(owners.get(j["meeting_id"], ""), viewer)]
|
| 597 |
+
titles: Dict[str, str] = {}
|
| 598 |
+
for job in rows:
|
| 599 |
+
if job["meeting_id"] not in titles:
|
| 600 |
+
m = store.get_meeting(job["meeting_id"])
|
| 601 |
+
titles[job["meeting_id"]] = (m or {}).get("title") or "Meeting"
|
| 602 |
+
job["meeting_title"] = titles[job["meeting_id"]]
|
| 603 |
+
return {"jobs": rows, "stt": jobs.stt_setup_hint()}
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
@router.post("/v1/meetingsense/jobs/{job_id}/cancel")
|
| 607 |
+
async def jobs_cancel(
|
| 608 |
+
job_id: str,
|
| 609 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 610 |
+
) -> Dict[str, Any]:
|
| 611 |
+
_require_enabled()
|
| 612 |
+
job = db.get_job(job_id)
|
| 613 |
+
if job is None:
|
| 614 |
+
raise HTTPException(status_code=404, detail="not found")
|
| 615 |
+
_owned_entry(job["meeting_id"], _viewer(authorization, homepilot_session))
|
| 616 |
+
if job["state"] in ("queued", "running"):
|
| 617 |
+
db.update_job(job_id, state="cancelled", stage="cancelled", finished=job["state"] == "queued")
|
| 618 |
+
if job["type"] == "transcribe" and job["state"] == "queued":
|
| 619 |
+
db.update_entry(job["meeting_id"], transcript_state="none")
|
| 620 |
+
return {"job": db.get_job(job_id)}
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
@router.post("/v1/meetingsense/jobs/{job_id}/retry")
|
| 624 |
+
async def jobs_retry(
|
| 625 |
+
job_id: str,
|
| 626 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 627 |
+
) -> Dict[str, Any]:
|
| 628 |
+
_require_enabled()
|
| 629 |
+
job = db.get_job(job_id)
|
| 630 |
+
if job is None:
|
| 631 |
+
raise HTTPException(status_code=404, detail="not found")
|
| 632 |
+
_owned_entry(job["meeting_id"], _viewer(authorization, homepilot_session))
|
| 633 |
+
if job["state"] in ("queued", "running"):
|
| 634 |
+
raise _error(409, "in_progress", "That job is still running.")
|
| 635 |
+
new_id = db.create_job(job["meeting_id"], job["type"], options=job.get("options"))
|
| 636 |
+
if job["type"] == "transcribe":
|
| 637 |
+
db.update_entry(job["meeting_id"], transcript_state="queued")
|
| 638 |
+
jobs.wake()
|
| 639 |
+
return {"job": db.get_job(new_id)}
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
@router.post("/v1/meetingsense/library/item/{meeting_id}/transcriptions")
|
| 643 |
+
async def transcribe_again(
|
| 644 |
+
meeting_id: str, body: Optional[Dict[str, Any]] = None,
|
| 645 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 646 |
+
) -> JSONResponse:
|
| 647 |
+
"""Transcribe (again) on this computer. The current transcript stays until the new one
|
| 648 |
+
is ready, and stays as a version afterwards."""
|
| 649 |
+
_require_enabled()
|
| 650 |
+
_owned_entry(meeting_id, _viewer(authorization, homepilot_session))
|
| 651 |
+
if jobs._primary_audio(meeting_id) is None:
|
| 652 |
+
raise _error(409, "no_audio", "This meeting has no recording to transcribe.")
|
| 653 |
+
for job in db.list_jobs(meeting_id=meeting_id, states=("queued", "running")):
|
| 654 |
+
if job["type"] == "transcribe":
|
| 655 |
+
raise _error(409, "in_progress", "A transcription is already running for this meeting.")
|
| 656 |
+
jid = db.create_job(meeting_id, "transcribe", options=body or {})
|
| 657 |
+
db.ensure_entry(meeting_id)
|
| 658 |
+
db.update_entry(meeting_id, transcript_state="queued")
|
| 659 |
+
jobs.wake()
|
| 660 |
+
return JSONResponse(status_code=202, content={"job": db.get_job(jid)})
|
| 661 |
+
|
| 662 |
+
|
| 663 |
+
@router.post("/v1/meetingsense/library/item/{meeting_id}/ask")
|
| 664 |
+
async def ask_library_meeting(
|
| 665 |
+
meeting_id: str, body: Optional[Dict[str, Any]] = None,
|
| 666 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 667 |
+
) -> Dict[str, Any]:
|
| 668 |
+
"""The meeting's own Ask, for a finished library meeting.
|
| 669 |
+
|
| 670 |
+
The answer comes from the existing MeetingSense route, unchanged. Only when no model
|
| 671 |
+
answered (``degraded == "extractive"``) and the meeting is not live are the quoted lines
|
| 672 |
+
chosen again: the core fallback quotes the newest lines, which suits a live meeting, but for
|
| 673 |
+
a finished one the lines that match the question are the useful evidence.
|
| 674 |
+
"""
|
| 675 |
+
from .. import ask as ask_mod # noqa: PLC0415
|
| 676 |
+
from .. import export as export_mod # noqa: PLC0415
|
| 677 |
+
from .. import session as session_mod # noqa: PLC0415
|
| 678 |
+
from ..routes import ask_meeting # noqa: PLC0415
|
| 679 |
+
|
| 680 |
+
_require_enabled()
|
| 681 |
+
_owned_entry(meeting_id, _viewer(authorization, homepilot_session))
|
| 682 |
+
result = await ask_meeting(meeting_id, body or {})
|
| 683 |
+
if result.get("degraded") != "extractive":
|
| 684 |
+
return result
|
| 685 |
+
live = session_mod.get(meeting_id)
|
| 686 |
+
if live is not None and live.state == session_mod.MeetingState.LIVE:
|
| 687 |
+
return result
|
| 688 |
+
question = str((body or {}).get("text") or "")
|
| 689 |
+
rows = ask_mod.retrieve(store.get_segments(meeting_id), [], question, limit=3)
|
| 690 |
+
rendered = "\n".join(
|
| 691 |
+
f"[{export_mod.clock(r.get('t0_ms'))}] " + (f"{r['speaker']}: " if r.get("speaker") else "") + r["text"].strip()
|
| 692 |
+
for r in rows if (r.get("text") or "").strip()
|
| 693 |
+
)
|
| 694 |
+
if not rendered:
|
| 695 |
+
return result
|
| 696 |
+
text = f"From the meeting transcript:\n{rendered}"
|
| 697 |
+
cited = sorted({export_mod.clock(r.get("t0_ms")) for r in rows})
|
| 698 |
+
return {**result, "text": text, "cited": cited, "sources": len(rows)}
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
@router.post("/v1/meetingsense/library/item/{meeting_id}/summarize")
|
| 702 |
+
async def summarize_again(
|
| 703 |
+
meeting_id: str, body: Optional[Dict[str, Any]] = None,
|
| 704 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 705 |
+
) -> JSONResponse:
|
| 706 |
+
_require_enabled()
|
| 707 |
+
_owned_entry(meeting_id, _viewer(authorization, homepilot_session))
|
| 708 |
+
if not store.get_segments(meeting_id):
|
| 709 |
+
raise _error(409, "no_transcript", "There is no transcript to summarise yet.")
|
| 710 |
+
db.ensure_entry(meeting_id)
|
| 711 |
+
jid = db.create_job(meeting_id, "summarize", max_attempts=1, options=body or {})
|
| 712 |
+
db.update_entry(meeting_id, summary_state="pending")
|
| 713 |
+
jobs.wake()
|
| 714 |
+
return JSONResponse(status_code=202, content={"job": db.get_job(jid)})
|
| 715 |
+
|
| 716 |
+
|
| 717 |
+
# ── meeting intelligence ────────────────────────────────────────────────────
|
| 718 |
+
|
| 719 |
+
|
| 720 |
+
def _analysis_jobs(meeting_id: str) -> List[Dict[str, Any]]:
|
| 721 |
+
return [j for j in db.list_jobs(meeting_id=meeting_id, limit=20) if j["type"] in ("analyze", "layout")]
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
def _analysis_payload(meeting_id: str) -> Dict[str, Any]:
|
| 725 |
+
from ..intelligence import service as intelligence # noqa: PLC0415
|
| 726 |
+
|
| 727 |
+
current = intelligence.current(meeting_id)
|
| 728 |
+
pending = [j for j in _analysis_jobs(meeting_id) if j["state"] in ("queued", "running")]
|
| 729 |
+
failed = [j for j in _analysis_jobs(meeting_id) if j["state"] == "failed"]
|
| 730 |
+
if pending:
|
| 731 |
+
state = "pending"
|
| 732 |
+
elif current:
|
| 733 |
+
state = "ready"
|
| 734 |
+
elif failed:
|
| 735 |
+
state = "failed"
|
| 736 |
+
elif store.get_segments(meeting_id):
|
| 737 |
+
state = "none"
|
| 738 |
+
else:
|
| 739 |
+
state = "no_transcript"
|
| 740 |
+
return {
|
| 741 |
+
"state": state,
|
| 742 |
+
"job": (pending or failed or [None])[0],
|
| 743 |
+
**({k: current.get(k) for k in ("id", "created_at", "model", "degraded", "schema_version", "versions",
|
| 744 |
+
"analysis", "visual", "questions", "report")} if current else {}),
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
|
| 748 |
+
@router.get("/v1/meetingsense/library/item/{meeting_id}/analysis")
|
| 749 |
+
async def analysis_read(
|
| 750 |
+
meeting_id: str,
|
| 751 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 752 |
+
) -> Dict[str, Any]:
|
| 753 |
+
"""The newest analysis with its visual page, questions and validator report, plus the
|
| 754 |
+
state the page should show (``none`` / ``pending`` / ``ready`` / ``failed`` / ``no_transcript``)."""
|
| 755 |
+
_require_enabled()
|
| 756 |
+
_require_intelligence()
|
| 757 |
+
_owned_entry(meeting_id, _viewer(authorization, homepilot_session))
|
| 758 |
+
return await asyncio.to_thread(_analysis_payload, meeting_id)
|
| 759 |
+
|
| 760 |
+
|
| 761 |
+
@router.post("/v1/meetingsense/library/item/{meeting_id}/analysis")
|
| 762 |
+
async def analysis_run(
|
| 763 |
+
meeting_id: str, body: Optional[Dict[str, Any]] = None,
|
| 764 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 765 |
+
) -> JSONResponse:
|
| 766 |
+
"""Queue the analysis (again). The current page stays until the new one is ready."""
|
| 767 |
+
_require_enabled()
|
| 768 |
+
_require_intelligence()
|
| 769 |
+
_owned_entry(meeting_id, _viewer(authorization, homepilot_session))
|
| 770 |
+
if not store.get_segments(meeting_id):
|
| 771 |
+
raise _error(409, "no_transcript", "There is no transcript to analyse yet.",
|
| 772 |
+
"Transcribe the recording first; the analysis is written from the transcript.")
|
| 773 |
+
for job in _analysis_jobs(meeting_id):
|
| 774 |
+
if job["state"] in ("queued", "running"):
|
| 775 |
+
return JSONResponse(status_code=202, content={"job": job, "state": "pending"})
|
| 776 |
+
db.ensure_entry(meeting_id)
|
| 777 |
+
jid = db.create_job(meeting_id, "analyze", max_attempts=1, options={k: v for k, v in (body or {}).items() if k == "model_target"})
|
| 778 |
+
jobs.wake()
|
| 779 |
+
return JSONResponse(status_code=202, content={"job": db.get_job(jid), "state": "pending"})
|
| 780 |
+
|
| 781 |
+
|
| 782 |
+
@router.get("/v1/meetingsense/library/item/{meeting_id}/visual")
|
| 783 |
+
async def visual_read(
|
| 784 |
+
meeting_id: str,
|
| 785 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 786 |
+
) -> Dict[str, Any]:
|
| 787 |
+
from ..intelligence import service as intelligence # noqa: PLC0415
|
| 788 |
+
|
| 789 |
+
_require_enabled()
|
| 790 |
+
_require_intelligence()
|
| 791 |
+
_owned_entry(meeting_id, _viewer(authorization, homepilot_session))
|
| 792 |
+
current = await asyncio.to_thread(intelligence.current, meeting_id)
|
| 793 |
+
if not current:
|
| 794 |
+
raise _error(404, "no_analysis", "This meeting has not been analysed yet.")
|
| 795 |
+
return {"visual": current.get("visual"), "analysis": current.get("analysis"), "id": current.get("id")}
|
| 796 |
+
|
| 797 |
+
|
| 798 |
+
@router.post("/v1/meetingsense/library/item/{meeting_id}/visual")
|
| 799 |
+
async def visual_relayout(
|
| 800 |
+
meeting_id: str, body: Optional[Dict[str, Any]] = None,
|
| 801 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 802 |
+
) -> JSONResponse:
|
| 803 |
+
"""Write the Executive Brief again from the latest analysis (no re-extraction). 409
|
| 804 |
+
``no_analysis`` until an analysis exists; the current page stays until the new one lands."""
|
| 805 |
+
from ..intelligence import service as intelligence # noqa: PLC0415
|
| 806 |
+
|
| 807 |
+
_require_enabled()
|
| 808 |
+
_require_intelligence()
|
| 809 |
+
_owned_entry(meeting_id, _viewer(authorization, homepilot_session))
|
| 810 |
+
if not await asyncio.to_thread(intelligence.current, meeting_id):
|
| 811 |
+
raise _error(409, "no_analysis", "This meeting has not been analysed yet.", "Generate the insights first.")
|
| 812 |
+
for job in _analysis_jobs(meeting_id):
|
| 813 |
+
if job["state"] in ("queued", "running"):
|
| 814 |
+
return JSONResponse(status_code=202, content={"job": job, "state": "pending"})
|
| 815 |
+
jid = db.create_job(meeting_id, "layout", max_attempts=1,
|
| 816 |
+
options={k: v for k, v in (body or {}).items() if k in ("model_target", "template")})
|
| 817 |
+
jobs.wake()
|
| 818 |
+
return JSONResponse(status_code=202, content={"job": db.get_job(jid), "state": "pending"})
|
| 819 |
+
|
| 820 |
+
|
| 821 |
+
@router.get("/v1/meetingsense/library/item/{meeting_id}/questions")
|
| 822 |
+
async def questions_read(
|
| 823 |
+
meeting_id: str,
|
| 824 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 825 |
+
) -> Dict[str, Any]:
|
| 826 |
+
from ..intelligence import service as intelligence # noqa: PLC0415
|
| 827 |
+
|
| 828 |
+
_require_enabled()
|
| 829 |
+
_require_intelligence()
|
| 830 |
+
_owned_entry(meeting_id, _viewer(authorization, homepilot_session))
|
| 831 |
+
current = await asyncio.to_thread(intelligence.current, meeting_id)
|
| 832 |
+
return {"questions": (current or {}).get("questions") or []}
|
| 833 |
+
|
| 834 |
+
|
| 835 |
+
@router.get("/v1/meetingsense/library/item/{meeting_id}/actions")
|
| 836 |
+
async def actions_read(
|
| 837 |
+
meeting_id: str,
|
| 838 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 839 |
+
) -> Dict[str, Any]:
|
| 840 |
+
from ..intelligence import store as intelligence_store # noqa: PLC0415
|
| 841 |
+
|
| 842 |
+
_require_enabled()
|
| 843 |
+
_require_intelligence()
|
| 844 |
+
_owned_entry(meeting_id, _viewer(authorization, homepilot_session))
|
| 845 |
+
return {"actions": await asyncio.to_thread(intelligence_store.actions, meeting_id)}
|
| 846 |
+
|
| 847 |
+
|
| 848 |
+
@router.patch("/v1/meetingsense/library/item/{meeting_id}/actions/{item_id}")
|
| 849 |
+
async def actions_update(
|
| 850 |
+
meeting_id: str, item_id: str, body: Dict[str, Any],
|
| 851 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 852 |
+
) -> Dict[str, Any]:
|
| 853 |
+
"""Tick, reassign or re-date an action item. Kept on the server, so it follows the meeting
|
| 854 |
+
to every device and survives a regenerated analysis."""
|
| 855 |
+
from ..intelligence import store as intelligence_store # noqa: PLC0415
|
| 856 |
+
|
| 857 |
+
_require_enabled()
|
| 858 |
+
_require_intelligence()
|
| 859 |
+
_owned_entry(meeting_id, _viewer(authorization, homepilot_session))
|
| 860 |
+
row = await asyncio.to_thread(
|
| 861 |
+
intelligence_store.update_action, meeting_id, item_id,
|
| 862 |
+
**{k: body.get(k) for k in intelligence_store.UPDATABLE if k in body},
|
| 863 |
+
)
|
| 864 |
+
if row is None:
|
| 865 |
+
raise HTTPException(status_code=404, detail="not found")
|
| 866 |
+
return {"action": row}
|
| 867 |
+
|
| 868 |
+
|
| 869 |
+
@router.get("/v1/meetingsense/library/schema")
|
| 870 |
+
async def intelligence_schema() -> Dict[str, Any]:
|
| 871 |
+
"""The published analysis contract, for other consumers (DayPilot reads this)."""
|
| 872 |
+
from ..intelligence import schema as schema_mod # noqa: PLC0415
|
| 873 |
+
|
| 874 |
+
_require_enabled()
|
| 875 |
+
_require_intelligence()
|
| 876 |
+
return schema_mod.json_schema()
|
| 877 |
+
|
| 878 |
+
|
| 879 |
+
@router.get("/v1/meetingsense/library/item/{meeting_id}/versions")
|
| 880 |
+
async def versions_list(
|
| 881 |
+
meeting_id: str,
|
| 882 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 883 |
+
) -> Dict[str, Any]:
|
| 884 |
+
_require_enabled()
|
| 885 |
+
_owned_entry(meeting_id, _viewer(authorization, homepilot_session))
|
| 886 |
+
return {"versions": db.list_versions(meeting_id)}
|
| 887 |
+
|
| 888 |
+
|
| 889 |
+
@router.post("/v1/meetingsense/library/item/{meeting_id}/versions")
|
| 890 |
+
async def versions_create(
|
| 891 |
+
meeting_id: str, body: Dict[str, Any],
|
| 892 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 893 |
+
) -> JSONResponse:
|
| 894 |
+
"""Save an edited transcript as a new version and show it. Earlier versions stay."""
|
| 895 |
+
_require_enabled()
|
| 896 |
+
_owned_entry(meeting_id, _viewer(authorization, homepilot_session))
|
| 897 |
+
segments = (body or {}).get("segments")
|
| 898 |
+
if not isinstance(segments, list) or not segments:
|
| 899 |
+
raise _error(400, "no_segments", "segments must be a non-empty list.")
|
| 900 |
+
clean = []
|
| 901 |
+
for seg in segments:
|
| 902 |
+
if not isinstance(seg, dict) or not str(seg.get("text") or "").strip():
|
| 903 |
+
continue
|
| 904 |
+
clean.append({"t0_ms": int(seg.get("t0_ms") or 0),
|
| 905 |
+
"t1_ms": None if seg.get("t1_ms") is None else int(seg["t1_ms"]),
|
| 906 |
+
"speaker": (str(seg.get("speaker")).strip() or None) if seg.get("speaker") else None,
|
| 907 |
+
"text": str(seg["text"]).strip()[:4000], "conf": None})
|
| 908 |
+
if not clean:
|
| 909 |
+
raise _error(400, "no_segments", "Every segment was empty.")
|
| 910 |
+
active = next((v for v in db.list_versions(meeting_id) if v["active"]), None)
|
| 911 |
+
vid = db.create_version(meeting_id, clean, origin="edit", timing=(active or {}).get("timing") or "imported",
|
| 912 |
+
note=str((body or {}).get("note") or "Edited by hand")[:200])
|
| 913 |
+
db.publish_version(meeting_id, vid)
|
| 914 |
+
return JSONResponse(status_code=201, content={"version_id": vid, "versions": db.list_versions(meeting_id)})
|
| 915 |
+
|
| 916 |
+
|
| 917 |
+
@router.post("/v1/meetingsense/library/item/{meeting_id}/versions/{version_id}/publish")
|
| 918 |
+
async def versions_publish(
|
| 919 |
+
meeting_id: str, version_id: str,
|
| 920 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 921 |
+
) -> Dict[str, Any]:
|
| 922 |
+
_require_enabled()
|
| 923 |
+
_owned_entry(meeting_id, _viewer(authorization, homepilot_session))
|
| 924 |
+
try:
|
| 925 |
+
count = db.publish_version(meeting_id, version_id)
|
| 926 |
+
except KeyError:
|
| 927 |
+
raise HTTPException(status_code=404, detail="not found") from None
|
| 928 |
+
return {"ok": True, "segments": count, "versions": db.list_versions(meeting_id)}
|
| 929 |
+
|
| 930 |
+
|
| 931 |
+
@router.get("/v1/meetingsense/library/item/{meeting_id}/transcript")
|
| 932 |
+
async def transcript_export(
|
| 933 |
+
meeting_id: str, fmt: str = Query("txt"),
|
| 934 |
+
authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None),
|
| 935 |
+
) -> Response:
|
| 936 |
+
"""TXT / SRT / VTT / JSON / MD of the transcript shown now (MeetingSense export covers
|
| 937 |
+
md, srt and json; TXT and VTT are added here for imported meetings)."""
|
| 938 |
+
_require_enabled()
|
| 939 |
+
found = _owned_entry(meeting_id, _viewer(authorization, homepilot_session))
|
| 940 |
+
segments = store.get_segments(meeting_id)
|
| 941 |
+
title = (found["meeting"].get("title") or "meeting").strip()
|
| 942 |
+
fname = re.sub(r"[^\w\-. ]+", "_", title)[:80] or "meeting"
|
| 943 |
+
|
| 944 |
+
def clock(ms: Optional[int], sep: str) -> str:
|
| 945 |
+
ms = int(ms or 0)
|
| 946 |
+
h, rem = divmod(ms, 3_600_000)
|
| 947 |
+
m, rem = divmod(rem, 60_000)
|
| 948 |
+
s, ms = divmod(rem, 1000)
|
| 949 |
+
return f"{h:02d}:{m:02d}:{s:02d}{sep}{ms:03d}"
|
| 950 |
+
|
| 951 |
+
fmt = fmt.lower()
|
| 952 |
+
if fmt == "json":
|
| 953 |
+
content = json.dumps({"title": title, "segments": segments}, ensure_ascii=False, indent=2)
|
| 954 |
+
media_type = "application/json"
|
| 955 |
+
elif fmt in ("srt", "vtt"):
|
| 956 |
+
sep = "," if fmt == "srt" else "."
|
| 957 |
+
lines = ["WEBVTT", ""] if fmt == "vtt" else []
|
| 958 |
+
for i, seg in enumerate(segments, 1):
|
| 959 |
+
end = seg["t1_ms"] if seg.get("t1_ms") is not None else int(seg["t0_ms"] or 0) + 3000
|
| 960 |
+
if fmt == "srt":
|
| 961 |
+
lines.append(str(i))
|
| 962 |
+
text = f"{seg['speaker']}: {seg['text']}" if seg.get("speaker") else seg["text"]
|
| 963 |
+
lines += [f"{clock(seg['t0_ms'], sep)} --> {clock(end, sep)}", text, ""]
|
| 964 |
+
content = "\n".join(lines)
|
| 965 |
+
media_type = "text/vtt" if fmt == "vtt" else "application/x-subrip"
|
| 966 |
+
elif fmt in ("txt", "md"):
|
| 967 |
+
head = [f"# {title}", ""] if fmt == "md" else [title, ""]
|
| 968 |
+
body_lines = [
|
| 969 |
+
f"[{clock(seg['t0_ms'], '.')[:8]}] " + (f"{seg['speaker']}: " if seg.get("speaker") else "") + seg["text"]
|
| 970 |
+
for seg in segments
|
| 971 |
+
]
|
| 972 |
+
content = "\n".join(head + body_lines) + "\n"
|
| 973 |
+
media_type = "text/markdown" if fmt == "md" else "text/plain"
|
| 974 |
+
else:
|
| 975 |
+
raise _error(400, "bad_format", "fmt must be txt, md, srt, vtt or json.")
|
| 976 |
+
return Response(content=content, media_type=f"{media_type}; charset=utf-8",
|
| 977 |
+
headers={"Content-Disposition": f'attachment; filename="{fname}.{fmt}"'})
|
backend/app/meetingsense/library/sync.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Folder sync — import finished recordings from a folder such as ``D:\\AIRecord``.
|
| 2 |
+
|
| 3 |
+
**Who may read the disk.** The server reads a folder only when it lies below a root the
|
| 4 |
+
operator listed in ``MEETINGS_SYNC_ALLOWED_ROOTS``. A path typed in the browser is a request,
|
| 5 |
+
not a grant; a backend on another machine cannot see this computer's ``D:`` at all, and is
|
| 6 |
+
told so rather than pretending.
|
| 7 |
+
|
| 8 |
+
When HomePilot runs under WSL (the usual Windows setup), ``D:\\AIRecord`` is
|
| 9 |
+
``/mnt/d/AIRecord`` to the server, and the translation is done here.
|
| 10 |
+
|
| 11 |
+
**What a scan does.** It walks the folder, groups files into meetings (the same discovery an
|
| 12 |
+
upload uses), and imports each meeting once its files have stopped changing — a recording
|
| 13 |
+
still being written is left for the next scan. Re-running a scan is always safe: an
|
| 14 |
+
Insta360 meeting is recognised by its ``localMeetingId`` and anything else by the SHA-256 of
|
| 15 |
+
its recording, so nothing is imported twice, and a renamed file is followed rather than
|
| 16 |
+
duplicated. Source files are only read. A file that later disappears leaves its meeting in
|
| 17 |
+
the library, marked "source no longer available".
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import asyncio
|
| 23 |
+
import logging
|
| 24 |
+
import os
|
| 25 |
+
import re
|
| 26 |
+
import time
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 29 |
+
|
| 30 |
+
from . import db
|
| 31 |
+
from .adapters import discover
|
| 32 |
+
from .config import load_library_config
|
| 33 |
+
from .importer import analyse, ingest
|
| 34 |
+
|
| 35 |
+
log = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
_loop_task: Optional[asyncio.Task] = None
|
| 38 |
+
_scan_lock: Optional[asyncio.Lock] = None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class SyncRefused(Exception):
|
| 42 |
+
def __init__(self, message: str, *, code: str, hint: str = "") -> None:
|
| 43 |
+
super().__init__(message)
|
| 44 |
+
self.code = code
|
| 45 |
+
self.hint = hint
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
_WIN_DRIVE = re.compile(r"^([A-Za-z]):[\\/]*(.*)$")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _is_wsl() -> bool:
|
| 52 |
+
try:
|
| 53 |
+
return "microsoft" in Path("/proc/version").read_text().lower()
|
| 54 |
+
except OSError:
|
| 55 |
+
return False
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def server_path(user_path: str) -> str:
|
| 59 |
+
"""Where the server finds a folder the user named. ``D:\\X`` → ``/mnt/d/X`` off Windows."""
|
| 60 |
+
raw = (user_path or "").strip().strip('"')
|
| 61 |
+
m = _WIN_DRIVE.match(raw)
|
| 62 |
+
if m and os.name != "nt":
|
| 63 |
+
rest = m.group(2).replace("\\", "/").strip("/")
|
| 64 |
+
return f"/mnt/{m.group(1).lower()}/{rest}" if rest else f"/mnt/{m.group(1).lower()}"
|
| 65 |
+
return raw
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _within(path: Path, root: Path) -> bool:
|
| 69 |
+
try:
|
| 70 |
+
path.relative_to(root)
|
| 71 |
+
return True
|
| 72 |
+
except ValueError:
|
| 73 |
+
return False
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def authorize(user_path: str) -> Tuple[str, Path]:
|
| 77 |
+
"""Validate a requested folder; return (as entered, resolved server path)."""
|
| 78 |
+
cfg = load_library_config()
|
| 79 |
+
if not user_path or not user_path.strip():
|
| 80 |
+
raise SyncRefused("Choose a folder.", code="missing_path")
|
| 81 |
+
if not cfg.sync_roots:
|
| 82 |
+
raise SyncRefused(
|
| 83 |
+
"Folder sync is turned off on this server.",
|
| 84 |
+
code="sync_not_enabled",
|
| 85 |
+
hint=("Ask the person who runs HomePilot to allow the folder: set "
|
| 86 |
+
"MEETINGS_SYNC_ALLOWED_ROOTS=" + (server_path("D:\\AIRecord") if os.name != "nt" else "D:\\AIRecord")
|
| 87 |
+
+ " and restart."),
|
| 88 |
+
)
|
| 89 |
+
resolved = Path(server_path(user_path)).expanduser()
|
| 90 |
+
try:
|
| 91 |
+
resolved = resolved.resolve(strict=True)
|
| 92 |
+
except (OSError, RuntimeError):
|
| 93 |
+
hint = ""
|
| 94 |
+
if _WIN_DRIVE.match(user_path.strip()) and os.name != "nt" and not _is_wsl():
|
| 95 |
+
hint = ("This HomePilot server is not running on that Windows computer, so it cannot see its "
|
| 96 |
+
"drives. Upload the recordings instead, or run HomePilot on that computer.")
|
| 97 |
+
raise SyncRefused(f"The folder “{user_path}” was not found on the HomePilot server.",
|
| 98 |
+
code="not_found", hint=hint)
|
| 99 |
+
if not resolved.is_dir():
|
| 100 |
+
raise SyncRefused(f"“{user_path}” is not a folder.", code="not_a_folder")
|
| 101 |
+
allowed = []
|
| 102 |
+
for root in cfg.sync_roots:
|
| 103 |
+
try:
|
| 104 |
+
allowed.append(Path(server_path(root)).expanduser().resolve())
|
| 105 |
+
except (OSError, RuntimeError):
|
| 106 |
+
continue
|
| 107 |
+
if not any(_within(resolved, root) for root in allowed):
|
| 108 |
+
raise SyncRefused(
|
| 109 |
+
"That folder is outside the folders this server allows.",
|
| 110 |
+
code="not_allowed",
|
| 111 |
+
hint="Allowed: " + "; ".join(cfg.sync_roots),
|
| 112 |
+
)
|
| 113 |
+
return user_path.strip(), resolved
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def add_source(user_path: str, *, display_name: str = "", owner_user_id: str = "",
|
| 117 |
+
project_id: Optional[str] = None) -> Dict[str, Any]:
|
| 118 |
+
entered, resolved = authorize(user_path)
|
| 119 |
+
for existing in db.list_sources():
|
| 120 |
+
if Path(existing["resolved_path"]) == resolved:
|
| 121 |
+
return existing
|
| 122 |
+
sid = db.create_source(kind="folder", display_name=display_name.strip() or Path(entered).name or entered,
|
| 123 |
+
path=entered, resolved_path=str(resolved), owner_user_id=owner_user_id,
|
| 124 |
+
project_id=project_id or None)
|
| 125 |
+
return db.get_source(sid) # type: ignore[return-value]
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _stable(source_id: str, files, *, now: float, stability_s: int) -> bool:
|
| 129 |
+
"""Every file of a meeting has stopped changing: not modified for ``stability_s`` seconds,
|
| 130 |
+
and the same size and mtime as at the last scan (a file that changed since is still being
|
| 131 |
+
written, whatever its mtime says)."""
|
| 132 |
+
ok = True
|
| 133 |
+
for f in files:
|
| 134 |
+
seen = db.observe(source_id, f.rel, f.size, f.mtime)
|
| 135 |
+
if seen.get("changed"):
|
| 136 |
+
ok = False
|
| 137 |
+
continue
|
| 138 |
+
settled_since = min(float(seen["first_seen_at"]), float(f.mtime))
|
| 139 |
+
if now - settled_since < stability_s:
|
| 140 |
+
ok = False
|
| 141 |
+
return ok
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def scan_source(source_id: str) -> Dict[str, Any]:
|
| 145 |
+
"""One reconciliation pass over a folder. Idempotent; safe after a crash."""
|
| 146 |
+
source = db.get_source(source_id)
|
| 147 |
+
if source is None:
|
| 148 |
+
raise SyncRefused("No such folder source.", code="not_found")
|
| 149 |
+
cfg = load_library_config()
|
| 150 |
+
now = time.time()
|
| 151 |
+
counts = {"found": 0, "imported": 0, "already": 0, "waiting": 0, "skipped": 0, "failed": 0}
|
| 152 |
+
errors: List[str] = []
|
| 153 |
+
db.update_source(source_id, last_scan_at=now)
|
| 154 |
+
try:
|
| 155 |
+
_, root = authorize(source["path"]) # re-checked every scan: the operator may revoke
|
| 156 |
+
except SyncRefused as exc:
|
| 157 |
+
db.update_source(source_id, last_error=f"{exc} {exc.hint}".strip(), counts=counts)
|
| 158 |
+
raise
|
| 159 |
+
known = {a["relative_path"]: a for a in db.assets_for_source(source_id)}
|
| 160 |
+
present = set()
|
| 161 |
+
for cand in discover(root):
|
| 162 |
+
counts["found"] += 1
|
| 163 |
+
for f in cand.files:
|
| 164 |
+
present.add(f.rel)
|
| 165 |
+
if not cand.files:
|
| 166 |
+
counts["skipped"] += 1
|
| 167 |
+
continue
|
| 168 |
+
# Already imported from this folder at this path (fast path, no hashing).
|
| 169 |
+
prior = known.get(cand.audio.rel if cand.audio else cand.files[0].rel)
|
| 170 |
+
if prior and prior.get("meeting_id"):
|
| 171 |
+
counts["already"] += 1
|
| 172 |
+
continue
|
| 173 |
+
if not _stable(source_id, cand.files, now=now, stability_s=cfg.stability_s):
|
| 174 |
+
counts["waiting"] += 1
|
| 175 |
+
continue
|
| 176 |
+
try:
|
| 177 |
+
item = analyse(cand)
|
| 178 |
+
if item.get("duplicate_of"):
|
| 179 |
+
_follow_rename(item, cand, source_id)
|
| 180 |
+
counts["already"] += 1
|
| 181 |
+
continue
|
| 182 |
+
if item.get("outcome") == "skip":
|
| 183 |
+
counts["skipped"] += 1
|
| 184 |
+
continue
|
| 185 |
+
# Automatic attach only when exactly one scheduled meeting fits; otherwise the
|
| 186 |
+
# recording becomes its own meeting and the match is offered in the library.
|
| 187 |
+
attach = item.get("attach_to") if len(item.get("scheduled_matches") or []) == 1 else None
|
| 188 |
+
ingest(cand, source_id=source_id, origin_type="sync", owner_user_id=source.get("owner_user_id") or "",
|
| 189 |
+
project_id=source.get("project_id"), attach_to=attach, analysed=item)
|
| 190 |
+
counts["imported"] += 1
|
| 191 |
+
except Exception as exc: # noqa: BLE001 — one bad file must not stop the folder
|
| 192 |
+
counts["failed"] += 1
|
| 193 |
+
errors.append(f"{cand.key}: {exc}")
|
| 194 |
+
log.warning("meetings sync: %s failed: %s", cand.key, exc)
|
| 195 |
+
# Files that vanished: keep the meeting, say the original is gone.
|
| 196 |
+
for rel, asset in known.items():
|
| 197 |
+
available = 1 if rel in present else 0
|
| 198 |
+
if int(asset.get("source_available") or 0) != available:
|
| 199 |
+
db.update_asset(asset["id"], source_available=available)
|
| 200 |
+
db.update_source(source_id, last_success_at=time.time(),
|
| 201 |
+
last_error="; ".join(errors)[:1000] if errors else "", counts=counts)
|
| 202 |
+
if counts["imported"]:
|
| 203 |
+
from .jobs import wake # noqa: PLC0415
|
| 204 |
+
|
| 205 |
+
wake()
|
| 206 |
+
return counts
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _follow_rename(item: Dict[str, Any], cand, source_id: str) -> None:
|
| 210 |
+
"""Same recording, new path: point the asset at its new name instead of re-importing."""
|
| 211 |
+
if cand.audio is None:
|
| 212 |
+
return
|
| 213 |
+
sha = (item.get("audio") or {}).get("sha256")
|
| 214 |
+
for asset in db.assets_with_sha(sha) if sha else []:
|
| 215 |
+
if asset["source_id"] == source_id and asset["relative_path"] != cand.audio.rel:
|
| 216 |
+
db.update_asset(asset["id"], relative_path=cand.audio.rel, source_available=1)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
async def scan_async(source_id: str) -> Dict[str, Any]:
|
| 220 |
+
global _scan_lock
|
| 221 |
+
if _scan_lock is None:
|
| 222 |
+
_scan_lock = asyncio.Lock()
|
| 223 |
+
async with _scan_lock:
|
| 224 |
+
return await asyncio.to_thread(scan_source, source_id)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def start_sync_loop() -> bool:
|
| 228 |
+
global _loop_task
|
| 229 |
+
cfg = load_library_config()
|
| 230 |
+
if not (cfg.enabled and cfg.sync_roots) or os.getenv("MEETINGS_LIBRARY_WORKER", "1").strip().lower() in ("0", "false", "off"):
|
| 231 |
+
return False
|
| 232 |
+
if _loop_task is not None and not _loop_task.done():
|
| 233 |
+
return True
|
| 234 |
+
try:
|
| 235 |
+
loop = asyncio.get_running_loop()
|
| 236 |
+
except RuntimeError:
|
| 237 |
+
return False
|
| 238 |
+
_loop_task = loop.create_task(_sync_loop())
|
| 239 |
+
return True
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
async def _sync_loop() -> None:
|
| 243 |
+
db.ensure_migrated()
|
| 244 |
+
while True:
|
| 245 |
+
interval = load_library_config().sync_interval_s
|
| 246 |
+
for source in db.list_sources():
|
| 247 |
+
if not source.get("enabled"):
|
| 248 |
+
continue
|
| 249 |
+
try:
|
| 250 |
+
await scan_async(source["id"])
|
| 251 |
+
except SyncRefused:
|
| 252 |
+
pass # recorded on the source; shown in Folder Sync
|
| 253 |
+
except Exception: # noqa: BLE001
|
| 254 |
+
log.exception("meetings sync: scan of %s failed", source["id"])
|
| 255 |
+
await asyncio.sleep(interval)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
async def stop_sync_loop() -> None:
|
| 259 |
+
global _loop_task
|
| 260 |
+
if _loop_task is not None:
|
| 261 |
+
_loop_task.cancel()
|
| 262 |
+
try:
|
| 263 |
+
await _loop_task
|
| 264 |
+
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
| 265 |
+
pass
|
| 266 |
+
_loop_task = None
|
backend/app/meetingsense/notes_engine.py
CHANGED
|
@@ -501,22 +501,43 @@ async def call_model(
|
|
| 501 |
model: str = "",
|
| 502 |
provider: str = "",
|
| 503 |
base_url: str = "",
|
|
|
|
|
|
|
| 504 |
) -> str:
|
| 505 |
"""The default MeetingSense model call, routed through HomePilot's compute layer.
|
| 506 |
|
| 507 |
All three of `provider`, `model` and `base_url` are optional: a meeting's setup supplies
|
| 508 |
the chat target the rest of HomePilot is using, and anything it leaves out is resolved
|
| 509 |
from the install's configuration by :func:`resolve_target` rather than assumed.
|
| 510 |
-
"""
|
| 511 |
-
from ..compute import route_chat
|
| 512 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
target = resolve_target(provider=provider, model=model, base_url=base_url)
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 522 |
return ((response.get("choices") or [{}])[0].get("message", {}) or {}).get("content", "") or ""
|
|
|
|
| 501 |
model: str = "",
|
| 502 |
provider: str = "",
|
| 503 |
base_url: str = "",
|
| 504 |
+
max_tokens: int = 600,
|
| 505 |
+
response_format: Optional[str] = None,
|
| 506 |
) -> str:
|
| 507 |
"""The default MeetingSense model call, routed through HomePilot's compute layer.
|
| 508 |
|
| 509 |
All three of `provider`, `model` and `base_url` are optional: a meeting's setup supplies
|
| 510 |
the chat target the rest of HomePilot is using, and anything it leaves out is resolved
|
| 511 |
from the install's configuration by :func:`resolve_target` rather than assumed.
|
|
|
|
|
|
|
| 512 |
|
| 513 |
+
``max_tokens`` defaults to the 600 the rolling notes were tuned for; longer callers (the
|
| 514 |
+
end-of-meeting document, the typed analysis) say what they need. ``response_format="json"``
|
| 515 |
+
asks the provider for a JSON object where that is supported — today Ollama's ``format`` —
|
| 516 |
+
and is ignored elsewhere, where the caller's own parsing has to cope.
|
| 517 |
+
"""
|
| 518 |
target = resolve_target(provider=provider, model=model, base_url=base_url)
|
| 519 |
+
if response_format and target["provider"] == "ollama":
|
| 520 |
+
# The compute router has no response_format parameter yet; the local Ollama path is the
|
| 521 |
+
# router's identity case, so calling it directly loses no routing and gains JSON mode.
|
| 522 |
+
from .. import llm
|
| 523 |
+
|
| 524 |
+
response = await llm.chat_ollama(
|
| 525 |
+
messages,
|
| 526 |
+
temperature=temperature,
|
| 527 |
+
max_tokens=max_tokens,
|
| 528 |
+
base_url=target["base_url"],
|
| 529 |
+
model=target["model"],
|
| 530 |
+
response_format=response_format,
|
| 531 |
+
)
|
| 532 |
+
else:
|
| 533 |
+
from ..compute import route_chat
|
| 534 |
+
|
| 535 |
+
response = await route_chat(
|
| 536 |
+
messages,
|
| 537 |
+
provider=target["provider"],
|
| 538 |
+
base_url=target["base_url"],
|
| 539 |
+
temperature=temperature,
|
| 540 |
+
max_tokens=max_tokens,
|
| 541 |
+
model=target["model"],
|
| 542 |
+
)
|
| 543 |
return ((response.get("choices") or [{}])[0].get("message", {}) or {}).get("content", "") or ""
|
backend/app/personas/avatar_crop.py
CHANGED
|
@@ -167,3 +167,67 @@ def active_crop(appearance: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
| 167 |
if crop.get("output") != appearance.get("selected_thumb_filename"):
|
| 168 |
return None
|
| 169 |
return crop
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
if crop.get("output") != appearance.get("selected_thumb_filename"):
|
| 168 |
return None
|
| 169 |
return crop
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _look(appearance: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
| 174 |
+
sel = appearance.get("selected")
|
| 175 |
+
return sel if isinstance(sel, dict) and sel else None
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def files_rel(url: Optional[str]) -> Optional[str]:
|
| 179 |
+
"""The upload-relative path a /files/ URL points at (absolute or not)."""
|
| 180 |
+
if not isinstance(url, str):
|
| 181 |
+
return None
|
| 182 |
+
if "/files/" in url:
|
| 183 |
+
ref = url.split("/files/", 1)[1]
|
| 184 |
+
elif url.startswith("files/"):
|
| 185 |
+
ref = url[len("files/"):]
|
| 186 |
+
else:
|
| 187 |
+
return None
|
| 188 |
+
from urllib.parse import unquote
|
| 189 |
+
|
| 190 |
+
ref = unquote(ref.split("?", 1)[0].split("#", 1)[0]).lstrip("/")
|
| 191 |
+
return ref or None
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _same_picture(appearance: Dict[str, Any]) -> set:
|
| 195 |
+
"""Every file that is the persona's current picture: the original, the
|
| 196 |
+
thumbnail shown, and the framing's source, output and automatic thumbnail."""
|
| 197 |
+
crop = appearance.get("avatar_crop") if isinstance(appearance.get("avatar_crop"), dict) else {}
|
| 198 |
+
names = (appearance.get("selected_filename"), appearance.get("selected_thumb_filename"),
|
| 199 |
+
crop.get("source"), crop.get("output"), crop.get("auto_thumb"))
|
| 200 |
+
return {n for n in names if isinstance(n, str) and n}
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def keeps_manual_picture(before: Dict[str, Any], after: Dict[str, Any], chosen_url: Optional[str] = None) -> bool:
|
| 204 |
+
"""
|
| 205 |
+
True while a picture chosen in Edit Profile Picture stays: it is in use, and
|
| 206 |
+
the user has not picked another look since. A look that is only another
|
| 207 |
+
reference to the same picture (a settings panel wrapping the current
|
| 208 |
+
picture, or its thumbnail, in a look of its own) is not another look.
|
| 209 |
+
"""
|
| 210 |
+
if active_crop(before) is None:
|
| 211 |
+
return False
|
| 212 |
+
if _look(after) == _look(before):
|
| 213 |
+
return True
|
| 214 |
+
rel = files_rel(chosen_url)
|
| 215 |
+
return rel is not None and rel in _same_picture(before)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def keep_manual_picture(before: Dict[str, Any], after: Dict[str, Any], chosen_url: Optional[str] = None) -> Dict[str, Any]:
|
| 219 |
+
"""
|
| 220 |
+
Saving other persona settings must not undo Edit Profile Picture.
|
| 221 |
+
|
| 222 |
+
Settings panels send back the whole persona_appearance they loaded — maybe
|
| 223 |
+
from before the crop, with the old automatic thumbnail — so, while the user
|
| 224 |
+
has not picked another look, the picture, its thumbnail and the framing
|
| 225 |
+
stay as they were. Picking another look replaces them, as it always did.
|
| 226 |
+
"""
|
| 227 |
+
if not keeps_manual_picture(before, after, chosen_url):
|
| 228 |
+
return after
|
| 229 |
+
kept = dict(after)
|
| 230 |
+
for key in ("selected_filename", "selected_thumb_filename", "avatar_crop"):
|
| 231 |
+
if key in before:
|
| 232 |
+
kept[key] = before[key]
|
| 233 |
+
return kept
|
frontend/.cache-bust
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
1792086980
|
frontend/package-lock.json
CHANGED
|
@@ -9,6 +9,7 @@
|
|
| 9 |
"version": "2.0.0",
|
| 10 |
"dependencies": {
|
| 11 |
"axios": "^1.7.2",
|
|
|
|
| 12 |
"lucide-react": "^0.457.0",
|
| 13 |
"react": "^18.3.1",
|
| 14 |
"react-dom": "^18.3.1",
|
|
@@ -2881,6 +2882,12 @@
|
|
| 2881 |
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
| 2882 |
}
|
| 2883 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2884 |
"node_modules/html-url-attributes": {
|
| 2885 |
"version": "3.0.1",
|
| 2886 |
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
|
|
|
| 9 |
"version": "2.0.0",
|
| 10 |
"dependencies": {
|
| 11 |
"axios": "^1.7.2",
|
| 12 |
+
"html-to-image": "^1.11.13",
|
| 13 |
"lucide-react": "^0.457.0",
|
| 14 |
"react": "^18.3.1",
|
| 15 |
"react-dom": "^18.3.1",
|
|
|
|
| 2882 |
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
| 2883 |
}
|
| 2884 |
},
|
| 2885 |
+
"node_modules/html-to-image": {
|
| 2886 |
+
"version": "1.11.13",
|
| 2887 |
+
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
|
| 2888 |
+
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
|
| 2889 |
+
"license": "MIT"
|
| 2890 |
+
},
|
| 2891 |
"node_modules/html-url-attributes": {
|
| 2892 |
"version": "3.0.1",
|
| 2893 |
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
frontend/package.json
CHANGED
|
@@ -4,6 +4,7 @@
|
|
| 4 |
"version": "2.0.0",
|
| 5 |
"type": "module",
|
| 6 |
"scripts": {
|
|
|
|
| 7 |
"dev": "vite --host 0.0.0.0 --port 3000",
|
| 8 |
"prebuild": "node scripts/generate-model-catalog.mjs",
|
| 9 |
"build": "tsc -b && vite build",
|
|
@@ -15,6 +16,7 @@
|
|
| 15 |
},
|
| 16 |
"dependencies": {
|
| 17 |
"axios": "^1.7.2",
|
|
|
|
| 18 |
"lucide-react": "^0.457.0",
|
| 19 |
"react": "^18.3.1",
|
| 20 |
"react-dom": "^18.3.1",
|
|
|
|
| 4 |
"version": "2.0.0",
|
| 5 |
"type": "module",
|
| 6 |
"scripts": {
|
| 7 |
+
"predev": "node scripts/ensure-deps.mjs",
|
| 8 |
"dev": "vite --host 0.0.0.0 --port 3000",
|
| 9 |
"prebuild": "node scripts/generate-model-catalog.mjs",
|
| 10 |
"build": "tsc -b && vite build",
|
|
|
|
| 16 |
},
|
| 17 |
"dependencies": {
|
| 18 |
"axios": "^1.7.2",
|
| 19 |
+
"html-to-image": "^1.11.13",
|
| 20 |
"lucide-react": "^0.457.0",
|
| 21 |
"react": "^18.3.1",
|
| 22 |
"react-dom": "^18.3.1",
|
frontend/scripts/ensure-deps.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
/**
|
| 3 |
+
* Runs before `npm run dev` (the "predev" script): makes sure node_modules has
|
| 4 |
+
* what package.json asks for.
|
| 5 |
+
*
|
| 6 |
+
* The start commands (`make start`, `make start-frontend`, `make dev`) only ran
|
| 7 |
+
* `npm install` when node_modules did not exist at all, so a dependency added by
|
| 8 |
+
* a `git pull` was never installed and Vite stopped with
|
| 9 |
+
* "Failed to resolve import". This compares every direct dependency with the
|
| 10 |
+
* version package-lock.json pins, and runs `npm install` only when one is
|
| 11 |
+
* missing or different — otherwise it costs a few milliseconds.
|
| 12 |
+
*
|
| 13 |
+
* Set HOMEPILOT_SKIP_DEPS_CHECK=1 to skip it.
|
| 14 |
+
*/
|
| 15 |
+
import { spawnSync } from 'node:child_process'
|
| 16 |
+
import fs from 'node:fs'
|
| 17 |
+
import path from 'node:path'
|
| 18 |
+
import { fileURLToPath } from 'node:url'
|
| 19 |
+
|
| 20 |
+
function readJson(file) {
|
| 21 |
+
try {
|
| 22 |
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
| 23 |
+
} catch {
|
| 24 |
+
return null
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Direct dependencies of the package in `root` that are not installed, or not
|
| 30 |
+
* at the version its package-lock.json pins. [{ name, reason }]
|
| 31 |
+
*/
|
| 32 |
+
export function staleDependencies(root) {
|
| 33 |
+
const pkg = readJson(path.join(root, 'package.json'))
|
| 34 |
+
if (!pkg) return []
|
| 35 |
+
const pinned = readJson(path.join(root, 'package-lock.json'))?.packages || {}
|
| 36 |
+
const names = Object.keys({ ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) })
|
| 37 |
+
const stale = []
|
| 38 |
+
for (const name of names) {
|
| 39 |
+
const installed = readJson(path.join(root, 'node_modules', name, 'package.json'))
|
| 40 |
+
if (!installed) {
|
| 41 |
+
stale.push({ name, reason: 'not installed' })
|
| 42 |
+
continue
|
| 43 |
+
}
|
| 44 |
+
// Linked (file:) packages have no pinned version; existence is enough.
|
| 45 |
+
const want = pinned[`node_modules/${name}`]?.version
|
| 46 |
+
if (want && installed.version !== want) {
|
| 47 |
+
stale.push({ name, reason: `${installed.version} installed, ${want} expected` })
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
return stale
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function npmInstall(root) {
|
| 54 |
+
// Inside `npm run`, npm_execpath is npm itself: run it with this Node, so it
|
| 55 |
+
// works the same on Linux, macOS, WSL and Windows.
|
| 56 |
+
const npmCli = process.env.npm_execpath
|
| 57 |
+
const args = ['install', '--no-audit', '--no-fund']
|
| 58 |
+
const res = npmCli && /npm-cli\.c?js$/.test(npmCli)
|
| 59 |
+
? spawnSync(process.execPath, [npmCli, ...args], { cwd: root, stdio: 'inherit' })
|
| 60 |
+
: spawnSync('npm', args, { cwd: root, stdio: 'inherit', shell: process.platform === 'win32' })
|
| 61 |
+
return res.status === 0
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function main() {
|
| 65 |
+
if (process.env.HOMEPILOT_SKIP_DEPS_CHECK === '1') return
|
| 66 |
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
| 67 |
+
const stale = staleDependencies(root)
|
| 68 |
+
if (!stale.length) return
|
| 69 |
+
const shown = stale.slice(0, 5).map((s) => `${s.name} (${s.reason})`).join(', ')
|
| 70 |
+
const more = stale.length > 5 ? ` and ${stale.length - 5} more` : ''
|
| 71 |
+
console.log(`[homepilot] Frontend dependencies changed: ${shown}${more}. Running npm install…`)
|
| 72 |
+
if (!npmInstall(root)) {
|
| 73 |
+
console.error('[homepilot] npm install failed. Run it yourself, then start again: cd frontend && npm install')
|
| 74 |
+
process.exit(1)
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main()
|
frontend/src/test/__snapshots__/meetingsLandingGate.test.tsx.snap
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
| 2 |
+
|
| 3 |
+
exports[`keeps the Meetings landing page exactly as it is 1`] = `"<div class="hp-meetings h-full flex flex-col bg-black text-white" data-testid="meetings-home"><header class="hp-clear-nav flex flex-wrap items-center gap-3 border-b border-white/[0.06] px-4 py-3 sm:px-6 sm:py-4"><div class="grid h-11 w-11 shrink-0 place-items-center rounded-xl border border-white/10 bg-white/[0.04]"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar-days text-violet-300"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path><path d="M8 14h.01"></path><path d="M12 14h.01"></path><path d="M16 14h.01"></path><path d="M8 18h.01"></path><path d="M12 18h.01"></path><path d="M16 18h.01"></path></svg></div><div class="min-w-0"><h1 class="text-lg font-semibold leading-tight sm:text-xl">Meetings</h1><p class="text-xs text-white/50 sm:text-sm">All meetings in one place</p></div><div class="ml-auto flex items-center gap-2"><button type="button" aria-label="New Meeting" title="New Meeting" class="inline-flex h-10 items-center gap-2 rounded-xl bg-gradient-to-r from-fuchsia-600 to-violet-600 px-3 text-sm font-semibold shadow-lg shadow-fuchsia-900/30 hover:from-fuchsia-500 hover:to-violet-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fuchsia-300 sm:px-4"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg> <span class="hidden sm:inline">New Meeting</span></button><button type="button" aria-label="Import Files" title="Import Files" class="inline-flex h-10 items-center gap-2 rounded-xl border border-teal-400/40 bg-teal-500/10 px-3 text-sm font-semibold text-teal-200 hover:bg-teal-500/20 sm:px-4"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud-upload"><path d="M12 13v8"></path><path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"></path><path d="m8 17 4-4 4 4"></path></svg> <span class="hidden sm:inline">Import Files</span></button><button type="button" aria-label="Folder Sync" title="Folder Sync" class="inline-flex h-10 items-center gap-2 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-sm font-medium text-white/80 hover:bg-white/[0.08] sm:px-4"><svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-refresh-cw"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg> <span class="hidden sm:inline">Folder Sync</span></button></div></header><nav class="flex gap-2 overflow-x-auto px-4 pt-4 scrollbar-hide sm:px-6" aria-label="Meeting groups" role="tablist"><button type="button" role="tab" aria-selected="true" data-testid="meetings-tab-all" class="shrink-0 rounded-full px-4 py-1.5 text-sm transition-colors bg-violet-600/80 font-semibold text-white">All</button><button type="button" role="tab" aria-selected="false" data-testid="meetings-tab-project" class="shrink-0 rounded-full px-4 py-1.5 text-sm transition-colors border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.07] hover:text-white">Project Meetings</button><button type="button" role="tab" aria-selected="false" data-testid="meetings-tab-recorded" class="shrink-0 rounded-full px-4 py-1.5 text-sm transition-colors border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.07] hover:text-white">Recorded</button><button type="button" role="tab" aria-selected="false" data-testid="meetings-tab-imported" class="shrink-0 rounded-full px-4 py-1.5 text-sm transition-colors border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.07] hover:text-white">Imported</button><button type="button" role="tab" aria-selected="false" data-testid="meetings-tab-unsorted" class="shrink-0 rounded-full px-4 py-1.5 text-sm transition-colors border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.07] hover:text-white">Unsorted</button><button type="button" role="tab" aria-selected="false" data-testid="meetings-tab-processing" class="shrink-0 rounded-full px-4 py-1.5 text-sm transition-colors border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.07] hover:text-white">Processing</button></nav><div class="flex flex-wrap items-center gap-2 px-4 pt-3 sm:gap-3 sm:px-6"><label class="relative min-w-0 flex-1 basis-[16rem]"><span class="sr-only">Search meetings</span><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-white/40"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg><input type="search" placeholder="Search meetings by title, transcript, or project..." class="h-11 w-full rounded-xl border border-white/10 bg-white/[0.03] pl-10 pr-3 text-sm text-white placeholder-white/40 focus:border-violet-400/60 focus:outline-none" data-testid="meetings-search" value=""></label><div class="relative"><button type="button" aria-expanded="false" class="inline-flex h-11 items-center gap-2 rounded-xl border border-white/10 bg-white/[0.03] px-3.5 text-sm text-white/80 hover:bg-white/[0.07]"><svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-down"><path d="m21 16-4 4-4-4"></path><path d="M17 20V4"></path><path d="m3 8 4-4 4 4"></path><path d="M7 4v16"></path></svg> Sort <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down text-white/50"><path d="m6 9 6 6 6-6"></path></svg></button></div><div class="relative"><button type="button" aria-expanded="false" class="inline-flex h-11 items-center gap-2 rounded-xl border px-3.5 text-sm hover:bg-white/[0.07] border-white/10 bg-white/[0.03] text-white/80"><svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-filter"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg> Filter</button></div></div><div class="flex-1 overflow-y-auto px-4 pb-8 pt-4 sm:px-6"><div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3" data-testid="meetings-grid"><div role="button" tabindex="0" aria-label="Architecture Review, Transcribed" data-testid="meeting-card-m1" class="group relative cursor-pointer rounded-2xl border border-white/10 bg-white/[0.03] p-5 transition-colors hover:border-white/20 hover:bg-white/[0.05] focus-visible:outline focus-visible:outline-2 focus-visible:outline-violet-400"><div class="flex flex-wrap items-center gap-2 pr-8"><span class="rounded-lg border px-2.5 py-0.5 text-xs font-medium bg-violet-500/15 text-violet-200 border-violet-400/25">Project Meeting</span><span class="inline-flex items-center gap-1 rounded-lg border px-2.5 py-0.5 text-xs font-medium bg-emerald-500/15 text-emerald-300 border-emerald-400/25">Transcribed</span></div><h3 class="mt-3 line-clamp-1 text-lg font-semibold text-white">Architecture Review</h3><p class="mt-1 line-clamp-2 min-h-[2.5rem] text-sm text-white/55">Discussed system architecture.</p><div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-1 text-xs text-white/55"><span class="inline-flex items-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar-days"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path><path d="M8 14h.01"></path><path d="M12 14h.01"></path><path d="M16 14h.01"></path><path d="M8 18h.01"></path><path d="M12 18h.01"></path><path d="M16 18h.01"></path></svg> 02/28/2026</span><span class="inline-flex items-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock3"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16.5 12"></polyline></svg> 52 min</span><span class="inline-flex min-w-0 items-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path></svg> <span class="truncate">Project: HomePilot</span></span></div><div class="absolute right-3 top-3"><button type="button" aria-label="More actions for Architecture Review" aria-expanded="false" class="grid h-8 w-8 place-items-center rounded-lg text-white/50 opacity-100 hover:bg-white/10 hover:text-white focus-visible:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ellipsis"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg></button></div></div><div role="button" tabindex="0" aria-label="Budget review, Transcribed" data-testid="meeting-card-m2" class="group relative cursor-pointer rounded-2xl border border-white/10 bg-white/[0.03] p-5 transition-colors hover:border-white/20 hover:bg-white/[0.05] focus-visible:outline focus-visible:outline-2 focus-visible:outline-violet-400"><div class="flex flex-wrap items-center gap-2 pr-8"><span class="rounded-lg border px-2.5 py-0.5 text-xs font-medium bg-blue-500/15 text-blue-200 border-blue-400/25">Client Call</span><span class="inline-flex items-center gap-1 rounded-lg border px-2.5 py-0.5 text-xs font-medium bg-emerald-500/15 text-emerald-300 border-emerald-400/25" title="Timestamps are approximate">Transcribed</span></div><h3 class="mt-3 line-clamp-1 text-lg font-semibold text-white">Budget review</h3><p class="mt-1 line-clamp-2 min-h-[2.5rem] text-sm text-white/55">Discussed system architecture.</p><div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-1 text-xs text-white/55"><span class="inline-flex items-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar-days"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path><path d="M8 14h.01"></path><path d="M12 14h.01"></path><path d="M16 14h.01"></path><path d="M8 18h.01"></path><path d="M12 18h.01"></path><path d="M16 18h.01"></path></svg> 02/28/2026</span><span class="inline-flex items-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock3"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16.5 12"></polyline></svg> 52 min</span><span class="inline-flex min-w-0 items-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path></svg> <span class="truncate">Project: HomePilot</span></span></div><div class="absolute right-3 top-3"><button type="button" aria-label="More actions for Budget review" aria-expanded="false" class="grid h-8 w-8 place-items-center rounded-lg text-white/50 opacity-100 hover:bg-white/10 hover:text-white focus-visible:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ellipsis"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg></button></div></div><div role="button" tabindex="0" aria-label="Kickoff, Awaiting recording" data-testid="meeting-card-m3" class="group relative cursor-pointer rounded-2xl border border-white/10 bg-white/[0.03] p-5 transition-colors hover:border-white/20 hover:bg-white/[0.05] focus-visible:outline focus-visible:outline-2 focus-visible:outline-violet-400"><div class="flex flex-wrap items-center gap-2 pr-8"><span class="rounded-lg border px-2.5 py-0.5 text-xs font-medium bg-violet-500/15 text-violet-200 border-violet-400/25">Project Meeting</span><span class="inline-flex items-center gap-1 rounded-lg border px-2.5 py-0.5 text-xs font-medium bg-white/[0.06] text-white/70 border-white/10">Awaiting recording</span></div><h3 class="mt-3 line-clamp-1 text-lg font-semibold text-white">Kickoff</h3><p class="mt-1 line-clamp-2 min-h-[2.5rem] text-sm text-white/55">Discussed system architecture.</p><div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-1 text-xs text-white/55"><span class="inline-flex items-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar-days"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path><path d="M8 14h.01"></path><path d="M12 14h.01"></path><path d="M16 14h.01"></path><path d="M8 18h.01"></path><path d="M12 18h.01"></path><path d="M16 18h.01"></path></svg> 03/01/2026</span><span class="inline-flex items-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock3"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16.5 12"></polyline></svg> 52 min</span><span class="inline-flex min-w-0 items-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path></svg> <span class="truncate">Project: HomePilot</span></span></div><div class="absolute right-3 top-3"><button type="button" aria-label="More actions for Kickoff" aria-expanded="false" class="grid h-8 w-8 place-items-center rounded-lg text-white/50 opacity-100 hover:bg-white/10 hover:text-white focus-visible:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ellipsis"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg></button></div></div></div></div></div>"`;
|
frontend/src/test/conversationHub.test.tsx
CHANGED
|
@@ -120,6 +120,27 @@ describe('ProjectAvatar', () => {
|
|
| 120 |
expect(screen.getByRole('img', { name: 'Angel' }).tagName).toBe('DIV')
|
| 121 |
})
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
it('a small avatar shows its fallback mark when no picture loads', () => {
|
| 124 |
const { container } = render(<AvatarImage urls={['http://hp/face', null]} className="a" fallback={<span>mark</span>} />)
|
| 125 |
const img = container.querySelector('img') as HTMLImageElement
|
|
|
|
| 120 |
expect(screen.getByRole('img', { name: 'Angel' }).tagName).toBe('DIV')
|
| 121 |
})
|
| 122 |
|
| 123 |
+
it('shows a loading shimmer until the picture has arrived, then the picture', () => {
|
| 124 |
+
render(<ProjectAvatar url="http://hp/files/big.png" fallbackUrl="http://hp/thumb.webp" name="Angel" projectType="persona" />)
|
| 125 |
+
const frame = document.querySelector('.hp-project-avatar') as HTMLElement
|
| 126 |
+
const img = screen.getByRole('img', { name: 'Angel' })
|
| 127 |
+
expect(frame).toHaveAttribute('aria-busy', 'true')
|
| 128 |
+
expect(frame.querySelector('.hp-project-avatar__skeleton')).not.toBeNull()
|
| 129 |
+
expect(img).toHaveAttribute('data-ready', 'false')
|
| 130 |
+
fireEvent.load(img)
|
| 131 |
+
expect(frame).not.toHaveAttribute('aria-busy')
|
| 132 |
+
expect(frame.querySelector('.hp-project-avatar__skeleton')).toBeNull()
|
| 133 |
+
expect(img).toHaveAttribute('data-ready', 'true')
|
| 134 |
+
})
|
| 135 |
+
|
| 136 |
+
it('a small avatar pulses until loaded', () => {
|
| 137 |
+
const { container } = render(<AvatarImage urls={['http://hp/thumb.webp']} className="h-8 w-8 rounded-full" />)
|
| 138 |
+
const img = container.querySelector('img') as HTMLImageElement
|
| 139 |
+
expect(img.className).toContain('hp-avatar-loading')
|
| 140 |
+
fireEvent.load(img)
|
| 141 |
+
expect(img.className).toBe('h-8 w-8 rounded-full')
|
| 142 |
+
})
|
| 143 |
+
|
| 144 |
it('a small avatar shows its fallback mark when no picture loads', () => {
|
| 145 |
const { container } = render(<AvatarImage urls={['http://hp/face', null]} className="a" fallback={<span>mark</span>} />)
|
| 146 |
const img = container.querySelector('img') as HTMLImageElement
|
frontend/src/test/ensureDeps.test.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* `npm run dev` first checks that node_modules has what package.json asks for
|
| 3 |
+
* (scripts/ensure-deps.mjs). A dependency added by a pull — react-easy-crop for
|
| 4 |
+
* Edit Profile Picture — used to stop Vite with "Failed to resolve import"
|
| 5 |
+
* because the start commands only installed when node_modules was missing.
|
| 6 |
+
*/
|
| 7 |
+
import { afterEach, describe, expect, it } from 'vitest'
|
| 8 |
+
import fs from 'node:fs'
|
| 9 |
+
import os from 'node:os'
|
| 10 |
+
import path from 'node:path'
|
| 11 |
+
import { fileURLToPath } from 'node:url'
|
| 12 |
+
import { staleDependencies } from '../../scripts/ensure-deps.mjs'
|
| 13 |
+
|
| 14 |
+
const dirs = []
|
| 15 |
+
afterEach(() => { for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }) })
|
| 16 |
+
|
| 17 |
+
function project({ deps = {}, devDeps = {}, lock = {}, installed = {} }) {
|
| 18 |
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hp-deps-'))
|
| 19 |
+
dirs.push(root)
|
| 20 |
+
const write = (rel, data) => {
|
| 21 |
+
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true })
|
| 22 |
+
fs.writeFileSync(path.join(root, rel), JSON.stringify(data))
|
| 23 |
+
}
|
| 24 |
+
write('package.json', { name: 'x', dependencies: deps, devDependencies: devDeps })
|
| 25 |
+
if (lock) {
|
| 26 |
+
const packages = { '': {} }
|
| 27 |
+
for (const [name, version] of Object.entries(lock)) packages[`node_modules/${name}`] = { version }
|
| 28 |
+
write('package-lock.json', { lockfileVersion: 3, packages })
|
| 29 |
+
}
|
| 30 |
+
for (const [name, version] of Object.entries(installed)) write(`node_modules/${name}/package.json`, { name, version })
|
| 31 |
+
return root
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
describe('the dependency check before npm run dev', () => {
|
| 35 |
+
it('is quiet when everything is installed at the pinned versions', () => {
|
| 36 |
+
const root = project({
|
| 37 |
+
deps: { react: '^18.3.1', 'react-easy-crop': '6.2.3' },
|
| 38 |
+
devDeps: { vite: '^5.0.0' },
|
| 39 |
+
lock: { react: '18.3.1', 'react-easy-crop': '6.2.3', vite: '5.4.0' },
|
| 40 |
+
installed: { react: '18.3.1', 'react-easy-crop': '6.2.3', vite: '5.4.0' },
|
| 41 |
+
})
|
| 42 |
+
expect(staleDependencies(root)).toEqual([])
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
it('finds a dependency a pull added but nobody installed', () => {
|
| 46 |
+
const root = project({
|
| 47 |
+
deps: { react: '^18.3.1', 'react-easy-crop': '6.2.3' },
|
| 48 |
+
lock: { react: '18.3.1', 'react-easy-crop': '6.2.3' },
|
| 49 |
+
installed: { react: '18.3.1' },
|
| 50 |
+
})
|
| 51 |
+
expect(staleDependencies(root)).toEqual([{ name: 'react-easy-crop', reason: 'not installed' }])
|
| 52 |
+
})
|
| 53 |
+
|
| 54 |
+
it('finds one installed at a different version from the lockfile', () => {
|
| 55 |
+
const root = project({
|
| 56 |
+
devDeps: { vite: '^5.0.0' },
|
| 57 |
+
lock: { vite: '5.4.0' },
|
| 58 |
+
installed: { vite: '5.1.0' },
|
| 59 |
+
})
|
| 60 |
+
expect(staleDependencies(root)).toEqual([{ name: 'vite', reason: '5.1.0 installed, 5.4.0 expected' }])
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
it('without a lockfile, only checks that each one is there', () => {
|
| 64 |
+
const root = project({ deps: { a: '^1.0.0', b: '^2.0.0' }, lock: null, installed: { a: '1.9.0' } })
|
| 65 |
+
expect(staleDependencies(root).map((s) => s.name)).toEqual(['b'])
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
it('npm run dev runs the check first, and it reads the real frontend files', () => {
|
| 69 |
+
const frontend = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
| 70 |
+
expect(Array.isArray(staleDependencies(frontend))).toBe(true)
|
| 71 |
+
const pkg = JSON.parse(fs.readFileSync(path.join(frontend, 'package.json'), 'utf8'))
|
| 72 |
+
expect(pkg.scripts.predev).toBe('node scripts/ensure-deps.mjs')
|
| 73 |
+
})
|
| 74 |
+
})
|
frontend/src/test/meetingIntelligence.test.tsx
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Meeting intelligence on the detail page: the visual summary renders only what the analysis
|
| 3 |
+
* holds and every timestamp seeks the recording; the insights panel is honest about its states
|
| 4 |
+
* and disappears when the server has the feature off; the assistant shows its citations.
|
| 5 |
+
*/
|
| 6 |
+
import React from 'react'
|
| 7 |
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
| 8 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
| 9 |
+
|
| 10 |
+
import { VisualSummary } from '../ui/meetingsense/library/meeting-summary/VisualSummary'
|
| 11 |
+
import { InsightsPanel } from '../ui/meetingsense/library/InsightsPanel'
|
| 12 |
+
import { AssistantDrawer } from '../ui/meetingsense/library/AssistantDrawer'
|
| 13 |
+
import { analysisToMarkdown, stampToMs, type MeetingAnalysis, type VisualSpec } from '../ui/meetingsense/library/intelligenceApi'
|
| 14 |
+
|
| 15 |
+
const analysis: MeetingAnalysis = {
|
| 16 |
+
schema_version: 1, meeting_id: 'm1', language: 'en', created_at: 1_772_272_800,
|
| 17 |
+
title: 'Fixing domain status detection', one_line: 'The team confirmed the Langfuse move.',
|
| 18 |
+
topics: [{ id: 't1', label: 'Domain status', t0_ms: 0, t1_ms: 30_000, summary: 'Unknown status is misread.' }],
|
| 19 |
+
decisions: [
|
| 20 |
+
{ id: 'd1', statement: 'Move prompt management to Langfuse', status: 'confirmed', evidence: [{ segment_id: 's4', t0_ms: 22_000, quote: 'move prompt management to Langfuse' }], confidence: 0.8 },
|
| 21 |
+
{ id: 'd2', statement: 'Add an MX lookup', status: 'proposed', evidence: [{ segment_id: 's3', t0_ms: 15_000, quote: 'second lookup' }], confidence: 0.6 },
|
| 22 |
+
],
|
| 23 |
+
risks: [{ id: 'r1', title: 'Unknown status treated as unregistered', severity: 'high', detail: 'The agent skips the DNS check.', root_cause: 'No consecutive lookups', solutions: ['Chain the DNS and MX checks'], status: 'open', evidence: [{ segment_id: 's2', t0_ms: 6_000, quote: 'treats unknown' }] }],
|
| 24 |
+
action_items: [{ id: 'a1', text: 'Inject fifty annotation tickets', owner: 'Ebony', due_text: 'by Friday', status: 'open', evidence: [{ segment_id: 's5', t0_ms: 30_000, quote: 'inject fifty' }], confidence: 0.8 }],
|
| 25 |
+
open_questions: [{ id: 'q1', text: 'Who owns the definition of unknown status?', evidence: [{ segment_id: 's6', t0_ms: 38_000, quote: 'Who owns' }] }],
|
| 26 |
+
conclusions: [{ id: 'c1', text: 'Consecutive lookups and Langfuse-managed prompts are the two fixes.', evidence: [{ segment_id: 's2', t0_ms: 6_000, quote: 'treats unknown' }] }],
|
| 27 |
+
speakers: [{ label: 'Ebony', talk_ms: 20_000, share: 0.5, lines: 2 }],
|
| 28 |
+
degraded: null, model: 'qwen', validator: { kept: 5, dropped: 1, repaired: 0, notes: [] },
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const spec: VisualSpec = {
|
| 32 |
+
schema_version: 1, analysis_id: 'an1', template: 'autopilot', blocks: [
|
| 33 |
+
{ type: 'header', title: analysis.title, text: analysis.one_line, items: [], columns: [], rows: [], meta: { degraded: null } },
|
| 34 |
+
{ type: 'kpi_row', items: [], columns: [], rows: [], meta: { kpis: [{ label: 'Decisions', value: 2 }, { label: 'Risks', value: 1 }] } },
|
| 35 |
+
{ type: 'risk_cards', title: 'Risks and problems', items: ['r1'], columns: [], rows: [], meta: {} },
|
| 36 |
+
{ type: 'decision_list', title: 'Decisions', items: ['d1', 'd2'], columns: [], rows: [], meta: {} },
|
| 37 |
+
{ type: 'comparison_table', title: 'Decision status at a glance', items: [], columns: ['Decision', 'Status', 'Said at'], rows: [['Move prompt management to Langfuse', 'Confirmed', '00:00:22']], meta: { status_column: 1, time_column: 2 } },
|
| 38 |
+
{ type: 'timeline', title: 'Action items', items: ['a1'], columns: [], rows: [], meta: {} },
|
| 39 |
+
{ type: 'section', title: 'Still open', items: ['q1'], columns: [], rows: [], tone: 'amber', meta: {} },
|
| 40 |
+
{ type: 'callout', title: 'Key conclusion', text: analysis.conclusions[0].text, items: ['c1'], columns: [], rows: [], meta: {} },
|
| 41 |
+
],
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
type Route = (url: URL, init?: RequestInit) => { status?: number; body: unknown }
|
| 45 |
+
let routes: Array<[RegExp, Route]> = []
|
| 46 |
+
const calls: Array<{ url: string; method: string; body?: any }> = []
|
| 47 |
+
function respond(pattern: RegExp, fn: Route) { routes.push([pattern, fn]) }
|
| 48 |
+
|
| 49 |
+
beforeEach(() => {
|
| 50 |
+
routes = []
|
| 51 |
+
calls.length = 0
|
| 52 |
+
sessionStorage.clear()
|
| 53 |
+
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
| 54 |
+
const url = new URL(String(input), 'http://hp.test')
|
| 55 |
+
const method = (init?.method || 'GET').toUpperCase()
|
| 56 |
+
let body: any
|
| 57 |
+
try { body = init?.body && typeof init.body === 'string' ? JSON.parse(init.body) : undefined } catch { body = undefined }
|
| 58 |
+
calls.push({ url: url.pathname, method, body })
|
| 59 |
+
for (const [pattern, fn] of routes) {
|
| 60 |
+
if (pattern.test(`${method} ${url.pathname}`)) {
|
| 61 |
+
const out = fn(url, init)
|
| 62 |
+
return new Response(JSON.stringify(out.body), { status: out.status || 200, headers: { 'content-type': 'application/json' } })
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
return new Response(JSON.stringify({ detail: 'not found' }), { status: 404, headers: { 'content-type': 'application/json' } })
|
| 66 |
+
}))
|
| 67 |
+
})
|
| 68 |
+
afterEach(() => { vi.unstubAllGlobals() })
|
| 69 |
+
|
| 70 |
+
describe('VisualSummary', () => {
|
| 71 |
+
it('renders every block from the analysis and seeks from any timestamp', () => {
|
| 72 |
+
const onSeek = vi.fn()
|
| 73 |
+
render(<VisualSummary analysis={analysis} spec={spec} onSeek={onSeek} />)
|
| 74 |
+
expect(screen.getByRole('heading', { name: 'Fixing domain status detection' })).toBeInTheDocument()
|
| 75 |
+
expect(screen.getByText('Unknown status treated as unregistered')).toBeInTheDocument()
|
| 76 |
+
expect(screen.getByText('High priority')).toBeInTheDocument()
|
| 77 |
+
expect(screen.getByText('Root cause:').parentElement).toHaveTextContent('No consecutive lookups')
|
| 78 |
+
expect(screen.getAllByText('Confirmed').length).toBeGreaterThanOrEqual(2) // list pill and table pill
|
| 79 |
+
expect(screen.getByText('Proposed')).toBeInTheDocument()
|
| 80 |
+
expect(screen.getByTestId('vs-timeline')).toHaveTextContent('Ebony')
|
| 81 |
+
expect(screen.getByTestId('vs-callout')).toHaveTextContent('Consecutive lookups')
|
| 82 |
+
// Any timestamp plays the recording from that line.
|
| 83 |
+
fireEvent.click(screen.getAllByRole('button', { name: 'Play from 00:00:22' })[0])
|
| 84 |
+
expect(onSeek).toHaveBeenCalledWith(22_000)
|
| 85 |
+
fireEvent.click(screen.getByRole('button', { name: 'Play from 00:00:30' }))
|
| 86 |
+
expect(onSeek).toHaveBeenCalledWith(30_000)
|
| 87 |
+
})
|
| 88 |
+
|
| 89 |
+
it('says plainly when the page is extractive, and never shows items the analysis lacks', () => {
|
| 90 |
+
const degraded = { ...analysis, degraded: 'extractive', decisions: [] }
|
| 91 |
+
const thin: VisualSpec = { ...spec, blocks: [{ ...spec.blocks[0], meta: { degraded: 'extractive' } }, spec.blocks[3]] }
|
| 92 |
+
render(<VisualSummary analysis={degraded} spec={thin} />)
|
| 93 |
+
expect(screen.getByText(/No language model answered/)).toBeInTheDocument()
|
| 94 |
+
expect(screen.queryByText('Decisions')).not.toBeInTheDocument() // an empty list block renders nothing
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
it('exports the analysis as readable Markdown with timestamps', () => {
|
| 98 |
+
const md = analysisToMarkdown(analysis)
|
| 99 |
+
expect(md).toContain('# Fixing domain status detection')
|
| 100 |
+
expect(md).toContain('**Confirmed:** Move prompt management to Langfuse _(00:00:22)_')
|
| 101 |
+
expect(md).toContain('- [ ] Inject fifty annotation tickets — Ebony (by Friday)')
|
| 102 |
+
expect(stampToMs('00:12:34')).toBe(754_000)
|
| 103 |
+
})
|
| 104 |
+
})
|
| 105 |
+
|
| 106 |
+
describe('InsightsPanel', () => {
|
| 107 |
+
it('renders nothing when the server has the feature off', async () => {
|
| 108 |
+
const { container } = render(<InsightsPanel meetingId="m1" hasTranscript />)
|
| 109 |
+
await waitFor(() => expect(calls.some((c) => c.url.endsWith('/analysis'))).toBe(true))
|
| 110 |
+
await waitFor(() => expect(container).toBeEmptyDOMElement())
|
| 111 |
+
})
|
| 112 |
+
|
| 113 |
+
it('offers to generate, then shows progress once the job is queued', async () => {
|
| 114 |
+
let state: 'none' | 'pending' = 'none'
|
| 115 |
+
respond(/GET .*\/analysis$/, () => ({ body: state === 'none' ? { state } : { state, job: { stage: 'analysing', progress: 0.3, state: 'running' } } }))
|
| 116 |
+
respond(/POST .*\/analysis$/, () => { state = 'pending'; return { status: 202, body: { job: { id: 'j1', state: 'queued' }, state: 'pending' } } })
|
| 117 |
+
render(<InsightsPanel meetingId="m1" hasTranscript />)
|
| 118 |
+
const cta = await screen.findByRole('button', { name: /Generate insights/ })
|
| 119 |
+
expect(screen.getByTestId('insights-cta')).toHaveTextContent('one-page summary')
|
| 120 |
+
fireEvent.click(cta)
|
| 121 |
+
await screen.findByTestId('insights-progress')
|
| 122 |
+
expect(screen.getByTestId('insights-progress')).toHaveTextContent('analysing')
|
| 123 |
+
expect(calls.filter((c) => c.method === 'POST').length).toBe(1)
|
| 124 |
+
})
|
| 125 |
+
|
| 126 |
+
const readyBody = () => ({
|
| 127 |
+
state: 'ready', id: 'an1', created_at: 1_772_272_800, model: 'qwen', degraded: null, versions: 1,
|
| 128 |
+
analysis, visual: spec, report: analysis.validator,
|
| 129 |
+
questions: [{ id: 'sq1', text: 'What was decided about prompt management?', kind: 'decisions' }, { id: 'sq2', text: 'Who owns the tickets?', kind: 'actions' }],
|
| 130 |
+
})
|
| 131 |
+
|
| 132 |
+
it('shows the compact card with counts, the verification line and clickable suggested questions when ready', async () => {
|
| 133 |
+
respond(/GET .*\/analysis$/, () => ({ body: readyBody() }))
|
| 134 |
+
const onAsk = vi.fn()
|
| 135 |
+
const onOpenVisual = vi.fn()
|
| 136 |
+
render(<InsightsPanel meetingId="m1" hasTranscript onAsk={onAsk} onOpenVisual={onOpenVisual} />)
|
| 137 |
+
const card = await screen.findByTestId('insights-compact')
|
| 138 |
+
expect(card).toHaveTextContent('Fixing domain status detection')
|
| 139 |
+
expect(card).toHaveTextContent('2 decisions')
|
| 140 |
+
expect(card).toHaveTextContent('1 risks')
|
| 141 |
+
expect(screen.getByText(/5 items verified against the transcript · 1 dropped/)).toBeInTheDocument()
|
| 142 |
+
expect(screen.getByText('Model · qwen')).toBeInTheDocument()
|
| 143 |
+
expect(screen.queryByTestId('visual-summary')).not.toBeInTheDocument() // the full page lives in its tab
|
| 144 |
+
fireEvent.click(screen.getByRole('button', { name: /Open visual summary/ }))
|
| 145 |
+
expect(onOpenVisual).toHaveBeenCalled()
|
| 146 |
+
fireEvent.click(screen.getByRole('button', { name: /What was decided about prompt management/ }))
|
| 147 |
+
expect(onAsk).toHaveBeenCalledWith('What was decided about prompt management?')
|
| 148 |
+
expect(screen.getByRole('button', { name: /Regenerate/ })).toBeEnabled()
|
| 149 |
+
})
|
| 150 |
+
|
| 151 |
+
it('shows the whole visual page in place when there is no tab to open', async () => {
|
| 152 |
+
respond(/GET .*\/analysis$/, () => ({ body: readyBody() }))
|
| 153 |
+
render(<InsightsPanel meetingId="m1" hasTranscript />)
|
| 154 |
+
await screen.findByTestId('visual-summary')
|
| 155 |
+
expect(screen.queryByTestId('insights-compact')).not.toBeInTheDocument()
|
| 156 |
+
})
|
| 157 |
+
|
| 158 |
+
it('waits for a transcript before offering anything', async () => {
|
| 159 |
+
respond(/GET .*\/analysis$/, () => ({ body: { state: 'no_transcript' } }))
|
| 160 |
+
render(<InsightsPanel meetingId="m1" hasTranscript={false} />)
|
| 161 |
+
expect(await screen.findByText(/written once there is a transcript/)).toBeInTheDocument()
|
| 162 |
+
expect(screen.queryByRole('button', { name: /Generate insights/ })).not.toBeInTheDocument()
|
| 163 |
+
})
|
| 164 |
+
})
|
| 165 |
+
|
| 166 |
+
describe('AssistantDrawer', () => {
|
| 167 |
+
it('answers with citations that seek the recording, and asks a seeded question on open', async () => {
|
| 168 |
+
respond(/POST .*\/ask$/, (_u, init) => {
|
| 169 |
+
const q = JSON.parse(String(init?.body)).text as string
|
| 170 |
+
return { body: { text: `Answer to: ${q}`, cited: ['00:00:22', '00:00:30'], degraded: null } }
|
| 171 |
+
})
|
| 172 |
+
const onSeek = vi.fn()
|
| 173 |
+
const onSeeded = vi.fn()
|
| 174 |
+
render(<AssistantDrawer meetingId="m1" open onClose={() => undefined} seed="What was decided?" onSeeded={onSeeded} onSeek={onSeek} hasTranscript />)
|
| 175 |
+
expect(await screen.findByText('Answer to: What was decided?')).toBeInTheDocument()
|
| 176 |
+
expect(onSeeded).toHaveBeenCalled()
|
| 177 |
+
fireEvent.click(screen.getByRole('button', { name: 'Play from 00:00:30' }))
|
| 178 |
+
expect(onSeek).toHaveBeenCalledWith(30_000)
|
| 179 |
+
// A typed follow-up goes through the same route and the history keeps both turns.
|
| 180 |
+
fireEvent.change(screen.getByLabelText('Ask a question about this meeting'), { target: { value: 'And who owns it?' } })
|
| 181 |
+
fireEvent.click(screen.getByRole('button', { name: 'Ask' }))
|
| 182 |
+
expect(await screen.findByText('Answer to: And who owns it?')).toBeInTheDocument()
|
| 183 |
+
expect(calls.filter((c) => c.method === 'POST').length).toBe(2)
|
| 184 |
+
expect(JSON.parse(sessionStorage.getItem('hp_meeting_assistant:m1') || '[]')).toHaveLength(2)
|
| 185 |
+
})
|
| 186 |
+
|
| 187 |
+
it('says when an answer is quoted from the transcript because no model answered', async () => {
|
| 188 |
+
respond(/POST .*\/ask$/, () => ({ body: { text: 'From the meeting transcript:\n[00:00:22] we agreed', cited: ['00:00:22'], degraded: 'extractive' } }))
|
| 189 |
+
render(<AssistantDrawer meetingId="m2" open onClose={() => undefined} seed="Anything?" hasTranscript />)
|
| 190 |
+
expect(await screen.findByText(/No language model answered — these are the transcript lines/)).toBeInTheDocument()
|
| 191 |
+
})
|
| 192 |
+
})
|
frontend/src/test/meetingsLandingGate.test.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* The Meetings landing page must not change while meeting intelligence lands in the detail
|
| 3 |
+
* view. This gate snapshots the rendered markup of `MeetingsHome` with a fixed library, so
|
| 4 |
+
* any change to its structure or classes fails here before anyone looks at a screenshot.
|
| 5 |
+
*
|
| 6 |
+
* Updating the snapshot is a deliberate act (`vitest -u`) that belongs in a pull request
|
| 7 |
+
* which says the landing page is meant to change.
|
| 8 |
+
*/
|
| 9 |
+
import React from 'react'
|
| 10 |
+
import { render, screen } from '@testing-library/react'
|
| 11 |
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
| 12 |
+
|
| 13 |
+
import { MeetingsHome } from '../ui/meetingsense/library/MeetingsHome'
|
| 14 |
+
import type { LibraryItem } from '../ui/meetingsense/library/api'
|
| 15 |
+
|
| 16 |
+
// Noon UTC, so the printed date is the same on every machine between UTC-11 and UTC+11.
|
| 17 |
+
const NOON = 1_772_280_000
|
| 18 |
+
|
| 19 |
+
function item(over: Partial<LibraryItem> = {}): LibraryItem {
|
| 20 |
+
return {
|
| 21 |
+
library_id: 'm1', meeting_id: 'm1', origin_type: 'import', origin_id: 'm1', title: 'Architecture Review',
|
| 22 |
+
description: 'Discussed system architecture.', category: 'Project Meeting', started_at: NOON, ended_at: null,
|
| 23 |
+
scheduled_for: null, duration_ms: 3_120_000, projects: [{ id: 'p1', name: 'HomePilot' }],
|
| 24 |
+
source: { kind: 'upload', label: 'Upload' }, media_present: true, recording_state: 'present',
|
| 25 |
+
transcript_state: 'ready', summary_state: 'ready', segments: 3, conversation_id: null, status: 'completed',
|
| 26 |
+
live: false, warnings: [], ...over,
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const LIBRARY = {
|
| 31 |
+
items: [
|
| 32 |
+
item(),
|
| 33 |
+
item({ library_id: 'm2', meeting_id: 'm2', title: 'Budget review', category: 'Client Call', origin_type: 'sync', source: { kind: 'sync', label: 'Synced folder' }, transcript_state: 'ready_warnings' }),
|
| 34 |
+
item({ library_id: 'm3', meeting_id: 'm3', title: 'Kickoff', origin_type: 'scheduled', recording_state: 'awaiting', transcript_state: 'none', media_present: false, scheduled_for: NOON + 86_400, started_at: null }),
|
| 35 |
+
],
|
| 36 |
+
next_cursor: null, total: 3, counts: { all: 3, imported: 2, scheduled: 1, processing: 0 },
|
| 37 |
+
facets: { sources: [{ kind: 'upload', label: 'Upload' }], projects: [{ id: 'p1', name: 'HomePilot' }] },
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
beforeEach(() => {
|
| 41 |
+
localStorage.clear()
|
| 42 |
+
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
| 43 |
+
const url = new URL(String(input), 'http://hp.test')
|
| 44 |
+
const body = url.pathname.endsWith('/jobs') ? { jobs: [], stt: { available: true } } : LIBRARY
|
| 45 |
+
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
|
| 46 |
+
}))
|
| 47 |
+
})
|
| 48 |
+
afterEach(() => { vi.unstubAllGlobals() })
|
| 49 |
+
|
| 50 |
+
it('keeps the Meetings landing page exactly as it is', async () => {
|
| 51 |
+
const { container } = render(<MeetingsHome status={{ enabled: true, intelligence: true } as any} onOpen={() => undefined} canRecordLive />)
|
| 52 |
+
await screen.findByTestId('meetings-grid')
|
| 53 |
+
await screen.findByText('Architecture Review')
|
| 54 |
+
expect(screen.getByTestId('meetings-home')).toBeInTheDocument()
|
| 55 |
+
for (const name of ['New Meeting', 'Import Files', 'Folder Sync']) expect(screen.getByRole('button', { name })).toBeInTheDocument()
|
| 56 |
+
expect(container.innerHTML).toMatchSnapshot()
|
| 57 |
+
})
|
frontend/src/test/meetingsLibrary.test.tsx
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Meetings Library: honest status chips, readable summaries, the library grid, the details
|
| 3 |
+
* panel that can be hidden and stays hidden, and a wizard that records nothing by itself.
|
| 4 |
+
*/
|
| 5 |
+
import React from 'react'
|
| 6 |
+
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
| 7 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
| 8 |
+
|
| 9 |
+
import {
|
| 10 |
+
isFallbackSummary,
|
| 11 |
+
overviewSummary,
|
| 12 |
+
plainText,
|
| 13 |
+
statusChip,
|
| 14 |
+
} from '../ui/meetingsense/library/format'
|
| 15 |
+
import { MeetingsHome } from '../ui/meetingsense/library/MeetingsHome'
|
| 16 |
+
import { MeetingDetailPage } from '../ui/meetingsense/library/MeetingDetailPage'
|
| 17 |
+
import { StartMeetingWizard, summaryStyleFor } from '../ui/meetingsense/library/StartMeetingWizard'
|
| 18 |
+
import { resample } from '../ui/meetingsense/library/WaveformPlayer'
|
| 19 |
+
import type { LibraryItem } from '../ui/meetingsense/library/api'
|
| 20 |
+
|
| 21 |
+
const OUTLINE =
|
| 22 |
+
"*No language model was reachable, so this is assembled from the meeting's own words rather than written.*\n\n" +
|
| 23 |
+
'## The meeting in order\n\n**00:00:05–00:01:18** — A modular approach.'
|
| 24 |
+
|
| 25 |
+
function item(over: Partial<LibraryItem> = {}): LibraryItem {
|
| 26 |
+
return {
|
| 27 |
+
library_id: 'm1', meeting_id: 'm1', origin_type: 'import', origin_id: 'm1', title: 'Architecture Review',
|
| 28 |
+
description: 'Discussed system architecture.', category: 'Project Meeting', started_at: 1772272800, ended_at: null,
|
| 29 |
+
scheduled_for: null, duration_ms: 3_120_000, projects: [{ id: 'p1', name: 'HomePilot' }],
|
| 30 |
+
source: { kind: 'upload', label: 'Upload' }, media_present: true, recording_state: 'present',
|
| 31 |
+
transcript_state: 'ready', summary_state: 'ready', segments: 3, conversation_id: null, status: 'completed',
|
| 32 |
+
live: false, warnings: [], ...over,
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
type Route = (url: URL, init?: RequestInit) => unknown
|
| 37 |
+
let routes: Array<[RegExp, Route]> = []
|
| 38 |
+
const calls: Array<{ url: string; method: string; body?: any }> = []
|
| 39 |
+
|
| 40 |
+
function respond(pattern: RegExp, fn: Route) {
|
| 41 |
+
routes.push([pattern, fn])
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
beforeEach(() => {
|
| 45 |
+
routes = []
|
| 46 |
+
calls.length = 0
|
| 47 |
+
localStorage.clear()
|
| 48 |
+
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
| 49 |
+
const url = new URL(String(input), 'http://hp.test')
|
| 50 |
+
const method = (init?.method || 'GET').toUpperCase()
|
| 51 |
+
let body: any
|
| 52 |
+
try { body = init?.body && typeof init.body === 'string' ? JSON.parse(init.body) : undefined } catch { body = undefined }
|
| 53 |
+
calls.push({ url: url.pathname + url.search, method, body })
|
| 54 |
+
for (const [pattern, fn] of routes) {
|
| 55 |
+
if (pattern.test(`${method} ${url.pathname}`)) {
|
| 56 |
+
return new Response(JSON.stringify(fn(url, init)), { status: 200, headers: { 'content-type': 'application/json' } })
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
return new Response(JSON.stringify({ detail: 'not mocked' }), { status: 404 })
|
| 60 |
+
}))
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
afterEach(() => {
|
| 64 |
+
vi.unstubAllGlobals()
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
describe('status chips', () => {
|
| 68 |
+
it('never calls an empty or silent recording transcribed', () => {
|
| 69 |
+
expect(statusChip(item({ transcript_state: 'no_speech' })).label).toBe('No speech')
|
| 70 |
+
expect(statusChip(item({ transcript_state: 'none', media_present: true })).label).toBe('Needs Transcript')
|
| 71 |
+
expect(statusChip(item({ transcript_state: 'none', media_present: false, recording_state: 'awaiting' })).label).toBe('Awaiting recording')
|
| 72 |
+
expect(statusChip(item({ transcript_state: 'needs_setup' })).label).toBe('Needs setup')
|
| 73 |
+
expect(statusChip(item({ transcript_state: 'transcript_only' })).label).toBe('Transcript only')
|
| 74 |
+
expect(statusChip(item({ live: true })).label).toBe('Recording')
|
| 75 |
+
})
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
describe('summaries', () => {
|
| 79 |
+
it('skips the no-model outline and falls back to notes or the description', () => {
|
| 80 |
+
expect(isFallbackSummary({ text: OUTLINE })).toBe(true)
|
| 81 |
+
expect(overviewSummary([{ text: OUTLINE }], 'Notes summary.', 'Description.')).toEqual({ text: 'Notes summary.', markdown: false })
|
| 82 |
+
expect(overviewSummary([{ text: OUTLINE }], '', 'Description.')).toEqual({ text: 'Description.', markdown: false })
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
it('shows the written summary without its long outline', () => {
|
| 86 |
+
const doc = { text: '## Summary\n\nWe chose **React Native**.\n\n## The meeting in order\n\n**00:00** — long outline' }
|
| 87 |
+
const out = overviewSummary([{ text: OUTLINE }, doc], 'notes', 'desc')
|
| 88 |
+
expect(out.markdown).toBe(true)
|
| 89 |
+
expect(out.text).toContain('React Native')
|
| 90 |
+
expect(out.text).not.toContain('meeting in order')
|
| 91 |
+
expect(plainText(out.text)).toBe('Summary\n\nWe chose React Native.')
|
| 92 |
+
})
|
| 93 |
+
|
| 94 |
+
it('maps the chosen outputs to a summary style', () => {
|
| 95 |
+
expect(summaryStyleFor(['transcript', 'minutes'])).toBe('minutes')
|
| 96 |
+
expect(summaryStyleFor(['summary', 'actions'])).toBe('notes')
|
| 97 |
+
expect(summaryStyleFor(['actions'])).toBe('actions')
|
| 98 |
+
})
|
| 99 |
+
})
|
| 100 |
+
|
| 101 |
+
describe('waveform', () => {
|
| 102 |
+
it('keeps the loudest peak of each bucket when there are more peaks than bars', () => {
|
| 103 |
+
expect(resample([0.1, 0.9, 0.2, 0.3, 0.8, 0.1], 3)).toEqual([0.9, 0.3, 0.8])
|
| 104 |
+
expect(resample([0.5, 0.4], 10)).toEqual([0.5, 0.4])
|
| 105 |
+
expect(resample(Array.from({ length: 400 }, (_, i) => i / 400), 100)).toHaveLength(100)
|
| 106 |
+
})
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
describe('Meetings home', () => {
|
| 110 |
+
it('lists meetings as cards and asks the server for the chosen tab', async () => {
|
| 111 |
+
respond(/GET \/v1\/meetingsense\/library$/, (url) => ({
|
| 112 |
+
items: url.searchParams.get('tab') === 'imported'
|
| 113 |
+
? [item({ library_id: 'm2', meeting_id: 'm2', title: 'Imported meeting · Oct 10, 12:31', transcript_state: 'no_speech' })]
|
| 114 |
+
: [item(), item({ library_id: 's1', meeting_id: 's1', title: 'Sprint Planning', origin_type: 'scheduled', transcript_state: 'none', media_present: false, recording_state: 'awaiting' })],
|
| 115 |
+
next_cursor: null, total: 2, counts: {}, facets: { sources: [], projects: [] },
|
| 116 |
+
}))
|
| 117 |
+
respond(/GET \/v1\/meetingsense\/jobs/, () => ({ jobs: [], stt: { available: true } }))
|
| 118 |
+
const onOpen = vi.fn()
|
| 119 |
+
render(<MeetingsHome status={{ enabled: true }} onOpen={onOpen} />)
|
| 120 |
+
|
| 121 |
+
const grid = await screen.findByTestId('meetings-grid')
|
| 122 |
+
expect(within(grid).getByText('Architecture Review')).toBeTruthy()
|
| 123 |
+
expect(within(grid).getByText('Awaiting recording')).toBeTruthy()
|
| 124 |
+
// Icon-only on phones, so the header actions carry their names themselves.
|
| 125 |
+
for (const name of ['New Meeting', 'Import Files', 'Folder Sync']) expect(screen.getByRole('button', { name })).toBeTruthy()
|
| 126 |
+
|
| 127 |
+
fireEvent.click(screen.getByTestId('meetings-tab-imported'))
|
| 128 |
+
await screen.findByText('Imported meeting · Oct 10, 12:31')
|
| 129 |
+
expect(screen.getByText('No speech')).toBeTruthy()
|
| 130 |
+
expect(calls.some((c) => c.url.includes('tab=imported'))).toBe(true)
|
| 131 |
+
expect(localStorage.getItem('hp_meetings_tab')).toBe('imported')
|
| 132 |
+
|
| 133 |
+
fireEvent.click(screen.getByTestId('meeting-card-m2'))
|
| 134 |
+
expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ library_id: 'm2' }))
|
| 135 |
+
})
|
| 136 |
+
})
|
| 137 |
+
|
| 138 |
+
describe('Meeting detail', () => {
|
| 139 |
+
function detail(over: Record<string, unknown> = {}) {
|
| 140 |
+
return {
|
| 141 |
+
item: item(),
|
| 142 |
+
segments: [
|
| 143 |
+
{ id: 'a', t0_ms: 5000, t1_ms: 9000, speaker: 'Jane Smith', text: "Let's start with the architecture proposal." },
|
| 144 |
+
{ id: 'b', t0_ms: 16000, t1_ms: 20000, speaker: 'Michael Lee', text: 'A modular approach works.' },
|
| 145 |
+
],
|
| 146 |
+
notes: { version: 1, updated_at: 0, notes: { summary: 'Agreed on a modular architecture.', decisions: ['Adopt React Native.'], actions: [{ text: 'Write the design doc', owner: 'Sarah Park', due: 'Mar 7' }] } },
|
| 147 |
+
summaries: [{ id: 's1', text: OUTLINE, degraded: 'extractive', created_at: 1772272900 }],
|
| 148 |
+
assets: [], jobs: [], versions: [{ id: 'v1', origin: 'import', timing: 'imported', active: 1, segment_count: 2, provider: null, model: null, language: null, note: null, created_at: 0 }],
|
| 149 |
+
...over,
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
it('shows the notes instead of the raw outline, and remembers a hidden panel', async () => {
|
| 154 |
+
localStorage.setItem('hp_meeting_details_open', '1')
|
| 155 |
+
respond(/GET \/v1\/meetingsense\/library\/item\/m1$/, () => detail())
|
| 156 |
+
const { unmount } = render(<MeetingDetailPage libraryId="m1" onBack={() => undefined} />)
|
| 157 |
+
|
| 158 |
+
await screen.findByRole('heading', { name: 'Architecture Review' })
|
| 159 |
+
expect(screen.getAllByText('Agreed on a modular architecture.').length).toBeGreaterThan(0)
|
| 160 |
+
expect(screen.queryByText(/No language model was reachable/)).toBeNull()
|
| 161 |
+
expect(screen.getByText('Adopt React Native.')).toBeTruthy()
|
| 162 |
+
|
| 163 |
+
const panel = screen.getByRole('complementary', { name: 'Meeting details' })
|
| 164 |
+
expect(within(panel).getByText('Write the design doc')).toBeTruthy()
|
| 165 |
+
|
| 166 |
+
const toggle = screen.getByTestId('details-toggle')
|
| 167 |
+
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
| 168 |
+
fireEvent.click(toggle)
|
| 169 |
+
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
| 170 |
+
expect(screen.queryByRole('complementary', { name: 'Meeting details' })).toBeNull()
|
| 171 |
+
expect(localStorage.getItem('hp_meeting_details_open')).toBe('0')
|
| 172 |
+
unmount()
|
| 173 |
+
|
| 174 |
+
render(<MeetingDetailPage libraryId="m1" onBack={() => undefined} />)
|
| 175 |
+
await screen.findByRole('heading', { name: 'Architecture Review' })
|
| 176 |
+
expect(screen.queryByRole('complementary', { name: 'Meeting details' })).toBeNull()
|
| 177 |
+
expect(screen.getByTestId('details-toggle').textContent).toContain('Show details')
|
| 178 |
+
})
|
| 179 |
+
|
| 180 |
+
it('resizes the details panel from the keyboard within its limits', async () => {
|
| 181 |
+
localStorage.setItem('hp_meeting_details_open', '1')
|
| 182 |
+
respond(/GET \/v1\/meetingsense\/library\/item\/m1$/, () => detail())
|
| 183 |
+
render(<MeetingDetailPage libraryId="m1" onBack={() => undefined} />)
|
| 184 |
+
const handle = await screen.findByRole('separator', { name: 'Resize details' })
|
| 185 |
+
expect(handle.getAttribute('aria-valuenow')).toBe('360')
|
| 186 |
+
fireEvent.keyDown(handle, { key: 'ArrowLeft' })
|
| 187 |
+
expect(handle.getAttribute('aria-valuenow')).toBe('380')
|
| 188 |
+
for (let i = 0; i < 20; i += 1) fireEvent.keyDown(handle, { key: 'ArrowLeft' })
|
| 189 |
+
expect(handle.getAttribute('aria-valuenow')).toBe('520')
|
| 190 |
+
expect(localStorage.getItem('hp_meeting_details_width')).toBe('520')
|
| 191 |
+
})
|
| 192 |
+
|
| 193 |
+
it('renders a written summary as text, not markdown symbols', async () => {
|
| 194 |
+
localStorage.setItem('hp_meeting_details_open', '1')
|
| 195 |
+
respond(/GET \/v1\/meetingsense\/library\/item\/m1$/, () => detail({
|
| 196 |
+
notes: null,
|
| 197 |
+
summaries: [{ id: 's2', text: '## Decisions\n\n- Keep **Kubernetes**.', created_at: 1772272900 }],
|
| 198 |
+
}))
|
| 199 |
+
render(<MeetingDetailPage libraryId="m1" onBack={() => undefined} />)
|
| 200 |
+
const strong = await screen.findByText('Kubernetes')
|
| 201 |
+
expect(strong.tagName).toBe('STRONG')
|
| 202 |
+
expect(screen.queryByText(/\*\*Kubernetes\*\*/)).toBeNull()
|
| 203 |
+
})
|
| 204 |
+
|
| 205 |
+
it('says plainly why a silent recording has no transcript', async () => {
|
| 206 |
+
respond(/GET \/v1\/meetingsense\/library\/item\/m3$/, () => detail({
|
| 207 |
+
item: item({ library_id: 'm3', meeting_id: 'm3', transcript_state: 'no_speech', summary_state: 'none',
|
| 208 |
+
warnings: ['No speech detected — the recording is silent (loudest moment -70.3 dB).'] }),
|
| 209 |
+
segments: [], notes: null, summaries: [], versions: [],
|
| 210 |
+
}))
|
| 211 |
+
render(<MeetingDetailPage libraryId="m3" onBack={() => undefined} />)
|
| 212 |
+
expect(await screen.findByText('No speech was detected in the recording.')).toBeTruthy()
|
| 213 |
+
expect(screen.getByText(/loudest moment -70.3 dB/)).toBeTruthy()
|
| 214 |
+
})
|
| 215 |
+
})
|
| 216 |
+
|
| 217 |
+
describe('Start a meeting wizard', () => {
|
| 218 |
+
it('needs a title, then schedules the meeting without recording anything', async () => {
|
| 219 |
+
respond(/GET \/projects$/, () => ({ projects: [{ id: 'p1', name: 'HomePilot' }] }))
|
| 220 |
+
respond(/POST \/v1\/meetingsense\/library\/meetings$/, () => ({ meeting_id: 'new1' }))
|
| 221 |
+
const onCreated = vi.fn()
|
| 222 |
+
render(<StartMeetingWizard onClose={() => undefined} onCreated={onCreated} />)
|
| 223 |
+
|
| 224 |
+
fireEvent.click(screen.getByRole('button', { name: /Next: Recording source/ }))
|
| 225 |
+
expect(screen.getByRole('alert').textContent).toContain('Give the meeting a title.')
|
| 226 |
+
|
| 227 |
+
fireEvent.change(screen.getByPlaceholderText('Project planning meeting'), { target: { value: 'Q4 roadmap review' } })
|
| 228 |
+
fireEvent.click(screen.getByText('Schedule for later'))
|
| 229 |
+
fireEvent.click(screen.getByRole('button', { name: /Next: Recording source/ }))
|
| 230 |
+
fireEvent.click(screen.getByRole('button', { name: /Next: Assistant/ }))
|
| 231 |
+
fireEvent.click(screen.getByRole('button', { name: /Next: Summary/ }))
|
| 232 |
+
fireEvent.click(screen.getByRole('button', { name: /Schedule meeting/ }))
|
| 233 |
+
|
| 234 |
+
await waitFor(() => expect(onCreated).toHaveBeenCalled())
|
| 235 |
+
const post = calls.find((c) => c.method === 'POST' && c.url.startsWith('/v1/meetingsense/library/meetings'))
|
| 236 |
+
expect(post?.body?.title).toBe('Q4 roadmap review')
|
| 237 |
+
expect(post?.body?.scheduled_for).toBeGreaterThan(Date.now() / 1000)
|
| 238 |
+
expect(calls.some((c) => /\/ws|\/start/.test(c.url))).toBe(false)
|
| 239 |
+
})
|
| 240 |
+
})
|
frontend/src/test/visualSummaryTab.test.tsx
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* The Visual Summary tab: the Executive Brief renders only what the server verified, numbers
|
| 3 |
+
* the sections that have content and hides the rest, shows decision status exactly as
|
| 4 |
+
* verified, and every stamp leads back to the transcript. The toolbar re-writes, exports and
|
| 5 |
+
* presents the same brief without inventing anything.
|
| 6 |
+
*/
|
| 7 |
+
import React from 'react'
|
| 8 |
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
| 9 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
| 10 |
+
|
| 11 |
+
import { ExecutiveBriefView } from '../ui/meetingsense/library/meeting-summary/ExecutiveBrief'
|
| 12 |
+
import { VisualSummaryTab } from '../ui/meetingsense/library/VisualSummaryTab'
|
| 13 |
+
import { slidesFor } from '../ui/meetingsense/library/PresentMode'
|
| 14 |
+
import { printBrief, safeFilename } from '../ui/meetingsense/library/briefExport'
|
| 15 |
+
import type { ExecutiveBrief, MeetingAnalysis, VisualSpec } from '../ui/meetingsense/library/intelligenceApi'
|
| 16 |
+
import type { LibraryItem } from '../ui/meetingsense/library/api'
|
| 17 |
+
|
| 18 |
+
const analysis: MeetingAnalysis = {
|
| 19 |
+
schema_version: 1, meeting_id: 'm1', language: 'en', created_at: 1_772_272_800,
|
| 20 |
+
title: 'Agent debugging sync', one_line: 'The team confirmed the Langfuse move.',
|
| 21 |
+
topics: [],
|
| 22 |
+
decisions: [
|
| 23 |
+
{ id: 'd1', statement: 'Move prompt management to Langfuse', status: 'confirmed', evidence: [{ segment_id: 's4', t0_ms: 22_000, quote: 'move prompt management to Langfuse' }], confidence: 0.8 },
|
| 24 |
+
{ id: 'd2', statement: 'Add an MX record lookup before deciding', status: 'proposed', evidence: [{ segment_id: 's3', t0_ms: 15_000, quote: 'second lookup' }], confidence: 0.6 },
|
| 25 |
+
],
|
| 26 |
+
risks: [
|
| 27 |
+
{ id: 'r1', title: 'Unknown status treated as unregistered', severity: 'high', detail: 'The agent skips the DNS check.', root_cause: 'No consecutive lookups', solutions: [], status: 'open', evidence: [{ segment_id: 's2', t0_ms: 6_000, quote: 'treats unknown' }] },
|
| 28 |
+
{ id: 'r2', title: 'MX lookup tool missing in the test environment', severity: 'medium', detail: 'The MX record tool is unavailable.', root_cause: '', solutions: [], status: 'open', evidence: [{ segment_id: 's7', t0_ms: 44_000, quote: 'missing MX record tool' }] },
|
| 29 |
+
],
|
| 30 |
+
action_items: [
|
| 31 |
+
{ id: 'a1', text: 'Inject fifty annotation tickets', owner: 'Ebony', due_text: 'by Friday', status: 'open', evidence: [{ segment_id: 's5', t0_ms: 30_000, quote: 'inject fifty' }], confidence: 0.8 },
|
| 32 |
+
{ id: 'a2', text: 'Ask the business systems team for access', owner: 'Vasi', due_text: 'next week', status: 'open', evidence: [{ segment_id: 's8', t0_ms: 52_000, quote: 'business systems team' }], confidence: 0.7 },
|
| 33 |
+
],
|
| 34 |
+
open_questions: [{ id: 'q1', text: 'Who owns the definition of unknown status?', evidence: [{ segment_id: 's6', t0_ms: 38_000, quote: 'Who owns' }] }],
|
| 35 |
+
conclusions: [{ id: 'c1', text: 'Consecutive lookups and Langfuse-managed prompts are the two fixes.', evidence: [{ segment_id: 's2', t0_ms: 6_000, quote: 'treats unknown' }] }],
|
| 36 |
+
speakers: [], degraded: null, model: 'qwen', validator: { kept: 7, dropped: 0, repaired: 0, notes: [] },
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const brief: ExecutiveBrief = {
|
| 40 |
+
eyebrow: 'Technical meeting · Executive brief',
|
| 41 |
+
title: 'Domain status fixes and Langfuse prompt management',
|
| 42 |
+
intro: 'The team traced agent failures to unknown domain status handling.',
|
| 43 |
+
risks: [{ item: 'r1', title: 'Ambiguous domain status handling', summary: 'Unknown statuses are treated as unregistered.', note: 'No consecutive lookups', severity: 'high', polished: true }],
|
| 44 |
+
blockers: [{ item: 'r2', title: 'MX lookup tool missing in the test environment', summary: 'The MX record tool is unavailable.', note: '', severity: 'medium', polished: false }],
|
| 45 |
+
decisions: [
|
| 46 |
+
{ item: 'd1', option: 'Langfuse prompt management', status: 'confirmed', status_label: 'Selected', impact: 'Prompt changes without a redeploy.', polished: true },
|
| 47 |
+
{ item: 'd2', option: 'Add an MX record lookup before deciding', status: 'proposed', status_label: 'Proposed', impact: '', polished: false },
|
| 48 |
+
],
|
| 49 |
+
actions: [
|
| 50 |
+
{ item: 'a1', bucket: 'This week', label: 'This week', text: 'Inject fifty annotation tickets', owner: 'Ebony', polished: false },
|
| 51 |
+
{ item: 'a2', bucket: 'Next steps', label: 'Next steps', text: 'Ask the business systems team for access', owner: 'Vasi', polished: false },
|
| 52 |
+
],
|
| 53 |
+
open_questions: ['q1'],
|
| 54 |
+
takeaway: 'Chain the DNS and MX checks and move prompt management to Langfuse.', takeaway_items: ['r1', 'd1'],
|
| 55 |
+
source_count: 7, polished: true, notes: ['d2: proposal kept in its own words.'],
|
| 56 |
+
}
|
| 57 |
+
const spec: VisualSpec = { schema_version: 1, analysis_id: 'an1', template: 'executive_brief', blocks: [], brief }
|
| 58 |
+
const item = {
|
| 59 |
+
title: 'Agent debugging sync', started_at: 1_772_272_800, scheduled_for: null, duration_ms: 1_500_000,
|
| 60 |
+
projects: [{ id: 'p1', name: 'Agents' }], source: { kind: 'import', label: 'Imported' },
|
| 61 |
+
} as unknown as LibraryItem
|
| 62 |
+
|
| 63 |
+
type Route = (url: URL, init?: RequestInit) => { status?: number; body: unknown }
|
| 64 |
+
let routes: Array<[RegExp, Route]> = []
|
| 65 |
+
const calls: Array<{ url: string; method: string }> = []
|
| 66 |
+
function respond(pattern: RegExp, fn: Route) { routes.push([pattern, fn]) }
|
| 67 |
+
|
| 68 |
+
beforeEach(() => {
|
| 69 |
+
routes = []
|
| 70 |
+
calls.length = 0
|
| 71 |
+
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
| 72 |
+
const url = new URL(String(input), 'http://hp.test')
|
| 73 |
+
const method = (init?.method || 'GET').toUpperCase()
|
| 74 |
+
calls.push({ url: url.pathname, method })
|
| 75 |
+
for (const [pattern, fn] of routes) {
|
| 76 |
+
if (pattern.test(`${method} ${url.pathname}`)) {
|
| 77 |
+
const out = fn(url, init)
|
| 78 |
+
return new Response(JSON.stringify(out.body), { status: out.status || 200, headers: { 'content-type': 'application/json' } })
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
return new Response(JSON.stringify({ detail: 'not found' }), { status: 404, headers: { 'content-type': 'application/json' } })
|
| 82 |
+
}))
|
| 83 |
+
})
|
| 84 |
+
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers() })
|
| 85 |
+
|
| 86 |
+
const readyBody = () => ({
|
| 87 |
+
state: 'ready', id: 'an1', created_at: 1_772_272_800, model: 'qwen', degraded: null, versions: 1,
|
| 88 |
+
analysis, visual: spec, report: analysis.validator,
|
| 89 |
+
questions: [{ id: 'sq1', text: 'What was decided about prompt management?', kind: 'decisions' }],
|
| 90 |
+
})
|
| 91 |
+
|
| 92 |
+
describe('ExecutiveBriefView', () => {
|
| 93 |
+
it('numbers the sections that have content, derives pills from verified status, and leads to the source', () => {
|
| 94 |
+
const onEvidence = vi.fn()
|
| 95 |
+
render(<ExecutiveBriefView analysis={analysis} brief={brief} onEvidence={onEvidence} meta={{ date: '2 Mar 2026', duration: '25 min' }} />)
|
| 96 |
+
expect(screen.getByRole('heading', { name: 'Domain status fixes and Langfuse prompt management' })).toBeInTheDocument()
|
| 97 |
+
expect(screen.getByText('01 / Core risks & blockers')).toBeInTheDocument()
|
| 98 |
+
expect(screen.getByText('02 / Decision comparison')).toBeInTheDocument()
|
| 99 |
+
expect(screen.getByText('03 / Next actions')).toBeInTheDocument()
|
| 100 |
+
expect(screen.getByText('04 / Still open')).toBeInTheDocument()
|
| 101 |
+
expect(screen.getByTestId('brief-card-r1')).toHaveTextContent('HIGH PRIORITY')
|
| 102 |
+
expect(screen.getByTestId('brief-card-r2')).toHaveTextContent('BLOCKER')
|
| 103 |
+
// Status pills come from the verified status, never from the wording.
|
| 104 |
+
expect(screen.getByTestId('brief-row-d1')).toHaveTextContent('Selected')
|
| 105 |
+
expect(screen.getByTestId('brief-row-d2')).toHaveTextContent('Proposed')
|
| 106 |
+
expect(screen.getByTestId('brief-timeline')).toHaveTextContent('This week')
|
| 107 |
+
expect(screen.getByTestId('brief-timeline')).toHaveTextContent('Next steps')
|
| 108 |
+
expect(screen.getByTestId('brief-takeaway')).toHaveTextContent('Chain the DNS and MX checks')
|
| 109 |
+
expect(screen.getByText(/Grounded in 7 transcript moments/)).toBeInTheDocument()
|
| 110 |
+
expect(screen.getByText(/2 Mar 2026 · 25 min/)).toBeInTheDocument()
|
| 111 |
+
// d1 is cited twice (its row and the takeaway); either stamp opens the same line.
|
| 112 |
+
fireEvent.click(screen.getAllByRole('button', { name: 'View source at 00:00:22' })[0])
|
| 113 |
+
expect(onEvidence).toHaveBeenCalledWith(22_000, 'd1')
|
| 114 |
+
})
|
| 115 |
+
|
| 116 |
+
it('hides empty sections and renumbers the rest, and never invents a blocker or a question', () => {
|
| 117 |
+
const thin: ExecutiveBrief = { ...brief, risks: [], blockers: [], open_questions: [], actions: [brief.actions[0]] }
|
| 118 |
+
render(<ExecutiveBriefView analysis={analysis} brief={thin} />)
|
| 119 |
+
expect(screen.queryByText(/Core risks/)).not.toBeInTheDocument()
|
| 120 |
+
expect(screen.queryByText(/Still open/)).not.toBeInTheDocument()
|
| 121 |
+
expect(screen.getByText('01 / Decision comparison')).toBeInTheDocument()
|
| 122 |
+
expect(screen.getByText('02 / Next actions')).toBeInTheDocument()
|
| 123 |
+
// Without a handler, stamps are plain text: nothing to click, nothing broken.
|
| 124 |
+
expect(screen.queryByRole('button', { name: /View source/ })).not.toBeInTheDocument()
|
| 125 |
+
expect(screen.getAllByText('00:00:22').length).toBeGreaterThan(0) // the decision row and the takeaway both cite d1
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
it('presents only the sections that exist as slides', () => {
|
| 129 |
+
expect(slidesFor(brief)).toEqual([['header'], ['risks'], ['decisions'], ['actions'], ['questions'], ['takeaway', 'footer']])
|
| 130 |
+
expect(slidesFor({ ...brief, actions: [], open_questions: [], takeaway: '' })).toEqual([['header'], ['risks'], ['decisions']])
|
| 131 |
+
})
|
| 132 |
+
})
|
| 133 |
+
|
| 134 |
+
describe('VisualSummaryTab', () => {
|
| 135 |
+
it('offers to generate when nothing exists yet, and queues the analysis', async () => {
|
| 136 |
+
let state = 'none'
|
| 137 |
+
respond(/GET .*\/analysis$/, () => ({ body: state === 'none' ? { state } : { state, job: { stage: 'writing the brief', progress: 0.6, state: 'running' } } }))
|
| 138 |
+
respond(/POST .*\/analysis$/, () => { state = 'pending'; return { status: 202, body: { job: { id: 'j1', state: 'queued' }, state: 'pending' } } })
|
| 139 |
+
render(<VisualSummaryTab meetingId="m1" item={item} hasTranscript />)
|
| 140 |
+
fireEvent.click(await screen.findByRole('button', { name: /Generate visual summary/ }))
|
| 141 |
+
await screen.findByTestId('visual-progress')
|
| 142 |
+
expect(screen.getByTestId('visual-progress')).toHaveTextContent('writing the brief')
|
| 143 |
+
expect(calls.filter((c) => c.method === 'POST').map((c) => c.url)).toEqual(['/v1/meetingsense/library/item/m1/analysis'])
|
| 144 |
+
})
|
| 145 |
+
|
| 146 |
+
it('renders the brief, rewrites it on request without re-analysing, and leads from the footer to the first source', async () => {
|
| 147 |
+
respond(/GET .*\/analysis$/, () => ({ body: readyBody() }))
|
| 148 |
+
respond(/POST .*\/visual$/, () => ({ status: 202, body: { job: { id: 'j2', state: 'queued' }, state: 'pending' } }))
|
| 149 |
+
const onEvidence = vi.fn()
|
| 150 |
+
const onAsk = vi.fn()
|
| 151 |
+
render(<VisualSummaryTab meetingId="m1" item={item} hasTranscript onEvidence={onEvidence} onAsk={onAsk} />)
|
| 152 |
+
await screen.findByTestId('executive-brief')
|
| 153 |
+
expect(screen.getByTestId('visual-footer')).toHaveTextContent('Grounded in 7 referenced transcript moments')
|
| 154 |
+
expect(screen.getByText(/AI-written wording · verified · qwen/)).toBeInTheDocument()
|
| 155 |
+
fireEvent.click(screen.getByRole('button', { name: /View source evidence/ }))
|
| 156 |
+
expect(onEvidence).toHaveBeenCalledWith(6_000, 'r1')
|
| 157 |
+
fireEvent.click(screen.getByRole('button', { name: 'View source at 00:00:44' }))
|
| 158 |
+
expect(onEvidence).toHaveBeenCalledWith(44_000, 'r2')
|
| 159 |
+
fireEvent.click(screen.getByRole('button', { name: /What was decided about prompt management/ }))
|
| 160 |
+
expect(onAsk).toHaveBeenCalledWith('What was decided about prompt management?')
|
| 161 |
+
fireEvent.click(screen.getByRole('button', { name: /Rewrite brief/ }))
|
| 162 |
+
await waitFor(() => expect(calls.filter((c) => c.method === 'POST').map((c) => c.url)).toEqual(['/v1/meetingsense/library/item/m1/visual']))
|
| 163 |
+
// The validation notes are there for whoever wants them, folded away by default.
|
| 164 |
+
fireEvent.click(screen.getByRole('button', { name: /What was checked/ }))
|
| 165 |
+
expect(screen.getByText('d2: proposal kept in its own words.')).toBeInTheDocument()
|
| 166 |
+
})
|
| 167 |
+
|
| 168 |
+
it('presents the same brief as slides, moves with the keyboard, and leaves on Escape', async () => {
|
| 169 |
+
respond(/GET .*\/analysis$/, () => ({ body: readyBody() }))
|
| 170 |
+
render(<VisualSummaryTab meetingId="m1" item={item} hasTranscript />)
|
| 171 |
+
await screen.findByTestId('executive-brief')
|
| 172 |
+
fireEvent.click(screen.getByRole('button', { name: /Present/ }))
|
| 173 |
+
const stage = screen.getByTestId('present-mode')
|
| 174 |
+
expect(screen.getByTestId('present-counter')).toHaveTextContent('1 / 6')
|
| 175 |
+
expect(stage).toHaveTextContent('Domain status fixes and Langfuse prompt management')
|
| 176 |
+
fireEvent.keyDown(document, { key: 'ArrowRight' })
|
| 177 |
+
expect(screen.getByTestId('present-counter')).toHaveTextContent('2 / 6')
|
| 178 |
+
expect(stage).toHaveTextContent('Core risks & blockers')
|
| 179 |
+
fireEvent.keyDown(document, { key: 'End' })
|
| 180 |
+
expect(screen.getByTestId('present-counter')).toHaveTextContent('6 / 6')
|
| 181 |
+
expect(stage).toHaveTextContent('Core takeaway')
|
| 182 |
+
fireEvent.keyDown(document, { key: 'Escape' })
|
| 183 |
+
expect(screen.queryByTestId('present-mode')).not.toBeInTheDocument()
|
| 184 |
+
})
|
| 185 |
+
|
| 186 |
+
it('says when the feature is off, and waits for a transcript', async () => {
|
| 187 |
+
const { unmount } = render(<VisualSummaryTab meetingId="m1" item={item} hasTranscript />)
|
| 188 |
+
expect(await screen.findByText(/turned off on this server/)).toBeInTheDocument()
|
| 189 |
+
unmount()
|
| 190 |
+
respond(/GET .*\/analysis$/, () => ({ body: { state: 'no_transcript' } }))
|
| 191 |
+
render(<VisualSummaryTab meetingId="m2" item={item} hasTranscript={false} />)
|
| 192 |
+
expect(await screen.findByText(/written once there is a transcript/)).toBeInTheDocument()
|
| 193 |
+
expect(screen.queryByRole('button', { name: /Generate visual summary/ })).not.toBeInTheDocument()
|
| 194 |
+
})
|
| 195 |
+
})
|
| 196 |
+
|
| 197 |
+
describe('exports', () => {
|
| 198 |
+
it('prints a clone of the report on its own and cleans up afterwards', () => {
|
| 199 |
+
vi.useFakeTimers()
|
| 200 |
+
const print = vi.fn()
|
| 201 |
+
vi.stubGlobal('print', print)
|
| 202 |
+
const report = document.createElement('div')
|
| 203 |
+
report.innerHTML = '<article class="hp-brief">Brief</article>'
|
| 204 |
+
document.body.appendChild(report)
|
| 205 |
+
printBrief(report)
|
| 206 |
+
act(() => { vi.advanceTimersByTime(60) })
|
| 207 |
+
expect(print).toHaveBeenCalledTimes(1)
|
| 208 |
+
expect(document.getElementById('hp-print-root')).toHaveTextContent('Brief')
|
| 209 |
+
expect(document.body.classList.contains('hp-print-brief')).toBe(true)
|
| 210 |
+
act(() => { vi.advanceTimersByTime(1600) })
|
| 211 |
+
expect(document.getElementById('hp-print-root')).toBeNull()
|
| 212 |
+
expect(document.body.classList.contains('hp-print-brief')).toBe(false)
|
| 213 |
+
report.remove()
|
| 214 |
+
})
|
| 215 |
+
|
| 216 |
+
it('names files after the meeting, safely', () => {
|
| 217 |
+
expect(safeFilename('Agent sync: Q3 / review!', 'visual-summary')).toBe('agent-sync-q3-review-visual-summary')
|
| 218 |
+
expect(safeFilename('', 'visual-summary')).toBe('meeting-visual-summary')
|
| 219 |
+
})
|
| 220 |
+
})
|
frontend/src/ui/App.tsx
CHANGED
|
@@ -145,6 +145,9 @@ import {
|
|
| 145 |
} from './meetingsense/catalog'
|
| 146 |
import { useMeetingCatalog } from './meetingsense/useMeetingCatalog'
|
| 147 |
import { MeetingLibrary } from './meetingsense/MeetingLibrary'
|
|
|
|
|
|
|
|
|
|
| 148 |
import { MeetingSenseProvider } from './meetingsense/MeetingSenseProvider'
|
| 149 |
import type { MeetingSenseStatus } from './meetingsense/entryPoint'
|
| 150 |
|
|
@@ -3317,15 +3320,33 @@ export default function App() {
|
|
| 3317 |
// MS29. The one status body the whole feature reads: the nav flag, the record button, and
|
| 3318 |
// the consent sheet's copy all come from it, so there is one round trip and one answer.
|
| 3319 |
const [meetingStatus, setMeetingStatus] = useState<MeetingSenseStatus | null>(null)
|
| 3320 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3321 |
useEffect(() => {
|
| 3322 |
let cancelled = false
|
| 3323 |
void (async () => {
|
| 3324 |
try {
|
| 3325 |
const res = await fetch('/v1/meetingsense/status')
|
| 3326 |
const body = res.ok ? await res.json() : null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3327 |
if (cancelled) return
|
| 3328 |
-
|
|
|
|
| 3329 |
setMeetingStatus(body)
|
| 3330 |
} catch {
|
| 3331 |
// An optional feature's probe failing is not something to tell the user about. The
|
|
@@ -6079,6 +6100,153 @@ ${personalityPrompt || 'You are a friendly voice assistant. Be helpful and warm.
|
|
| 6079 |
const advisor = useAdvisorUpdates(settingsDraft.backendUrl, advisorCurrent, settingsDraft.apiKey)
|
| 6080 |
const projectThumbUrl = projectAvatarUrl(currentProject, settingsDraft.backendUrl, 'thumb')
|
| 6081 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6082 |
return (
|
| 6083 |
<MeetingSenseProvider
|
| 6084 |
conversationId={conversationId}
|
|
@@ -6502,151 +6670,7 @@ ${personalityPrompt || 'You are a friendly voice assistant. Be helpful and warm.
|
|
| 6502 |
backendUrl={settingsDraft.backendUrl}
|
| 6503 |
apiKey={settingsDraft.apiKey}
|
| 6504 |
matrixHubUrl={settingsDraft.matrixHubEnabled ? settingsDraft.matrixHubUrl : undefined}
|
| 6505 |
-
onProjectSelect={
|
| 6506 |
-
try {
|
| 6507 |
-
// Fetch project info to get instructions and document count
|
| 6508 |
-
const response = await fetch(
|
| 6509 |
-
`${settingsDraft.backendUrl.replace(/\/+$/, '')}/projects/${projectId}`,
|
| 6510 |
-
{
|
| 6511 |
-
headers: authHeaders,
|
| 6512 |
-
}
|
| 6513 |
-
)
|
| 6514 |
-
|
| 6515 |
-
if (response.ok) {
|
| 6516 |
-
const data = await response.json()
|
| 6517 |
-
const project = data.project
|
| 6518 |
-
|
| 6519 |
-
// Store project ID and info for chat context
|
| 6520 |
-
localStorage.setItem('homepilot_current_project', projectId)
|
| 6521 |
-
setCurrentProject({
|
| 6522 |
-
id: projectId,
|
| 6523 |
-
name: project.name,
|
| 6524 |
-
document_count: project.document_count || 0,
|
| 6525 |
-
project_type: project.project_type,
|
| 6526 |
-
description: project.description,
|
| 6527 |
-
instructions: project.instructions,
|
| 6528 |
-
files: project.files,
|
| 6529 |
-
agentic: project.agentic,
|
| 6530 |
-
persona_agent: project.persona_agent,
|
| 6531 |
-
persona_appearance: project.persona_appearance,
|
| 6532 |
-
created_at: project.created_at,
|
| 6533 |
-
updated_at: project.updated_at,
|
| 6534 |
-
})
|
| 6535 |
-
|
| 6536 |
-
projectInfoLoadedRef.current = projectId
|
| 6537 |
-
const lastConvId = project.last_conversation_id
|
| 6538 |
-
const loadMessages = async (convId: string) => {
|
| 6539 |
-
const convData = await getJson<{
|
| 6540 |
-
ok: boolean
|
| 6541 |
-
messages: Array<{ role: string; content: string; created_at: string; media?: { images?: string[]; video_url?: string } | null }>
|
| 6542 |
-
}>(settingsDraft.backendUrl, `/conversations/${convId}/messages`, authHeaders)
|
| 6543 |
-
return convData.ok && convData.messages ? convData.messages : []
|
| 6544 |
-
}
|
| 6545 |
-
const toMsgs = (rows: Array<{ role: string; content: string; media?: any }>) =>
|
| 6546 |
-
rows.map((m, idx) => {
|
| 6547 |
-
const h = hydratePersistedMessageMedia(m.media)
|
| 6548 |
-
return {
|
| 6549 |
-
id: `restored-${idx}`,
|
| 6550 |
-
role: m.role as 'user' | 'assistant' | 'system',
|
| 6551 |
-
text: m.content,
|
| 6552 |
-
animate: false,
|
| 6553 |
-
media: h.media,
|
| 6554 |
-
...(h.callMemory ? { callMemory: h.callMemory } : {}),
|
| 6555 |
-
}
|
| 6556 |
-
})
|
| 6557 |
-
|
| 6558 |
-
if (project.project_type === 'persona') {
|
| 6559 |
-
// Companion-grade: the Session Hub opens right away. The persistent
|
| 6560 |
-
// session and its messages load behind it, in parallel with the last
|
| 6561 |
-
// conversation (the fallback when the session is still empty) —
|
| 6562 |
-
// previously four requests ran one after another before the hub showed.
|
| 6563 |
-
void (async () => {
|
| 6564 |
-
const lastPromise = lastConvId ? loadMessages(lastConvId).catch(() => []) : Promise.resolve([])
|
| 6565 |
-
try {
|
| 6566 |
-
const session = await resolveSession(projectId, 'text')
|
| 6567 |
-
console.log('[Project] Resolved persona session:', session.id)
|
| 6568 |
-
localStorage.setItem('homepilot_active_voice_session', JSON.stringify(session))
|
| 6569 |
-
const sessionRows = session.conversation_id === lastConvId
|
| 6570 |
-
? await lastPromise
|
| 6571 |
-
: await loadMessages(session.conversation_id).catch(() => [])
|
| 6572 |
-
const rows = sessionRows.length > 0 ? sessionRows : await lastPromise
|
| 6573 |
-
// Nothing to show: clear the chat first — it assigns a fresh
|
| 6574 |
-
// conversation id, so the session's own id must be set after it.
|
| 6575 |
-
if (rows.length === 0) onNewConversation()
|
| 6576 |
-
setConversationId(session.conversation_id)
|
| 6577 |
-
if (rows.length > 0) setMessages(toMsgs(rows))
|
| 6578 |
-
} catch (err) {
|
| 6579 |
-
console.warn('[Project] Session resolution failed:', err)
|
| 6580 |
-
const rows = await lastPromise
|
| 6581 |
-
if (lastConvId && rows.length > 0) {
|
| 6582 |
-
setConversationId(lastConvId)
|
| 6583 |
-
setMessages(toMsgs(rows))
|
| 6584 |
-
} else {
|
| 6585 |
-
onNewConversation()
|
| 6586 |
-
}
|
| 6587 |
-
}
|
| 6588 |
-
})()
|
| 6589 |
-
} else if (lastConvId) {
|
| 6590 |
-
// Restore previous conversation for this project
|
| 6591 |
-
try {
|
| 6592 |
-
const rows = await loadMessages(lastConvId)
|
| 6593 |
-
if (rows.length > 0) {
|
| 6594 |
-
setConversationId(lastConvId)
|
| 6595 |
-
setMessages(toMsgs(rows))
|
| 6596 |
-
} else {
|
| 6597 |
-
// Conversation was empty/deleted — start fresh
|
| 6598 |
-
onNewConversation()
|
| 6599 |
-
}
|
| 6600 |
-
} catch {
|
| 6601 |
-
// Failed to load — start fresh
|
| 6602 |
-
onNewConversation()
|
| 6603 |
-
}
|
| 6604 |
-
} else {
|
| 6605 |
-
// No previous conversation — start fresh
|
| 6606 |
-
onNewConversation()
|
| 6607 |
-
}
|
| 6608 |
-
|
| 6609 |
-
// Agent projects: auto-enable agentic execution mode
|
| 6610 |
-
if (project.project_type === 'agent') {
|
| 6611 |
-
const agentSettings: ChatScopedSettings = {
|
| 6612 |
-
advancedHelpEnabled: true,
|
| 6613 |
-
askBeforeActing: project.agentic?.ask_before_acting !== false,
|
| 6614 |
-
executionProfile: project.agentic?.execution_profile === 'balanced'
|
| 6615 |
-
? 'balanced'
|
| 6616 |
-
: project.agentic?.execution_profile === 'quality'
|
| 6617 |
-
? 'quality'
|
| 6618 |
-
: 'fast',
|
| 6619 |
-
incognito: false,
|
| 6620 |
-
}
|
| 6621 |
-
updateChatSettings(agentSettings)
|
| 6622 |
-
}
|
| 6623 |
-
|
| 6624 |
-
// Route to the correct mode based on project type
|
| 6625 |
-
if (project.project_type === 'persona') {
|
| 6626 |
-
// Companion-grade: show the Session Hub first
|
| 6627 |
-
// User picks Continue / New Voice / New Text
|
| 6628 |
-
setShowSessionPanel(true)
|
| 6629 |
-
setMode('chat')
|
| 6630 |
-
} else if (project.project_type === 'image') {
|
| 6631 |
-
setMode('imagine')
|
| 6632 |
-
} else if (project.project_type === 'video') {
|
| 6633 |
-
setMode('animate')
|
| 6634 |
-
} else {
|
| 6635 |
-
setMode('chat')
|
| 6636 |
-
}
|
| 6637 |
-
} else {
|
| 6638 |
-
// Fallback if fetch fails
|
| 6639 |
-
localStorage.setItem('homepilot_current_project', projectId)
|
| 6640 |
-
setCurrentProject(null)
|
| 6641 |
-
setMode('chat')
|
| 6642 |
-
}
|
| 6643 |
-
} catch (error) {
|
| 6644 |
-
console.error('Error fetching project:', error)
|
| 6645 |
-
// Fallback
|
| 6646 |
-
localStorage.setItem('homepilot_current_project', projectId)
|
| 6647 |
-
setMode('chat')
|
| 6648 |
-
}
|
| 6649 |
-
}}
|
| 6650 |
/>
|
| 6651 |
) : mode === 'imagine' ? (
|
| 6652 |
<ImagineView
|
|
@@ -6751,6 +6775,7 @@ ${personalityPrompt || 'You are a friendly voice assistant. Be helpful and warm.
|
|
| 6751 |
backendUrl={settingsDraft.backendUrl}
|
| 6752 |
apiKey={settingsDraft.apiKey}
|
| 6753 |
teamsMcpAvailable={teamsMcpAvailable}
|
|
|
|
| 6754 |
/>
|
| 6755 |
) : mode === 'routines' ? (
|
| 6756 |
// Routines: user-owned schedule definitions. Execution remains a separate capability.
|
|
@@ -6762,6 +6787,31 @@ ${personalityPrompt || 'You are a friendly voice assistant. Be helpful and warm.
|
|
| 6762 |
void openRoutineConversation(run)
|
| 6763 |
}}
|
| 6764 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6765 |
) : mode === 'meetings' ? (
|
| 6766 |
// MS28, only reachable when `_CATALOG` is on — `mode` cannot be set to this without
|
| 6767 |
// the nav item, and the nav item is behind the same flag.
|
|
|
|
| 145 |
} from './meetingsense/catalog'
|
| 146 |
import { useMeetingCatalog } from './meetingsense/useMeetingCatalog'
|
| 147 |
import { MeetingLibrary } from './meetingsense/MeetingLibrary'
|
| 148 |
+
import { MeetingsHome } from './meetingsense/library/MeetingsHome'
|
| 149 |
+
import { MeetingDetailPage } from './meetingsense/library/MeetingDetailPage'
|
| 150 |
+
import type { LibraryStatus } from './meetingsense/library/api'
|
| 151 |
import { MeetingSenseProvider } from './meetingsense/MeetingSenseProvider'
|
| 152 |
import type { MeetingSenseStatus } from './meetingsense/entryPoint'
|
| 153 |
|
|
|
|
| 3320 |
// MS29. The one status body the whole feature reads: the nav flag, the record button, and
|
| 3321 |
// the consent sheet's copy all come from it, so there is one round trip and one answer.
|
| 3322 |
const [meetingStatus, setMeetingStatus] = useState<MeetingSenseStatus | null>(null)
|
| 3323 |
+
// Meetings Library upgrade (MEETINGS_LIBRARY_UPGRADE). Its own status route, so the legacy
|
| 3324 |
+
// catalog below stays exactly as it was whenever the upgrade is off.
|
| 3325 |
+
const [libraryStatus, setLibraryStatus] = useState<LibraryStatus | null>(null)
|
| 3326 |
+
const [meetingDetailId, setMeetingDetailId] = useState<string | null>(null)
|
| 3327 |
+
const [teamsRoomToOpen, setTeamsRoomToOpen] = useState<string | null>(null)
|
| 3328 |
+
const libraryOn = Boolean(libraryStatus?.enabled)
|
| 3329 |
+
const meetingsView = useMeetingCatalog({ enabled: meetingsNavEnabled && mode === 'meetings' && !libraryOn })
|
| 3330 |
+
useEffect(() => {
|
| 3331 |
+
if (mode !== 'meetings') setMeetingDetailId(null)
|
| 3332 |
+
if (mode !== 'teams') setTeamsRoomToOpen(null)
|
| 3333 |
+
}, [mode])
|
| 3334 |
useEffect(() => {
|
| 3335 |
let cancelled = false
|
| 3336 |
void (async () => {
|
| 3337 |
try {
|
| 3338 |
const res = await fetch('/v1/meetingsense/status')
|
| 3339 |
const body = res.ok ? await res.json() : null
|
| 3340 |
+
let library: LibraryStatus | null = null
|
| 3341 |
+
try {
|
| 3342 |
+
const lib = await fetch('/v1/meetingsense/library/status')
|
| 3343 |
+
library = lib.ok ? await lib.json() : null
|
| 3344 |
+
} catch {
|
| 3345 |
+
// An older backend has no library: the Meetings view is the legacy catalog.
|
| 3346 |
+
}
|
| 3347 |
if (cancelled) return
|
| 3348 |
+
setLibraryStatus(library)
|
| 3349 |
+
setMeetingsNavEnabled(msCatalogEnabled(body) || Boolean(library?.enabled))
|
| 3350 |
setMeetingStatus(body)
|
| 3351 |
} catch {
|
| 3352 |
// An optional feature's probe failing is not something to tell the user about. The
|
|
|
|
| 6100 |
const advisor = useAdvisorUpdates(settingsDraft.backendUrl, advisorCurrent, settingsDraft.apiKey)
|
| 6101 |
const projectThumbUrl = projectAvatarUrl(currentProject, settingsDraft.backendUrl, 'thumb')
|
| 6102 |
|
| 6103 |
+
// Opening a project — from the Projects page, and from a meeting's project link.
|
| 6104 |
+
const handleProjectSelect = async (projectId: string) => {
|
| 6105 |
+
try {
|
| 6106 |
+
// Fetch project info to get instructions and document count
|
| 6107 |
+
const response = await fetch(
|
| 6108 |
+
`${settingsDraft.backendUrl.replace(/\/+$/, '')}/projects/${projectId}`,
|
| 6109 |
+
{
|
| 6110 |
+
headers: authHeaders,
|
| 6111 |
+
}
|
| 6112 |
+
)
|
| 6113 |
+
|
| 6114 |
+
if (response.ok) {
|
| 6115 |
+
const data = await response.json()
|
| 6116 |
+
const project = data.project
|
| 6117 |
+
|
| 6118 |
+
// Store project ID and info for chat context
|
| 6119 |
+
localStorage.setItem('homepilot_current_project', projectId)
|
| 6120 |
+
setCurrentProject({
|
| 6121 |
+
id: projectId,
|
| 6122 |
+
name: project.name,
|
| 6123 |
+
document_count: project.document_count || 0,
|
| 6124 |
+
project_type: project.project_type,
|
| 6125 |
+
description: project.description,
|
| 6126 |
+
instructions: project.instructions,
|
| 6127 |
+
files: project.files,
|
| 6128 |
+
agentic: project.agentic,
|
| 6129 |
+
persona_agent: project.persona_agent,
|
| 6130 |
+
persona_appearance: project.persona_appearance,
|
| 6131 |
+
created_at: project.created_at,
|
| 6132 |
+
updated_at: project.updated_at,
|
| 6133 |
+
})
|
| 6134 |
+
|
| 6135 |
+
projectInfoLoadedRef.current = projectId
|
| 6136 |
+
const lastConvId = project.last_conversation_id
|
| 6137 |
+
const loadMessages = async (convId: string) => {
|
| 6138 |
+
const convData = await getJson<{
|
| 6139 |
+
ok: boolean
|
| 6140 |
+
messages: Array<{ role: string; content: string; created_at: string; media?: { images?: string[]; video_url?: string } | null }>
|
| 6141 |
+
}>(settingsDraft.backendUrl, `/conversations/${convId}/messages`, authHeaders)
|
| 6142 |
+
return convData.ok && convData.messages ? convData.messages : []
|
| 6143 |
+
}
|
| 6144 |
+
const toMsgs = (rows: Array<{ role: string; content: string; media?: any }>) =>
|
| 6145 |
+
rows.map((m, idx) => {
|
| 6146 |
+
const h = hydratePersistedMessageMedia(m.media)
|
| 6147 |
+
return {
|
| 6148 |
+
id: `restored-${idx}`,
|
| 6149 |
+
role: m.role as 'user' | 'assistant' | 'system',
|
| 6150 |
+
text: m.content,
|
| 6151 |
+
animate: false,
|
| 6152 |
+
media: h.media,
|
| 6153 |
+
...(h.callMemory ? { callMemory: h.callMemory } : {}),
|
| 6154 |
+
}
|
| 6155 |
+
})
|
| 6156 |
+
|
| 6157 |
+
if (project.project_type === 'persona') {
|
| 6158 |
+
// Companion-grade: the Session Hub opens right away. The persistent
|
| 6159 |
+
// session and its messages load behind it, in parallel with the last
|
| 6160 |
+
// conversation (the fallback when the session is still empty) —
|
| 6161 |
+
// previously four requests ran one after another before the hub showed.
|
| 6162 |
+
void (async () => {
|
| 6163 |
+
const lastPromise = lastConvId ? loadMessages(lastConvId).catch(() => []) : Promise.resolve([])
|
| 6164 |
+
try {
|
| 6165 |
+
const session = await resolveSession(projectId, 'text')
|
| 6166 |
+
console.log('[Project] Resolved persona session:', session.id)
|
| 6167 |
+
localStorage.setItem('homepilot_active_voice_session', JSON.stringify(session))
|
| 6168 |
+
const sessionRows = session.conversation_id === lastConvId
|
| 6169 |
+
? await lastPromise
|
| 6170 |
+
: await loadMessages(session.conversation_id).catch(() => [])
|
| 6171 |
+
const rows = sessionRows.length > 0 ? sessionRows : await lastPromise
|
| 6172 |
+
// Nothing to show: clear the chat first — it assigns a fresh
|
| 6173 |
+
// conversation id, so the session's own id must be set after it.
|
| 6174 |
+
if (rows.length === 0) onNewConversation()
|
| 6175 |
+
setConversationId(session.conversation_id)
|
| 6176 |
+
if (rows.length > 0) setMessages(toMsgs(rows))
|
| 6177 |
+
} catch (err) {
|
| 6178 |
+
console.warn('[Project] Session resolution failed:', err)
|
| 6179 |
+
const rows = await lastPromise
|
| 6180 |
+
if (lastConvId && rows.length > 0) {
|
| 6181 |
+
setConversationId(lastConvId)
|
| 6182 |
+
setMessages(toMsgs(rows))
|
| 6183 |
+
} else {
|
| 6184 |
+
onNewConversation()
|
| 6185 |
+
}
|
| 6186 |
+
}
|
| 6187 |
+
})()
|
| 6188 |
+
} else if (lastConvId) {
|
| 6189 |
+
// Restore previous conversation for this project
|
| 6190 |
+
try {
|
| 6191 |
+
const rows = await loadMessages(lastConvId)
|
| 6192 |
+
if (rows.length > 0) {
|
| 6193 |
+
setConversationId(lastConvId)
|
| 6194 |
+
setMessages(toMsgs(rows))
|
| 6195 |
+
} else {
|
| 6196 |
+
// Conversation was empty/deleted — start fresh
|
| 6197 |
+
onNewConversation()
|
| 6198 |
+
}
|
| 6199 |
+
} catch {
|
| 6200 |
+
// Failed to load — start fresh
|
| 6201 |
+
onNewConversation()
|
| 6202 |
+
}
|
| 6203 |
+
} else {
|
| 6204 |
+
// No previous conversation — start fresh
|
| 6205 |
+
onNewConversation()
|
| 6206 |
+
}
|
| 6207 |
+
|
| 6208 |
+
// Agent projects: auto-enable agentic execution mode
|
| 6209 |
+
if (project.project_type === 'agent') {
|
| 6210 |
+
const agentSettings: ChatScopedSettings = {
|
| 6211 |
+
advancedHelpEnabled: true,
|
| 6212 |
+
askBeforeActing: project.agentic?.ask_before_acting !== false,
|
| 6213 |
+
executionProfile: project.agentic?.execution_profile === 'balanced'
|
| 6214 |
+
? 'balanced'
|
| 6215 |
+
: project.agentic?.execution_profile === 'quality'
|
| 6216 |
+
? 'quality'
|
| 6217 |
+
: 'fast',
|
| 6218 |
+
incognito: false,
|
| 6219 |
+
}
|
| 6220 |
+
updateChatSettings(agentSettings)
|
| 6221 |
+
}
|
| 6222 |
+
|
| 6223 |
+
// Route to the correct mode based on project type
|
| 6224 |
+
if (project.project_type === 'persona') {
|
| 6225 |
+
// Companion-grade: show the Session Hub first
|
| 6226 |
+
// User picks Continue / New Voice / New Text
|
| 6227 |
+
setShowSessionPanel(true)
|
| 6228 |
+
setMode('chat')
|
| 6229 |
+
} else if (project.project_type === 'image') {
|
| 6230 |
+
setMode('imagine')
|
| 6231 |
+
} else if (project.project_type === 'video') {
|
| 6232 |
+
setMode('animate')
|
| 6233 |
+
} else {
|
| 6234 |
+
setMode('chat')
|
| 6235 |
+
}
|
| 6236 |
+
} else {
|
| 6237 |
+
// Fallback if fetch fails
|
| 6238 |
+
localStorage.setItem('homepilot_current_project', projectId)
|
| 6239 |
+
setCurrentProject(null)
|
| 6240 |
+
setMode('chat')
|
| 6241 |
+
}
|
| 6242 |
+
} catch (error) {
|
| 6243 |
+
console.error('Error fetching project:', error)
|
| 6244 |
+
// Fallback
|
| 6245 |
+
localStorage.setItem('homepilot_current_project', projectId)
|
| 6246 |
+
setMode('chat')
|
| 6247 |
+
}
|
| 6248 |
+
}
|
| 6249 |
+
|
| 6250 |
return (
|
| 6251 |
<MeetingSenseProvider
|
| 6252 |
conversationId={conversationId}
|
|
|
|
| 6670 |
backendUrl={settingsDraft.backendUrl}
|
| 6671 |
apiKey={settingsDraft.apiKey}
|
| 6672 |
matrixHubUrl={settingsDraft.matrixHubEnabled ? settingsDraft.matrixHubUrl : undefined}
|
| 6673 |
+
onProjectSelect={handleProjectSelect}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6674 |
/>
|
| 6675 |
) : mode === 'imagine' ? (
|
| 6676 |
<ImagineView
|
|
|
|
| 6775 |
backendUrl={settingsDraft.backendUrl}
|
| 6776 |
apiKey={settingsDraft.apiKey}
|
| 6777 |
teamsMcpAvailable={teamsMcpAvailable}
|
| 6778 |
+
initialRoomId={teamsRoomToOpen}
|
| 6779 |
/>
|
| 6780 |
) : mode === 'routines' ? (
|
| 6781 |
// Routines: user-owned schedule definitions. Execution remains a separate capability.
|
|
|
|
| 6787 |
void openRoutineConversation(run)
|
| 6788 |
}}
|
| 6789 |
/>
|
| 6790 |
+
) : mode === 'meetings' && libraryOn ? (
|
| 6791 |
+
// Meetings Library (MEETINGS_LIBRARY_UPGRADE): one list of every meeting, with
|
| 6792 |
+
// imports, folder sync and a detail page that does not need a conversation.
|
| 6793 |
+
meetingDetailId ? (
|
| 6794 |
+
<MeetingDetailPage
|
| 6795 |
+
libraryId={meetingDetailId}
|
| 6796 |
+
onBack={() => setMeetingDetailId(null)}
|
| 6797 |
+
onOpenConversation={(cid) => { void loadConversation(cid); setMode('chat') }}
|
| 6798 |
+
onOpenTeamsRoom={(roomId) => { setTeamsRoomToOpen(roomId); setMode('teams') }}
|
| 6799 |
+
onOpenProject={(projectId) => { void handleProjectSelect(projectId) }}
|
| 6800 |
+
/>
|
| 6801 |
+
) : (
|
| 6802 |
+
<MeetingsHome
|
| 6803 |
+
status={libraryStatus}
|
| 6804 |
+
canRecordLive={Boolean(meetingStatus?.enabled)}
|
| 6805 |
+
onOpen={(item) => {
|
| 6806 |
+
if (item.origin_type === 'teams_room') {
|
| 6807 |
+
setTeamsRoomToOpen(item.origin_id)
|
| 6808 |
+
setMode('teams')
|
| 6809 |
+
} else {
|
| 6810 |
+
setMeetingDetailId(item.library_id)
|
| 6811 |
+
}
|
| 6812 |
+
}}
|
| 6813 |
+
/>
|
| 6814 |
+
)
|
| 6815 |
) : mode === 'meetings' ? (
|
| 6816 |
// MS28, only reachable when `_CATALOG` is on — `mode` cannot be set to this without
|
| 6817 |
// the nav item, and the nav item is behind the same flag.
|
frontend/src/ui/components/PersonaSettingsPanel.tsx
CHANGED
|
@@ -405,7 +405,9 @@ export function PersonaSettingsPanel({ project, backendUrl, apiKey, onClose, onS
|
|
| 405 |
|
| 406 |
const thumb = pap.selected_thumb_filename as string | undefined
|
| 407 |
const full = pap.selected_filename as string | undefined
|
| 408 |
-
|
|
|
|
|
|
|
| 409 |
if (!url) return []
|
| 410 |
|
| 411 |
return [
|
|
|
|
| 405 |
|
| 406 |
const thumb = pap.selected_thumb_filename as string | undefined
|
| 407 |
const full = pap.selected_filename as string | undefined
|
| 408 |
+
// The original, not the 256 px thumbnail: this look is what Save commits as
|
| 409 |
+
// the persona's picture, and a thumbnail (or a framed crop) would replace it.
|
| 410 |
+
const url = fileUrl(backendUrl, full || thumb)
|
| 411 |
if (!url) return []
|
| 412 |
|
| 413 |
return [
|
frontend/src/ui/components/ProjectAvatar.tsx
CHANGED
|
@@ -7,8 +7,11 @@
|
|
| 7 |
* - Without one (or if every URL fails to load): the project's type icon.
|
| 8 |
* - `level` (0..1, optional) makes the ring respond to audio — used by Voice
|
| 9 |
* for a static picture. The face itself is never animated.
|
|
|
|
|
|
|
|
|
|
| 10 |
*/
|
| 11 |
-
import React, { useEffect, useState } from 'react'
|
| 12 |
import { Bot, Film, Image as ImageIcon, MessageSquare, User } from 'lucide-react'
|
| 13 |
|
| 14 |
const FALLBACK_ACCENT = '139, 92, 246' // violet-500
|
|
@@ -73,6 +76,20 @@ export function useImageFallback(urls: Array<string | null | undefined>) {
|
|
| 73 |
}
|
| 74 |
}
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
/** A decorative <img> that tries each URL in turn; `fallback` when none loads. */
|
| 77 |
export function AvatarImage({
|
| 78 |
urls,
|
|
@@ -86,16 +103,19 @@ export function AvatarImage({
|
|
| 86 |
fallback?: React.ReactNode
|
| 87 |
}) {
|
| 88 |
const { src, onError } = useImageFallback(urls)
|
|
|
|
| 89 |
if (!src) return <>{fallback}</>
|
| 90 |
return (
|
| 91 |
<img
|
|
|
|
| 92 |
src={src}
|
| 93 |
alt=""
|
| 94 |
aria-hidden
|
| 95 |
-
className={className}
|
| 96 |
style={style}
|
| 97 |
draggable={false}
|
| 98 |
decoding="async"
|
|
|
|
| 99 |
onError={onError}
|
| 100 |
/>
|
| 101 |
)
|
|
@@ -121,7 +141,9 @@ export function ProjectAvatar({
|
|
| 121 |
className?: string
|
| 122 |
}) {
|
| 123 |
const { src, onError } = useImageFallback([url, fallbackUrl])
|
|
|
|
| 124 |
const showPicture = !!src
|
|
|
|
| 125 |
const [accent, setAccent] = useState(FALLBACK_ACCENT)
|
| 126 |
useEffect(() => setAccent(FALLBACK_ACCENT), [src])
|
| 127 |
const lvl = typeof level === 'number' && Number.isFinite(level) ? Math.max(0, Math.min(1, level)) : null
|
|
@@ -137,6 +159,7 @@ export function ProjectAvatar({
|
|
| 137 |
['--hp-level' as string]: lvl ?? 0,
|
| 138 |
}}
|
| 139 |
data-kind={showPicture ? 'picture' : 'icon'}
|
|
|
|
| 140 |
>
|
| 141 |
{lvl !== null ? (
|
| 142 |
<>
|
|
@@ -144,14 +167,18 @@ export function ProjectAvatar({
|
|
| 144 |
<span className="hp-project-avatar__wave hp-project-avatar__wave--2" aria-hidden="true" />
|
| 145 |
</>
|
| 146 |
) : null}
|
|
|
|
| 147 |
{showPicture ? (
|
| 148 |
<img
|
|
|
|
| 149 |
src={src as string}
|
| 150 |
alt={name}
|
| 151 |
className="hp-project-avatar__img"
|
|
|
|
| 152 |
draggable={false}
|
| 153 |
decoding="async"
|
| 154 |
onLoad={(e) => {
|
|
|
|
| 155 |
const tint = imageAccent(e.currentTarget)
|
| 156 |
if (tint) setAccent(tint)
|
| 157 |
}}
|
|
|
|
| 7 |
* - Without one (or if every URL fails to load): the project's type icon.
|
| 8 |
* - `level` (0..1, optional) makes the ring respond to audio — used by Voice
|
| 9 |
* for a static picture. The face itself is never animated.
|
| 10 |
+
* - While a picture is on its way (a full-size original can take a moment), a
|
| 11 |
+
* soft shimmer fills the circle and the picture fades in once it has arrived —
|
| 12 |
+
* never an empty black disc. A picture already in the cache shows at once.
|
| 13 |
*/
|
| 14 |
+
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
| 15 |
import { Bot, Film, Image as ImageIcon, MessageSquare, User } from 'lucide-react'
|
| 16 |
|
| 17 |
const FALLBACK_ACCENT = '139, 92, 246' // violet-500
|
|
|
|
| 76 |
}
|
| 77 |
}
|
| 78 |
|
| 79 |
+
/**
|
| 80 |
+
* Whether the <img> showing `src` has finished loading. A cached picture can be
|
| 81 |
+
* complete before React listens for "load", so that is checked right away.
|
| 82 |
+
*/
|
| 83 |
+
export function useImageReady(src: string | null) {
|
| 84 |
+
const ref = useRef<HTMLImageElement>(null)
|
| 85 |
+
const [readySrc, setReadySrc] = useState<string | null>(null)
|
| 86 |
+
useLayoutEffect(() => {
|
| 87 |
+
const img = ref.current
|
| 88 |
+
if (src && img && img.complete && img.naturalWidth > 0) setReadySrc(src)
|
| 89 |
+
}, [src])
|
| 90 |
+
return { ref, ready: !!src && readySrc === src, onLoad: () => setReadySrc(src) }
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
/** A decorative <img> that tries each URL in turn; `fallback` when none loads. */
|
| 94 |
export function AvatarImage({
|
| 95 |
urls,
|
|
|
|
| 103 |
fallback?: React.ReactNode
|
| 104 |
}) {
|
| 105 |
const { src, onError } = useImageFallback(urls)
|
| 106 |
+
const { ref, ready, onLoad } = useImageReady(src)
|
| 107 |
if (!src) return <>{fallback}</>
|
| 108 |
return (
|
| 109 |
<img
|
| 110 |
+
ref={ref}
|
| 111 |
src={src}
|
| 112 |
alt=""
|
| 113 |
aria-hidden
|
| 114 |
+
className={ready ? className : `${className || ''} hp-avatar-loading`}
|
| 115 |
style={style}
|
| 116 |
draggable={false}
|
| 117 |
decoding="async"
|
| 118 |
+
onLoad={onLoad}
|
| 119 |
onError={onError}
|
| 120 |
/>
|
| 121 |
)
|
|
|
|
| 141 |
className?: string
|
| 142 |
}) {
|
| 143 |
const { src, onError } = useImageFallback([url, fallbackUrl])
|
| 144 |
+
const { ref: imgRef, ready, onLoad } = useImageReady(src)
|
| 145 |
const showPicture = !!src
|
| 146 |
+
const loading = showPicture && !ready
|
| 147 |
const [accent, setAccent] = useState(FALLBACK_ACCENT)
|
| 148 |
useEffect(() => setAccent(FALLBACK_ACCENT), [src])
|
| 149 |
const lvl = typeof level === 'number' && Number.isFinite(level) ? Math.max(0, Math.min(1, level)) : null
|
|
|
|
| 159 |
['--hp-level' as string]: lvl ?? 0,
|
| 160 |
}}
|
| 161 |
data-kind={showPicture ? 'picture' : 'icon'}
|
| 162 |
+
aria-busy={loading || undefined}
|
| 163 |
>
|
| 164 |
{lvl !== null ? (
|
| 165 |
<>
|
|
|
|
| 167 |
<span className="hp-project-avatar__wave hp-project-avatar__wave--2" aria-hidden="true" />
|
| 168 |
</>
|
| 169 |
) : null}
|
| 170 |
+
{loading ? <span className="hp-project-avatar__skeleton" aria-hidden="true" /> : null}
|
| 171 |
{showPicture ? (
|
| 172 |
<img
|
| 173 |
+
ref={imgRef}
|
| 174 |
src={src as string}
|
| 175 |
alt={name}
|
| 176 |
className="hp-project-avatar__img"
|
| 177 |
+
data-ready={ready ? 'true' : 'false'}
|
| 178 |
draggable={false}
|
| 179 |
decoding="async"
|
| 180 |
onLoad={(e) => {
|
| 181 |
+
onLoad()
|
| 182 |
const tint = imageAccent(e.currentTarget)
|
| 183 |
if (tint) setAccent(tint)
|
| 184 |
}}
|
frontend/src/ui/meetingsense/MeetingSenseProvider.tsx
CHANGED
|
@@ -50,6 +50,15 @@ export interface MeetingControls {
|
|
| 50 |
undoSecondsLeft: number | null;
|
| 51 |
capture: CaptureOptions;
|
| 52 |
setCapture: (next: CaptureOptions) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
|
| 55 |
interface WorkspaceRecorder {
|
|
@@ -212,14 +221,14 @@ export function MeetingSenseProvider(props: React.PropsWithChildren<MeetingSense
|
|
| 212 |
const live = phase === 'live' || phase === 'reconnecting' || phase === 'stopping';
|
| 213 |
const ended = phase === 'ended';
|
| 214 |
|
| 215 |
-
const persistStartMarker = useCallback(async (id: string) => {
|
| 216 |
const response = await fetch(`${apiBase()}/conversations/${encodeURIComponent(id)}/messages`, {
|
| 217 |
method: 'POST',
|
| 218 |
credentials: 'include',
|
| 219 |
headers: headers(),
|
| 220 |
body: JSON.stringify({
|
| 221 |
role: 'system',
|
| 222 |
-
content: meetingStartMarker(
|
| 223 |
}),
|
| 224 |
});
|
| 225 |
if (!response.ok) throw new Error(`conversation marker HTTP ${response.status}`);
|
|
@@ -248,7 +257,14 @@ export function MeetingSenseProvider(props: React.PropsWithChildren<MeetingSense
|
|
| 248 |
}
|
| 249 |
}, []);
|
| 250 |
|
| 251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
const nextConversation = freshMeetingConversationId();
|
| 253 |
// Remember where the user was before the dedicated meeting thread takes over. Close
|
| 254 |
// returns there instead of leaving the app parked on a meeting conversation that
|
|
@@ -266,6 +282,8 @@ export function MeetingSenseProvider(props: React.PropsWithChildren<MeetingSense
|
|
| 266 |
try {
|
| 267 |
const result = await meeting.start({
|
| 268 |
conversationId: nextConversation,
|
|
|
|
|
|
|
| 269 |
notes: true,
|
| 270 |
watch: capture.slides,
|
| 271 |
audio: capture.audio,
|
|
@@ -301,7 +319,7 @@ export function MeetingSenseProvider(props: React.PropsWithChildren<MeetingSense
|
|
| 301 |
setScreenStream(null);
|
| 302 |
returnConversationIdRef.current = null;
|
| 303 |
meeting.reset();
|
| 304 |
-
return;
|
| 305 |
}
|
| 306 |
|
| 307 |
const stream = browserRecorder()?.getScreenPreviewStream?.() || null;
|
|
@@ -310,7 +328,7 @@ export function MeetingSenseProvider(props: React.PropsWithChildren<MeetingSense
|
|
| 310 |
if (result.meetingId) void attachContext(result.meetingId, capture.context);
|
| 311 |
|
| 312 |
try {
|
| 313 |
-
await persistStartMarker(nextConversation);
|
| 314 |
} catch (markerError) {
|
| 315 |
console.warn('[MeetingSense] meeting started but start marker could not be persisted', markerError);
|
| 316 |
}
|
|
@@ -318,10 +336,11 @@ export function MeetingSenseProvider(props: React.PropsWithChildren<MeetingSense
|
|
| 318 |
// Only after capture succeeds: the normal chat underneath switches to the new
|
| 319 |
// conversation. If permissions fail, the user is still exactly where they started.
|
| 320 |
onOpenConversation?.(nextConversation);
|
|
|
|
| 321 |
} finally {
|
| 322 |
setStarting(false);
|
| 323 |
}
|
| 324 |
-
}, [attachContext,
|
| 325 |
|
| 326 |
const begin = useCallback(() => {
|
| 327 |
if (live || starting) return;
|
|
@@ -342,6 +361,19 @@ export function MeetingSenseProvider(props: React.PropsWithChildren<MeetingSense
|
|
| 342 |
void meeting.stop();
|
| 343 |
}, [meeting]);
|
| 344 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
const onAccept = useCallback((remember: boolean) => {
|
| 346 |
if (remember) rememberConsent(storage);
|
| 347 |
setPendingStart(false);
|
|
@@ -563,6 +595,7 @@ export function MeetingSenseProvider(props: React.PropsWithChildren<MeetingSense
|
|
| 563 |
undoSecondsLeft: meeting.undoSecondsLeft,
|
| 564 |
capture,
|
| 565 |
setCapture,
|
|
|
|
| 566 |
};
|
| 567 |
|
| 568 |
return (
|
|
|
|
| 50 |
undoSecondsLeft: number | null;
|
| 51 |
capture: CaptureOptions;
|
| 52 |
setCapture: (next: CaptureOptions) => void;
|
| 53 |
+
/**
|
| 54 |
+
* Start a live meeting with these options, a title and a project (the Meetings wizard).
|
| 55 |
+
* Additive: `begin` is unchanged. `acknowledge` records the consent the wizard asked for.
|
| 56 |
+
*/
|
| 57 |
+
startWith?: (
|
| 58 |
+
next: CaptureOptions,
|
| 59 |
+
meta: { title?: string; projectId?: string },
|
| 60 |
+
acknowledge: boolean,
|
| 61 |
+
) => Promise<{ ok: boolean; error?: string }>;
|
| 62 |
}
|
| 63 |
|
| 64 |
interface WorkspaceRecorder {
|
|
|
|
| 221 |
const live = phase === 'live' || phase === 'reconnecting' || phase === 'stopping';
|
| 222 |
const ended = phase === 'ended';
|
| 223 |
|
| 224 |
+
const persistStartMarker = useCallback(async (id: string, options: CaptureOptions = capture) => {
|
| 225 |
const response = await fetch(`${apiBase()}/conversations/${encodeURIComponent(id)}/messages`, {
|
| 226 |
method: 'POST',
|
| 227 |
credentials: 'include',
|
| 228 |
headers: headers(),
|
| 229 |
body: JSON.stringify({
|
| 230 |
role: 'system',
|
| 231 |
+
content: meetingStartMarker(options, Boolean(status?.stt?.remote)),
|
| 232 |
}),
|
| 233 |
});
|
| 234 |
if (!response.ok) throw new Error(`conversation marker HTTP ${response.status}`);
|
|
|
|
| 257 |
}
|
| 258 |
}, []);
|
| 259 |
|
| 260 |
+
// Read by actuallyStart, whose own `capture` may be the wizard's options instead.
|
| 261 |
+
const currentCapture = capture;
|
| 262 |
+
const actuallyStart = useCallback(async (
|
| 263 |
+
override?: { capture?: CaptureOptions; title?: string; projectId?: string },
|
| 264 |
+
): Promise<{ ok: boolean; error?: string }> => {
|
| 265 |
+
// The wizard passes its options here rather than through state, which would not have
|
| 266 |
+
// updated yet in this same call.
|
| 267 |
+
const capture = override?.capture ?? currentCapture;
|
| 268 |
const nextConversation = freshMeetingConversationId();
|
| 269 |
// Remember where the user was before the dedicated meeting thread takes over. Close
|
| 270 |
// returns there instead of leaving the app parked on a meeting conversation that
|
|
|
|
| 282 |
try {
|
| 283 |
const result = await meeting.start({
|
| 284 |
conversationId: nextConversation,
|
| 285 |
+
...(override?.title ? { title: override.title } : {}),
|
| 286 |
+
...(override?.projectId ? { projectId: override.projectId } : {}),
|
| 287 |
notes: true,
|
| 288 |
watch: capture.slides,
|
| 289 |
audio: capture.audio,
|
|
|
|
| 319 |
setScreenStream(null);
|
| 320 |
returnConversationIdRef.current = null;
|
| 321 |
meeting.reset();
|
| 322 |
+
return { ok: false, error: result.error || 'The meeting could not start.' };
|
| 323 |
}
|
| 324 |
|
| 325 |
const stream = browserRecorder()?.getScreenPreviewStream?.() || null;
|
|
|
|
| 328 |
if (result.meetingId) void attachContext(result.meetingId, capture.context);
|
| 329 |
|
| 330 |
try {
|
| 331 |
+
await persistStartMarker(nextConversation, capture);
|
| 332 |
} catch (markerError) {
|
| 333 |
console.warn('[MeetingSense] meeting started but start marker could not be persisted', markerError);
|
| 334 |
}
|
|
|
|
| 336 |
// Only after capture succeeds: the normal chat underneath switches to the new
|
| 337 |
// conversation. If permissions fail, the user is still exactly where they started.
|
| 338 |
onOpenConversation?.(nextConversation);
|
| 339 |
+
return { ok: true };
|
| 340 |
} finally {
|
| 341 |
setStarting(false);
|
| 342 |
}
|
| 343 |
+
}, [attachContext, currentCapture, conversationId, meeting, onOpenConversation, persistStartMarker]);
|
| 344 |
|
| 345 |
const begin = useCallback(() => {
|
| 346 |
if (live || starting) return;
|
|
|
|
| 361 |
void meeting.stop();
|
| 362 |
}, [meeting]);
|
| 363 |
|
| 364 |
+
const startWith = useCallback(async (
|
| 365 |
+
next: CaptureOptions,
|
| 366 |
+
meta: { title?: string; projectId?: string },
|
| 367 |
+
acknowledge: boolean,
|
| 368 |
+
): Promise<{ ok: boolean; error?: string }> => {
|
| 369 |
+
if (live || starting) return { ok: false, error: 'A meeting is already recording.' };
|
| 370 |
+
if (!enabled) return { ok: false, error: 'MeetingSense is turned off on this server.' };
|
| 371 |
+
if (acknowledge) rememberConsent(storage);
|
| 372 |
+
if (!consentAcknowledged(storage)) return { ok: false, error: 'Recording needs your consent first.' };
|
| 373 |
+
setCapture(next);
|
| 374 |
+
return actuallyStart({ capture: next, title: meta.title, projectId: meta.projectId });
|
| 375 |
+
}, [live, starting, enabled, storage, actuallyStart]);
|
| 376 |
+
|
| 377 |
const onAccept = useCallback((remember: boolean) => {
|
| 378 |
if (remember) rememberConsent(storage);
|
| 379 |
setPendingStart(false);
|
|
|
|
| 595 |
undoSecondsLeft: meeting.undoSecondsLeft,
|
| 596 |
capture,
|
| 597 |
setCapture,
|
| 598 |
+
startWith,
|
| 599 |
};
|
| 600 |
|
| 601 |
return (
|
frontend/src/ui/meetingsense/library/AssistantDrawer.tsx
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* The meeting assistant: a drawer that answers from this meeting only, with citations that
|
| 3 |
+
* play the recording from the line they point at.
|
| 4 |
+
*
|
| 5 |
+
* The history is kept per meeting for the browser session, so switching tabs or closing the
|
| 6 |
+
* drawer never loses a conversation. Answers come from the same `ask` route the AI Chat tab
|
| 7 |
+
* uses; this drawer finally shows its `cited` stamps and says when an answer is quoted from
|
| 8 |
+
* the transcript because no model answered.
|
| 9 |
+
*/
|
| 10 |
+
import React, { useEffect, useRef, useState } from 'react';
|
| 11 |
+
import { Bot, Loader2, Play, Send, X } from 'lucide-react';
|
| 12 |
+
import { LibraryError, libraryApi } from './api';
|
| 13 |
+
import { stampToMs } from './intelligenceApi';
|
| 14 |
+
|
| 15 |
+
interface Turn { q: string; a: string; cited: string[]; degraded?: string | null; error?: boolean }
|
| 16 |
+
|
| 17 |
+
const KEY = (meetingId: string) => `hp_meeting_assistant:${meetingId}`;
|
| 18 |
+
|
| 19 |
+
function readHistory(meetingId: string): Turn[] {
|
| 20 |
+
try {
|
| 21 |
+
const raw = sessionStorage.getItem(KEY(meetingId));
|
| 22 |
+
const parsed = raw ? JSON.parse(raw) : [];
|
| 23 |
+
return Array.isArray(parsed) ? parsed : [];
|
| 24 |
+
} catch { return []; }
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export function AssistantLauncher({ onOpen, hidden }: { onOpen: () => void; hidden?: boolean }) {
|
| 28 |
+
if (hidden) return null;
|
| 29 |
+
return (
|
| 30 |
+
<button type="button" onClick={onOpen} data-testid="assistant-launcher"
|
| 31 |
+
className="fixed bottom-5 right-5 z-[55] inline-flex h-12 items-center gap-2 rounded-full border border-violet-300/30 bg-gradient-to-r from-fuchsia-600 to-violet-600 pl-4 pr-5 text-sm font-semibold text-white shadow-xl shadow-fuchsia-900/30 hover:from-fuchsia-500 hover:to-violet-500 focus-visible:outline-violet-300">
|
| 32 |
+
<Bot size={18} /> Ask about this meeting
|
| 33 |
+
</button>
|
| 34 |
+
);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export function AssistantDrawer({ meetingId, open, onClose, seed, onSeeded, onSeek, hasTranscript, title }: {
|
| 38 |
+
meetingId: string;
|
| 39 |
+
open: boolean;
|
| 40 |
+
onClose: () => void;
|
| 41 |
+
/** A question handed in from a suggestion card; asked as soon as the drawer opens. */
|
| 42 |
+
seed?: string | null;
|
| 43 |
+
onSeeded?: () => void;
|
| 44 |
+
onSeek?: (ms: number) => void;
|
| 45 |
+
hasTranscript: boolean;
|
| 46 |
+
title?: string;
|
| 47 |
+
}) {
|
| 48 |
+
const [log, setLog] = useState<Turn[]>(() => readHistory(meetingId));
|
| 49 |
+
const [q, setQ] = useState('');
|
| 50 |
+
const [busy, setBusy] = useState(false);
|
| 51 |
+
const input = useRef<HTMLInputElement>(null);
|
| 52 |
+
const end = useRef<HTMLDivElement>(null);
|
| 53 |
+
|
| 54 |
+
useEffect(() => { setLog(readHistory(meetingId)); }, [meetingId]);
|
| 55 |
+
useEffect(() => { try { sessionStorage.setItem(KEY(meetingId), JSON.stringify(log.slice(-40))); } catch { /* ignore */ } }, [meetingId, log]);
|
| 56 |
+
useEffect(() => { end.current?.scrollIntoView?.({ block: 'end' }); }, [log, busy, open]);
|
| 57 |
+
|
| 58 |
+
const ask = async (question: string) => {
|
| 59 |
+
const text = question.trim();
|
| 60 |
+
if (!text || !meetingId) return;
|
| 61 |
+
setQ('');
|
| 62 |
+
setBusy(true);
|
| 63 |
+
try {
|
| 64 |
+
const res = await libraryApi.ask(meetingId, text);
|
| 65 |
+
const answer = String(res.text || res.answer || '').trim();
|
| 66 |
+
const cited: string[] = Array.isArray(res.cited) ? res.cited.map((c: unknown) => String(c)) : [];
|
| 67 |
+
setLog((l) => [...l, {
|
| 68 |
+
q: text,
|
| 69 |
+
a: answer || (res.error ? 'No language model answered, so this could not be answered. The transcript is still searchable.' : 'The meeting does not say.'),
|
| 70 |
+
cited, degraded: res.degraded || null, error: !answer && !!res.error,
|
| 71 |
+
}]);
|
| 72 |
+
} catch (e) {
|
| 73 |
+
setLog((l) => [...l, { q: text, a: e instanceof LibraryError ? e.message : 'The question could not be answered.', cited: [], error: true }]);
|
| 74 |
+
} finally {
|
| 75 |
+
setBusy(false);
|
| 76 |
+
}
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
useEffect(() => {
|
| 80 |
+
if (open && seed) {
|
| 81 |
+
onSeeded?.();
|
| 82 |
+
void ask(seed);
|
| 83 |
+
}
|
| 84 |
+
if (open) setTimeout(() => input.current?.focus(), 50);
|
| 85 |
+
}, [open, seed]); // eslint-disable-line react-hooks/exhaustive-deps
|
| 86 |
+
|
| 87 |
+
// Escape closes the drawer wherever focus is — the page behind it, the input, a chip.
|
| 88 |
+
useEffect(() => {
|
| 89 |
+
if (!open) return undefined;
|
| 90 |
+
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
| 91 |
+
document.addEventListener('keydown', onKey);
|
| 92 |
+
return () => document.removeEventListener('keydown', onKey);
|
| 93 |
+
}, [open, onClose]);
|
| 94 |
+
|
| 95 |
+
if (!open) return null;
|
| 96 |
+
return (
|
| 97 |
+
<div className="fixed inset-0 z-[60] flex justify-end bg-black/50" onClick={onClose} data-testid="assistant-drawer">
|
| 98 |
+
<aside role="dialog" aria-modal="true" aria-label="Meeting assistant"
|
| 99 |
+
className="flex h-full w-full flex-col border-l border-white/10 bg-[#0b0b10] sm:w-[min(94vw,440px)]"
|
| 100 |
+
onClick={(e) => e.stopPropagation()}
|
| 101 |
+
onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}>
|
| 102 |
+
<header className="flex items-center gap-3 border-b border-white/[0.06] px-4 py-3">
|
| 103 |
+
<span className="grid h-9 w-9 place-items-center rounded-xl bg-violet-500/20 text-violet-200"><Bot size={18} /></span>
|
| 104 |
+
<div className="min-w-0 flex-1">
|
| 105 |
+
<h2 className="font-semibold leading-tight">Ask about this meeting</h2>
|
| 106 |
+
<p className="truncate text-xs text-white/50">{title || 'Answers come only from this meeting’s transcript.'}</p>
|
| 107 |
+
</div>
|
| 108 |
+
<button type="button" onClick={onClose} aria-label="Close assistant" className="grid h-9 w-9 place-items-center rounded-full hover:bg-white/10"><X size={16} /></button>
|
| 109 |
+
</header>
|
| 110 |
+
|
| 111 |
+
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-4">
|
| 112 |
+
{!hasTranscript ? <p className="text-sm text-white/55">Questions are answered from the transcript, and this meeting does not have one yet.</p> : null}
|
| 113 |
+
{hasTranscript && !log.length ? (
|
| 114 |
+
<p className="text-sm text-white/55">Ask what was decided, who owns what, why an approach was chosen, or what is still open. Each answer points at the lines it rests on.</p>
|
| 115 |
+
) : null}
|
| 116 |
+
{log.map((m, i) => (
|
| 117 |
+
<div key={i} className="space-y-1.5">
|
| 118 |
+
<p className="ml-auto w-fit max-w-[85%] rounded-2xl rounded-br-md bg-violet-600/40 px-3 py-2 text-sm">{m.q}</p>
|
| 119 |
+
<div className="w-fit max-w-[92%] rounded-2xl rounded-bl-md border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-white/85">
|
| 120 |
+
<p className="whitespace-pre-line">{m.a}</p>
|
| 121 |
+
{m.cited.length ? (
|
| 122 |
+
<div className="mt-2 flex flex-wrap gap-1.5" aria-label="Sources in the transcript">
|
| 123 |
+
{m.cited.map((c) => {
|
| 124 |
+
const ms = stampToMs(c);
|
| 125 |
+
return ms === null ? null : (
|
| 126 |
+
<button key={c} type="button" onClick={() => onSeek?.(ms)} disabled={!onSeek} aria-label={`Play from ${c}`}
|
| 127 |
+
className="inline-flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.04] px-1.5 py-0.5 text-xs tabular-nums text-violet-200 hover:bg-violet-500/20 hover:text-white disabled:text-white/50">
|
| 128 |
+
<Play size={10} /> {c}
|
| 129 |
+
</button>
|
| 130 |
+
);
|
| 131 |
+
})}
|
| 132 |
+
</div>
|
| 133 |
+
) : null}
|
| 134 |
+
{m.degraded === 'extractive' ? <p className="mt-1.5 text-xs text-amber-200/90">No language model answered — these are the transcript lines that match your question.</p> : null}
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
))}
|
| 138 |
+
{busy ? <p className="flex items-center gap-2 text-sm text-white/50"><Loader2 size={14} className="animate-spin" /> Reading the transcript…</p> : null}
|
| 139 |
+
<div ref={end} />
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
<form className="flex gap-2 border-t border-white/[0.06] p-3" onSubmit={(e) => { e.preventDefault(); void ask(q); }}>
|
| 143 |
+
<input ref={input} value={q} onChange={(e) => setQ(e.target.value)} disabled={!hasTranscript || busy}
|
| 144 |
+
placeholder="Ask a question about this meeting…" aria-label="Ask a question about this meeting"
|
| 145 |
+
className="h-11 min-w-0 flex-1 rounded-xl border border-white/10 bg-white/[0.03] px-3 text-sm focus:border-violet-400/60 focus:outline-none disabled:opacity-50" />
|
| 146 |
+
<button type="submit" disabled={busy || !q.trim() || !hasTranscript} aria-label="Ask"
|
| 147 |
+
className="grid h-11 w-11 place-items-center rounded-xl bg-violet-600 hover:bg-violet-500 disabled:opacity-50"><Send size={16} /></button>
|
| 148 |
+
</form>
|
| 149 |
+
</aside>
|
| 150 |
+
</div>
|
| 151 |
+
);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
export default AssistantDrawer;
|
frontend/src/ui/meetingsense/library/FolderSyncPanel.tsx
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Folder Sync — connect a folder such as D:\AIRecord and import finished recordings from it.
|
| 3 |
+
*
|
| 4 |
+
* The server decides what it may read: a folder must be below one the operator allowed
|
| 5 |
+
* (MEETINGS_SYNC_ALLOWED_ROOTS). When it is not, this panel says exactly that and what to
|
| 6 |
+
* set — a typed path never grants access by itself. Originals are only read; meetings
|
| 7 |
+
* already imported stay when a folder is disconnected.
|
| 8 |
+
*/
|
| 9 |
+
import React, { useCallback, useEffect, useState } from 'react';
|
| 10 |
+
import { FolderSync, Loader2, Pause, Play, RefreshCw, Trash2, X } from 'lucide-react';
|
| 11 |
+
import { ModalSheet } from '../../components/ModalSheet';
|
| 12 |
+
import { LibraryError, libraryApi, type SyncSource } from './api';
|
| 13 |
+
import { formatDateTime } from './format';
|
| 14 |
+
|
| 15 |
+
export function FolderSyncPanel({ onClose, onImported, suggestion }: {
|
| 16 |
+
onClose: () => void; onImported: () => void; suggestion?: string;
|
| 17 |
+
}) {
|
| 18 |
+
const [sources, setSources] = useState<SyncSource[]>([]);
|
| 19 |
+
const [enabled, setEnabled] = useState(true);
|
| 20 |
+
const [allowed, setAllowed] = useState<string[]>([]);
|
| 21 |
+
const [interval, setIntervalS] = useState(60);
|
| 22 |
+
const [path, setPath] = useState(suggestion || 'D:\\AIRecord');
|
| 23 |
+
const [projects, setProjects] = useState<Array<{ id: string; name: string }>>([]);
|
| 24 |
+
const [projectId, setProjectId] = useState('');
|
| 25 |
+
const [busy, setBusy] = useState<string | null>(null);
|
| 26 |
+
const [error, setError] = useState<{ message: string; hint?: string } | null>(null);
|
| 27 |
+
const [result, setResult] = useState<string | null>(null);
|
| 28 |
+
|
| 29 |
+
const load = useCallback(async () => {
|
| 30 |
+
try {
|
| 31 |
+
const body = await libraryApi.sources();
|
| 32 |
+
setSources(body.sources);
|
| 33 |
+
setEnabled(body.enabled);
|
| 34 |
+
setAllowed(body.allowed_roots);
|
| 35 |
+
setIntervalS(body.interval_s);
|
| 36 |
+
} catch (e) {
|
| 37 |
+
setError({ message: e instanceof LibraryError ? e.message : 'Folder sync could not be loaded.' });
|
| 38 |
+
}
|
| 39 |
+
}, []);
|
| 40 |
+
|
| 41 |
+
useEffect(() => { void load(); void libraryApi.projects().then(setProjects); }, [load]);
|
| 42 |
+
|
| 43 |
+
const run = async (key: string, fn: () => Promise<unknown>) => {
|
| 44 |
+
setBusy(key);
|
| 45 |
+
setError(null);
|
| 46 |
+
try {
|
| 47 |
+
await fn();
|
| 48 |
+
await load();
|
| 49 |
+
} catch (e) {
|
| 50 |
+
setError(e instanceof LibraryError ? { message: e.message, hint: e.hint } : { message: 'That did not work.' });
|
| 51 |
+
} finally {
|
| 52 |
+
setBusy(null);
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
const pickFolder = async () => {
|
| 57 |
+
// The desktop app can open the system folder picker; in a browser the path is typed.
|
| 58 |
+
const bridge = (window as any).homepilot?.meetings;
|
| 59 |
+
if (bridge?.pickFolder) {
|
| 60 |
+
const picked = await bridge.pickFolder();
|
| 61 |
+
if (picked) setPath(picked);
|
| 62 |
+
}
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const scan = (s: SyncSource) => run(`scan:${s.id}`, async () => {
|
| 66 |
+
const body = await libraryApi.scanSource(s.id);
|
| 67 |
+
const c = body.counts;
|
| 68 |
+
setResult(`${s.display_name}: ${c.imported || 0} imported, ${c.already || 0} already in the library, ${c.waiting || 0} still being written${c.failed ? `, ${c.failed} failed` : ''}.`);
|
| 69 |
+
if (c.imported) onImported();
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<ModalSheet
|
| 74 |
+
labelledBy="hp-sync-title"
|
| 75 |
+
onRequestClose={onClose}
|
| 76 |
+
header={(
|
| 77 |
+
<div className="flex items-start gap-3 px-5 py-4">
|
| 78 |
+
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl border border-white/10 bg-white/[0.04] text-violet-200"><FolderSync size={18} /></div>
|
| 79 |
+
<div className="min-w-0 flex-1">
|
| 80 |
+
<h2 id="hp-sync-title" className="text-lg font-semibold">Folder Sync</h2>
|
| 81 |
+
<p className="text-sm text-white/55">Import finished recordings from a folder, such as the Insta360 AIRecord folder. Checked every {Math.round(interval / 60) || 1} min and on demand.</p>
|
| 82 |
+
</div>
|
| 83 |
+
<button type="button" onClick={onClose} aria-label="Close" className="grid h-9 w-9 place-items-center rounded-full text-white/60 hover:bg-white/10"><X size={18} /></button>
|
| 84 |
+
</div>
|
| 85 |
+
)}
|
| 86 |
+
>
|
| 87 |
+
<div className="space-y-4 px-5 pb-6">
|
| 88 |
+
{!enabled ? (
|
| 89 |
+
<div className="rounded-xl border border-amber-400/30 bg-amber-500/10 p-3 text-sm text-amber-100">
|
| 90 |
+
<p className="font-semibold">Folder sync is turned off on this server.</p>
|
| 91 |
+
<p className="mt-1 text-amber-100/80">
|
| 92 |
+
HomePilot only reads folders its operator allows. To sync <code className="rounded bg-black/30 px-1">D:\AIRecord</code>,
|
| 93 |
+
set <code className="rounded bg-black/30 px-1">MEETINGS_SYNC_ALLOWED_ROOTS=/mnt/d/AIRecord</code> (HomePilot under WSL)
|
| 94 |
+
or <code className="rounded bg-black/30 px-1">D:\AIRecord</code> (HomePilot on Windows) and restart. Until then, use Import Files.
|
| 95 |
+
</p>
|
| 96 |
+
</div>
|
| 97 |
+
) : null}
|
| 98 |
+
|
| 99 |
+
{sources.length ? (
|
| 100 |
+
<ul className="space-y-2" data-testid="sync-sources">
|
| 101 |
+
{sources.map((s) => (
|
| 102 |
+
<li key={s.id} className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
| 103 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 104 |
+
<span className={`h-2 w-2 rounded-full ${s.enabled ? (s.last_error ? 'bg-amber-400' : 'bg-emerald-400') : 'bg-white/30'}`} aria-hidden />
|
| 105 |
+
<span className="font-semibold">{s.display_name}</span>
|
| 106 |
+
<code className="truncate text-xs text-white/50">{s.path}</code>
|
| 107 |
+
<div className="ml-auto flex gap-1.5">
|
| 108 |
+
<IconButton label={`Sync ${s.display_name} now`} busy={busy === `scan:${s.id}`} onClick={() => void scan(s)}><RefreshCw size={14} /></IconButton>
|
| 109 |
+
<IconButton label={s.enabled ? 'Pause' : 'Resume'} busy={busy === `toggle:${s.id}`}
|
| 110 |
+
onClick={() => void run(`toggle:${s.id}`, () => libraryApi.updateSource(s.id, { enabled: !s.enabled }))}>
|
| 111 |
+
{s.enabled ? <Pause size={14} /> : <Play size={14} />}
|
| 112 |
+
</IconButton>
|
| 113 |
+
<IconButton label={`Disconnect ${s.display_name}`} busy={busy === `del:${s.id}`}
|
| 114 |
+
onClick={() => {
|
| 115 |
+
if (window.confirm(`Disconnect ${s.path}? Meetings already imported from it stay in your library.`)) {
|
| 116 |
+
void run(`del:${s.id}`, () => libraryApi.deleteSource(s.id));
|
| 117 |
+
}
|
| 118 |
+
}}><Trash2 size={14} /></IconButton>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
<p className="mt-2 text-xs text-white/50">
|
| 122 |
+
{s.enabled ? 'Watching' : 'Paused'} · Last sync {s.last_success_at ? formatDateTime(s.last_success_at) : 'never'}
|
| 123 |
+
{s.counts && Object.keys(s.counts).length
|
| 124 |
+
? ` · ${s.counts.found || 0} found, ${s.counts.imported || 0} new, ${s.counts.waiting || 0} waiting` : ''}
|
| 125 |
+
</p>
|
| 126 |
+
{s.last_error ? <p className="mt-1 text-xs text-amber-200">{s.last_error}</p> : null}
|
| 127 |
+
</li>
|
| 128 |
+
))}
|
| 129 |
+
</ul>
|
| 130 |
+
) : null}
|
| 131 |
+
|
| 132 |
+
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
|
| 133 |
+
<h3 className="text-sm font-semibold">Connect a folder</h3>
|
| 134 |
+
<div className="mt-2 flex gap-2">
|
| 135 |
+
<input value={path} onChange={(e) => setPath(e.target.value)} aria-label="Folder path" placeholder="D:\AIRecord"
|
| 136 |
+
className="h-10 min-w-0 flex-1 rounded-xl border border-white/10 bg-black px-3 font-mono text-sm text-white focus:border-violet-400/60 focus:outline-none" />
|
| 137 |
+
{(window as any).homepilot?.meetings?.pickFolder ? (
|
| 138 |
+
<button type="button" onClick={() => void pickFolder()} className="h-10 rounded-xl border border-white/15 px-3 text-sm hover:bg-white/10">Browse…</button>
|
| 139 |
+
) : null}
|
| 140 |
+
</div>
|
| 141 |
+
<label className="mt-3 block text-xs text-white/60">
|
| 142 |
+
Put new meetings in
|
| 143 |
+
<select value={projectId} onChange={(e) => setProjectId(e.target.value)}
|
| 144 |
+
className="mt-1 h-9 w-full rounded-lg border border-white/10 bg-black px-2 text-sm text-white">
|
| 145 |
+
<option value="">Unsorted</option>
|
| 146 |
+
{projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
| 147 |
+
</select>
|
| 148 |
+
</label>
|
| 149 |
+
{allowed.length ? <p className="mt-2 text-xs text-white/40">Allowed on this server: {allowed.join(' · ')}</p> : null}
|
| 150 |
+
<button type="button" disabled={!path.trim() || busy === 'add'}
|
| 151 |
+
onClick={() => void run('add', async () => {
|
| 152 |
+
const { source } = await libraryApi.addSource(path.trim(), '', projectId);
|
| 153 |
+
await scan(source);
|
| 154 |
+
})}
|
| 155 |
+
className="mt-3 inline-flex h-10 items-center gap-2 rounded-xl bg-violet-600 px-4 text-sm font-semibold hover:bg-violet-500 disabled:opacity-50">
|
| 156 |
+
{busy === 'add' ? <Loader2 size={15} className="animate-spin" /> : <FolderSync size={15} />} Connect and sync
|
| 157 |
+
</button>
|
| 158 |
+
<p className="mt-2 text-xs text-white/40">Recordings are imported once they have finished writing. Source files are never changed or deleted.</p>
|
| 159 |
+
</div>
|
| 160 |
+
|
| 161 |
+
{result ? <p className="rounded-xl border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-100" role="status">{result}</p> : null}
|
| 162 |
+
{error ? (
|
| 163 |
+
<div className="rounded-xl border border-rose-400/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-100" role="alert">
|
| 164 |
+
{error.message}{error.hint ? <span className="mt-1 block text-xs text-rose-200/80">{error.hint}</span> : null}
|
| 165 |
+
</div>
|
| 166 |
+
) : null}
|
| 167 |
+
</div>
|
| 168 |
+
</ModalSheet>
|
| 169 |
+
);
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
function IconButton({ label, busy, onClick, children }: { label: string; busy?: boolean; onClick: () => void; children: React.ReactNode }) {
|
| 173 |
+
return (
|
| 174 |
+
<button type="button" onClick={onClick} aria-label={label} title={label} disabled={busy}
|
| 175 |
+
className="grid h-8 w-8 place-items-center rounded-lg border border-white/10 text-white/70 hover:bg-white/10 hover:text-white disabled:opacity-50">
|
| 176 |
+
{busy ? <Loader2 size={14} className="animate-spin" /> : children}
|
| 177 |
+
</button>
|
| 178 |
+
);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
export default FolderSyncPanel;
|
frontend/src/ui/meetingsense/library/ImportDialog.tsx
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Import Files — upload, preview, confirm.
|
| 3 |
+
*
|
| 4 |
+
* Nothing is created until "Import": the preview shows what HomePilot found in the files (an
|
| 5 |
+
* Insta360 / AIRecord folder, a recording, a transcript), how long the audio really is,
|
| 6 |
+
* whether a transcript is usable or the recording is silent, duplicates already in the
|
| 7 |
+
* library, and scheduled meetings a recording seems to belong to. Each meeting can be
|
| 8 |
+
* renamed, skipped, attached or imported on its own.
|
| 9 |
+
*/
|
| 10 |
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
| 11 |
+
import { AlertTriangle, CheckCircle2, CloudUpload, FileAudio, FileText, Loader2, X } from 'lucide-react';
|
| 12 |
+
import { ModalSheet } from '../../components/ModalSheet';
|
| 13 |
+
import { LibraryError, libraryApi, previewImport, type ImportItem, type ImportPreview } from './api';
|
| 14 |
+
import { bytes, formatDateTime, formatDuration } from './format';
|
| 15 |
+
|
| 16 |
+
const ACCEPT = '.mp3,.m4a,.wav,.ogg,.oga,.opus,.flac,.webm,.aac,.mp4,.txt,.md,.srt,.vtt,.json,.zip';
|
| 17 |
+
|
| 18 |
+
interface Choice { action: 'import' | 'skip' | 'attach'; title: string; attach_to?: string; transcribe: boolean }
|
| 19 |
+
|
| 20 |
+
export function ImportDialog({ onClose, onImported, maxUploadMb }: {
|
| 21 |
+
onClose: () => void; onImported: (count: number) => void; maxUploadMb?: number;
|
| 22 |
+
}) {
|
| 23 |
+
const [files, setFiles] = useState<File[]>([]);
|
| 24 |
+
const [projects, setProjects] = useState<Array<{ id: string; name: string }>>([]);
|
| 25 |
+
const [projectId, setProjectId] = useState('');
|
| 26 |
+
const [progress, setProgress] = useState<number | null>(null);
|
| 27 |
+
const [preview, setPreview] = useState<ImportPreview | null>(null);
|
| 28 |
+
const [choices, setChoices] = useState<Record<string, Choice>>({});
|
| 29 |
+
const [error, setError] = useState<{ message: string; hint?: string } | null>(null);
|
| 30 |
+
const [committing, setCommitting] = useState(false);
|
| 31 |
+
const [drag, setDrag] = useState(false);
|
| 32 |
+
const inputRef = useRef<HTMLInputElement>(null);
|
| 33 |
+
const abortRef = useRef<AbortController | null>(null);
|
| 34 |
+
const keyRef = useRef(`imp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
|
| 35 |
+
|
| 36 |
+
useEffect(() => { void libraryApi.projects().then(setProjects); }, []);
|
| 37 |
+
useEffect(() => () => abortRef.current?.abort(), []);
|
| 38 |
+
|
| 39 |
+
const addFiles = (list: FileList | File[] | null) => {
|
| 40 |
+
if (!list) return;
|
| 41 |
+
const next = Array.from(list);
|
| 42 |
+
const tooBig = maxUploadMb ? next.find((f) => f.size > maxUploadMb * 1024 * 1024) : undefined;
|
| 43 |
+
if (tooBig) {
|
| 44 |
+
setError({ message: `${tooBig.name} is larger than ${maxUploadMb} MB.` });
|
| 45 |
+
return;
|
| 46 |
+
}
|
| 47 |
+
setError(null);
|
| 48 |
+
setFiles((cur) => [...cur, ...next.filter((f) => !cur.some((c) => c.name === f.name && c.size === f.size))]);
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
const analyse = async () => {
|
| 52 |
+
setError(null);
|
| 53 |
+
setProgress(0);
|
| 54 |
+
abortRef.current = new AbortController();
|
| 55 |
+
try {
|
| 56 |
+
const body = await previewImport(files, projectId, setProgress, abortRef.current.signal);
|
| 57 |
+
setPreview(body);
|
| 58 |
+
const initial: Record<string, Choice> = {};
|
| 59 |
+
body.items.forEach((item) => {
|
| 60 |
+
initial[item.key] = { action: item.outcome, title: item.title, attach_to: item.attach_to, transcribe: true };
|
| 61 |
+
});
|
| 62 |
+
setChoices(initial);
|
| 63 |
+
} catch (e) {
|
| 64 |
+
if (e instanceof LibraryError && e.code === 'aborted') return;
|
| 65 |
+
setError(e instanceof LibraryError ? { message: e.message, hint: e.hint } : { message: 'The files could not be analysed.' });
|
| 66 |
+
} finally {
|
| 67 |
+
setProgress(null);
|
| 68 |
+
}
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const selected = useMemo(() => Object.values(choices).filter((c) => c.action !== 'skip').length, [choices]);
|
| 72 |
+
|
| 73 |
+
const commit = async () => {
|
| 74 |
+
if (!preview) return;
|
| 75 |
+
setCommitting(true);
|
| 76 |
+
setError(null);
|
| 77 |
+
try {
|
| 78 |
+
const res = await libraryApi.commitImport({
|
| 79 |
+
import_id: preview.import_id,
|
| 80 |
+
idempotency_key: keyRef.current,
|
| 81 |
+
project_id: projectId,
|
| 82 |
+
choices: Object.entries(choices).map(([key, c]) => ({
|
| 83 |
+
key, action: c.action, title: c.title, attach_to: c.action === 'attach' ? c.attach_to : undefined,
|
| 84 |
+
transcribe: c.transcribe,
|
| 85 |
+
})),
|
| 86 |
+
});
|
| 87 |
+
onImported(res.results.filter((r) => r.status === 'created' || r.status === 'attached').length);
|
| 88 |
+
} catch (e) {
|
| 89 |
+
setError(e instanceof LibraryError ? { message: e.message, hint: e.hint } : { message: 'The import failed.' });
|
| 90 |
+
} finally {
|
| 91 |
+
setCommitting(false);
|
| 92 |
+
}
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
const footer = (
|
| 96 |
+
<div className="flex flex-wrap items-center gap-3 px-5 py-3">
|
| 97 |
+
<span className="text-xs text-white/50">
|
| 98 |
+
{preview ? `${selected} of ${preview.items.length} will be imported` : files.length ? `${files.length} file${files.length === 1 ? '' : 's'} chosen` : 'Nothing chosen yet'}
|
| 99 |
+
</span>
|
| 100 |
+
<div className="ml-auto flex gap-2">
|
| 101 |
+
<button type="button" onClick={onClose} className="h-10 rounded-xl border border-white/15 px-4 text-sm hover:bg-white/10">Cancel</button>
|
| 102 |
+
{preview ? (
|
| 103 |
+
<button type="button" onClick={() => void commit()} disabled={committing || selected === 0}
|
| 104 |
+
className="inline-flex h-10 items-center gap-2 rounded-xl bg-violet-600 px-4 text-sm font-semibold hover:bg-violet-500 disabled:opacity-50">
|
| 105 |
+
{committing ? <Loader2 size={15} className="animate-spin" /> : <CheckCircle2 size={15} />} Import {selected || ''}
|
| 106 |
+
</button>
|
| 107 |
+
) : (
|
| 108 |
+
<button type="button" onClick={() => void analyse()} disabled={!files.length || progress !== null}
|
| 109 |
+
className="inline-flex h-10 items-center gap-2 rounded-xl bg-violet-600 px-4 text-sm font-semibold hover:bg-violet-500 disabled:opacity-50">
|
| 110 |
+
{progress !== null ? <Loader2 size={15} className="animate-spin" /> : null}
|
| 111 |
+
{progress !== null ? (progress < 1 ? `Uploading ${Math.round(progress * 100)}%` : 'Analysing…') : 'Preview import'}
|
| 112 |
+
</button>
|
| 113 |
+
)}
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
);
|
| 117 |
+
|
| 118 |
+
return (
|
| 119 |
+
<ModalSheet
|
| 120 |
+
labelledBy="hp-import-title"
|
| 121 |
+
onRequestClose={() => { if (!committing) onClose(); }}
|
| 122 |
+
className="hp-sheet--wide"
|
| 123 |
+
header={(
|
| 124 |
+
<div className="flex items-start gap-3 px-5 py-4">
|
| 125 |
+
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl border border-teal-400/30 bg-teal-500/10 text-teal-200"><CloudUpload size={18} /></div>
|
| 126 |
+
<div className="min-w-0 flex-1">
|
| 127 |
+
<h2 id="hp-import-title" className="text-lg font-semibold">Import Files</h2>
|
| 128 |
+
<p className="text-sm text-white/55">Recordings, transcripts, ZIP archives and Insta360 / AIRecord folders. Files are copied into HomePilot; originals are not changed.</p>
|
| 129 |
+
</div>
|
| 130 |
+
<button type="button" onClick={onClose} aria-label="Close" className="grid h-9 w-9 place-items-center rounded-full text-white/60 hover:bg-white/10"><X size={18} /></button>
|
| 131 |
+
</div>
|
| 132 |
+
)}
|
| 133 |
+
footer={footer}
|
| 134 |
+
>
|
| 135 |
+
<div className="space-y-4 px-5 pb-5">
|
| 136 |
+
{!preview ? (
|
| 137 |
+
<>
|
| 138 |
+
<div
|
| 139 |
+
onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
|
| 140 |
+
onDragLeave={() => setDrag(false)}
|
| 141 |
+
onDrop={(e) => { e.preventDefault(); setDrag(false); addFiles(e.dataTransfer.files); }}
|
| 142 |
+
className={`rounded-2xl border-2 border-dashed p-8 text-center transition-colors ${drag ? 'border-teal-400/60 bg-teal-500/10' : 'border-white/15 bg-white/[0.02]'}`}
|
| 143 |
+
>
|
| 144 |
+
<CloudUpload size={28} className="mx-auto text-white/40" />
|
| 145 |
+
<p className="mt-2 text-sm text-white/70">Drop files here, or</p>
|
| 146 |
+
<button type="button" onClick={() => inputRef.current?.click()} className="mt-2 rounded-xl border border-white/15 px-4 py-2 text-sm font-semibold hover:bg-white/10">Choose files</button>
|
| 147 |
+
<input ref={inputRef} type="file" multiple accept={ACCEPT} className="hidden" data-testid="import-input"
|
| 148 |
+
onChange={(e) => { addFiles(e.target.files); e.target.value = ''; }} />
|
| 149 |
+
<p className="mt-3 text-xs text-white/40">MP3, M4A, WAV, OGG, FLAC, WebM · TXT, MD, SRT, VTT, JSON · ZIP{maxUploadMb ? ` · up to ${maxUploadMb} MB each` : ''}</p>
|
| 150 |
+
</div>
|
| 151 |
+
{files.length ? (
|
| 152 |
+
<ul className="divide-y divide-white/[0.06] rounded-xl border border-white/10">
|
| 153 |
+
{files.map((f) => (
|
| 154 |
+
<li key={f.name + f.size} className="flex items-center gap-3 px-3 py-2 text-sm">
|
| 155 |
+
{/\.(txt|md|srt|vtt|json)$/i.test(f.name) ? <FileText size={16} className="text-white/50" /> : <FileAudio size={16} className="text-white/50" />}
|
| 156 |
+
<span className="min-w-0 flex-1 truncate">{f.name}</span>
|
| 157 |
+
<span className="text-xs text-white/40">{bytes(f.size)}</span>
|
| 158 |
+
<button type="button" aria-label={`Remove ${f.name}`} onClick={() => setFiles((cur) => cur.filter((c) => c !== f))}
|
| 159 |
+
className="text-white/40 hover:text-white"><X size={14} /></button>
|
| 160 |
+
</li>
|
| 161 |
+
))}
|
| 162 |
+
</ul>
|
| 163 |
+
) : null}
|
| 164 |
+
{progress !== null ? (
|
| 165 |
+
<div className="h-1.5 overflow-hidden rounded-full bg-white/10" role="progressbar" aria-valuenow={Math.round(progress * 100)} aria-valuemin={0} aria-valuemax={100}>
|
| 166 |
+
<div className="h-full bg-teal-400 transition-[width]" style={{ width: `${Math.round(progress * 100)}%` }} />
|
| 167 |
+
</div>
|
| 168 |
+
) : null}
|
| 169 |
+
</>
|
| 170 |
+
) : (
|
| 171 |
+
<>
|
| 172 |
+
<div className="flex flex-wrap gap-2 text-xs">
|
| 173 |
+
<Stat label="Meetings found" value={preview.totals.meetings} />
|
| 174 |
+
<Stat label="Already in library" value={preview.totals.duplicates} />
|
| 175 |
+
<Stat label="Need transcription" value={preview.totals.needs_transcription} />
|
| 176 |
+
<Stat label="Audio to transcribe" value={`${preview.totals.audio_minutes_to_transcribe} min`} />
|
| 177 |
+
</div>
|
| 178 |
+
<ul className="space-y-3" data-testid="import-preview">
|
| 179 |
+
{preview.items.map((item) => (
|
| 180 |
+
<PreviewRow key={item.key} item={item} choice={choices[item.key]}
|
| 181 |
+
onChange={(c) => setChoices((cur) => ({ ...cur, [item.key]: { ...cur[item.key], ...c } }))} />
|
| 182 |
+
))}
|
| 183 |
+
</ul>
|
| 184 |
+
</>
|
| 185 |
+
)}
|
| 186 |
+
<label className="block text-sm text-white/70">
|
| 187 |
+
Add to project <span className="text-white/40">(optional)</span>
|
| 188 |
+
<select value={projectId} onChange={(e) => setProjectId(e.target.value)}
|
| 189 |
+
className="mt-1 h-10 w-full rounded-xl border border-white/10 bg-black px-3 text-sm text-white focus:border-violet-400/60 focus:outline-none">
|
| 190 |
+
<option value="">No project — Unsorted</option>
|
| 191 |
+
{projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
| 192 |
+
</select>
|
| 193 |
+
</label>
|
| 194 |
+
{error ? (
|
| 195 |
+
<div className="rounded-xl border border-rose-400/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-100" role="alert">
|
| 196 |
+
{error.message}{error.hint ? <span className="mt-1 block text-xs text-rose-200/80">{error.hint}</span> : null}
|
| 197 |
+
</div>
|
| 198 |
+
) : null}
|
| 199 |
+
</div>
|
| 200 |
+
</ModalSheet>
|
| 201 |
+
);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
| 205 |
+
return (
|
| 206 |
+
<span className="rounded-lg border border-white/10 bg-white/[0.03] px-2.5 py-1 text-white/70">
|
| 207 |
+
{label}: <strong className="text-white">{value}</strong>
|
| 208 |
+
</span>
|
| 209 |
+
);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
const OUTCOME_TEXT: Record<string, string> = {
|
| 213 |
+
imported: 'Transcript included',
|
| 214 |
+
needs_transcription: 'Will be transcribed on this computer',
|
| 215 |
+
no_speech: 'No speech detected — kept, not transcribed',
|
| 216 |
+
transcript_only: 'Transcript only',
|
| 217 |
+
failed: 'The recording could not be read',
|
| 218 |
+
none: 'Nothing to transcribe',
|
| 219 |
+
};
|
| 220 |
+
|
| 221 |
+
function PreviewRow({ item, choice, onChange }: { item: ImportItem; choice?: Choice; onChange: (c: Partial<Choice>) => void }) {
|
| 222 |
+
if (!choice) return null;
|
| 223 |
+
const skipped = choice.action === 'skip';
|
| 224 |
+
return (
|
| 225 |
+
<li className={`rounded-2xl border p-4 ${skipped ? 'border-white/[0.06] bg-white/[0.01] opacity-70' : 'border-white/10 bg-white/[0.03]'}`}>
|
| 226 |
+
<div className="flex flex-wrap items-start gap-3">
|
| 227 |
+
<div className="min-w-0 flex-1">
|
| 228 |
+
<div className="flex flex-wrap items-center gap-2 text-xs">
|
| 229 |
+
<span className="rounded-md border border-indigo-400/25 bg-indigo-500/15 px-2 py-0.5 text-indigo-200">{item.source_label}</span>
|
| 230 |
+
<span className="text-white/50">{formatDateTime(item.started_at)}{item.time_source === 'file_date' ? ' (from file date)' : ''}</span>
|
| 231 |
+
{item.audio?.duration_ms ? <span className="text-white/50">· {formatDuration(item.audio.duration_ms)}</span> : null}
|
| 232 |
+
</div>
|
| 233 |
+
<input
|
| 234 |
+
value={choice.title}
|
| 235 |
+
onChange={(e) => onChange({ title: e.target.value })}
|
| 236 |
+
aria-label="Meeting title"
|
| 237 |
+
disabled={skipped}
|
| 238 |
+
className="mt-2 w-full rounded-lg border border-transparent bg-transparent px-1 text-base font-semibold text-white hover:border-white/10 focus:border-violet-400/60 focus:outline-none"
|
| 239 |
+
/>
|
| 240 |
+
<p className="mt-1 text-sm text-white/60">{OUTCOME_TEXT[item.transcript_state] || item.transcript_state}</p>
|
| 241 |
+
<p className="mt-1 truncate text-xs text-white/40" title={item.files.map((f) => f.rel).join('\n')}>
|
| 242 |
+
{item.files.map((f) => f.name).join(' · ')}
|
| 243 |
+
{item.ignored_files.length ? ` · ${item.ignored_files.length} device working file${item.ignored_files.length === 1 ? '' : 's'} not imported` : ''}
|
| 244 |
+
</p>
|
| 245 |
+
</div>
|
| 246 |
+
<select value={choice.action} onChange={(e) => onChange({ action: e.target.value as Choice['action'] })}
|
| 247 |
+
aria-label={`What to do with ${item.title}`}
|
| 248 |
+
className="h-9 rounded-lg border border-white/10 bg-black px-2 text-sm text-white focus:border-violet-400/60 focus:outline-none">
|
| 249 |
+
<option value="import">Import as new meeting</option>
|
| 250 |
+
{item.scheduled_matches.length ? <option value="attach">Attach to scheduled meeting</option> : null}
|
| 251 |
+
<option value="skip">Skip</option>
|
| 252 |
+
</select>
|
| 253 |
+
</div>
|
| 254 |
+
{item.duplicate_of ? (
|
| 255 |
+
<p className="mt-2 text-xs text-sky-200">Already in your library — skipped unless you choose to import it again.</p>
|
| 256 |
+
) : null}
|
| 257 |
+
{choice.action === 'attach' && item.scheduled_matches.length ? (
|
| 258 |
+
<label className="mt-2 block text-xs text-white/60">
|
| 259 |
+
Scheduled meeting
|
| 260 |
+
<select value={choice.attach_to || ''} onChange={(e) => onChange({ attach_to: e.target.value })}
|
| 261 |
+
className="mt-1 h-9 w-full rounded-lg border border-white/10 bg-black px-2 text-sm text-white">
|
| 262 |
+
{item.scheduled_matches.map((m) => (
|
| 263 |
+
<option key={m.meeting_id} value={m.meeting_id}>{m.title} — {m.minutes_apart} min apart</option>
|
| 264 |
+
))}
|
| 265 |
+
</select>
|
| 266 |
+
</label>
|
| 267 |
+
) : null}
|
| 268 |
+
{item.warnings.length ? (
|
| 269 |
+
<ul className="mt-2 space-y-1">
|
| 270 |
+
{item.warnings.map((w) => (
|
| 271 |
+
<li key={w} className="flex gap-2 text-xs text-amber-200/90"><AlertTriangle size={13} className="mt-0.5 shrink-0" /> {w}</li>
|
| 272 |
+
))}
|
| 273 |
+
</ul>
|
| 274 |
+
) : null}
|
| 275 |
+
</li>
|
| 276 |
+
);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
export default ImportDialog;
|
frontend/src/ui/meetingsense/library/InsightsPanel.tsx
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Meeting insights on the Overview tab — a compact card: what the analysis found, in numbers,
|
| 3 |
+
* the verification line, suggested questions, and the way into the Visual Summary tab, where
|
| 4 |
+
* the full executive brief lives. Without `onOpenVisual` (no tab to go to) it shows the whole
|
| 5 |
+
* dark visual page here instead.
|
| 6 |
+
*
|
| 7 |
+
* Hidden entirely when the feature is off on the server (the analysis route answers 404), so
|
| 8 |
+
* an install without `MEETINGS_INTELLIGENCE` sees the page exactly as before.
|
| 9 |
+
*/
|
| 10 |
+
import React from 'react';
|
| 11 |
+
import { ArrowRight, Bot, Download, HelpCircle, Loader2, RefreshCw, Sparkles } from 'lucide-react';
|
| 12 |
+
import { formatDateTime } from './format';
|
| 13 |
+
import { analysisToMarkdown, type ExecutiveBrief, type MeetingAnalysis, type SuggestedQuestion, type ValidatorReport } from './intelligenceApi';
|
| 14 |
+
import { VisualSummary } from './meeting-summary/VisualSummary';
|
| 15 |
+
import { useAnalysis } from './useAnalysis';
|
| 16 |
+
|
| 17 |
+
export function InsightsPanel({ meetingId, hasTranscript, title, onSeek, onAsk, onChanged, onOpenVisual }: {
|
| 18 |
+
meetingId: string;
|
| 19 |
+
hasTranscript: boolean;
|
| 20 |
+
title?: string;
|
| 21 |
+
onSeek?: (ms: number) => void;
|
| 22 |
+
onAsk?: (question: string) => void;
|
| 23 |
+
onChanged?: () => void;
|
| 24 |
+
/** Open the Visual Summary tab; when given, the Overview shows the compact card. */
|
| 25 |
+
onOpenVisual?: () => void;
|
| 26 |
+
}) {
|
| 27 |
+
const a = useAnalysis(meetingId, onChanged);
|
| 28 |
+
const payload = a.payload;
|
| 29 |
+
|
| 30 |
+
const download = () => {
|
| 31 |
+
if (!payload?.analysis) return;
|
| 32 |
+
const blob = new Blob([analysisToMarkdown(payload.analysis)], { type: 'text/markdown;charset=utf-8' });
|
| 33 |
+
const href = URL.createObjectURL(blob);
|
| 34 |
+
const el = document.createElement('a');
|
| 35 |
+
el.href = href;
|
| 36 |
+
el.download = `${(payload.analysis.title || title || 'meeting').replace(/[^\w\- ]+/g, '').trim().replace(/\s+/g, '-').toLowerCase() || 'meeting'}-insights.md`;
|
| 37 |
+
document.body.appendChild(el);
|
| 38 |
+
el.click();
|
| 39 |
+
el.remove();
|
| 40 |
+
setTimeout(() => URL.revokeObjectURL(href), 1000);
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
if (a.hidden) return null;
|
| 44 |
+
if (!payload && !a.error) {
|
| 45 |
+
return <div className="flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.02] p-4 text-sm text-white/55" aria-busy="true"><Loader2 size={14} className="animate-spin" /> Loading insights…</div>;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const state = payload?.state;
|
| 49 |
+
const ready = a.ready;
|
| 50 |
+
const job = payload?.job;
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<section className="space-y-3" aria-label="Meeting insights" data-testid="insights-panel">
|
| 54 |
+
{a.error ? <p className="rounded-xl border border-rose-400/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-100" role="alert">{a.error}</p> : null}
|
| 55 |
+
|
| 56 |
+
{!ready && state === 'no_transcript' ? (
|
| 57 |
+
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-4 text-sm text-white/55">Insights are written once there is a transcript.</div>
|
| 58 |
+
) : null}
|
| 59 |
+
|
| 60 |
+
{!ready && (state === 'none' || state === 'failed') && hasTranscript ? (
|
| 61 |
+
<div className="rounded-2xl border border-violet-400/25 bg-gradient-to-br from-violet-600/15 via-fuchsia-600/10 to-transparent p-5" data-testid="insights-cta">
|
| 62 |
+
<div className="flex items-start gap-3">
|
| 63 |
+
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-violet-500/20 text-violet-200"><Sparkles size={18} /></span>
|
| 64 |
+
<div className="min-w-0 flex-1">
|
| 65 |
+
<h3 className="font-semibold">Turn this meeting into a one-page summary</h3>
|
| 66 |
+
<p className="mt-1 text-sm leading-relaxed text-white/65">
|
| 67 |
+
Decisions, risks, action items and open questions, each linked to the exact line it came from, plus questions you can ask the assistant.
|
| 68 |
+
{state === 'failed' && job?.error ? <span className="block pt-1 text-rose-200">Last attempt failed: {job.error}</span> : null}
|
| 69 |
+
</p>
|
| 70 |
+
<button type="button" onClick={() => void a.regenerate()} disabled={a.busy}
|
| 71 |
+
className="mt-3 inline-flex h-10 items-center gap-2 rounded-xl bg-gradient-to-r from-fuchsia-600 to-violet-600 px-4 text-sm font-semibold shadow-lg shadow-fuchsia-900/30 hover:from-fuchsia-500 hover:to-violet-500 disabled:opacity-60">
|
| 72 |
+
{a.busy ? <Loader2 size={15} className="animate-spin" /> : <Sparkles size={15} />} {state === 'failed' ? 'Try again' : 'Generate insights'}
|
| 73 |
+
</button>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
) : null}
|
| 78 |
+
|
| 79 |
+
{a.pending && !ready ? <Progress stage={job?.stage || 'queued'} progress={job?.progress || 0} /> : null}
|
| 80 |
+
|
| 81 |
+
{ready && payload?.analysis && payload.visual ? (
|
| 82 |
+
<>
|
| 83 |
+
<div className="flex flex-wrap items-center gap-2 text-xs text-white/55">
|
| 84 |
+
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 px-2 py-0.5">
|
| 85 |
+
{payload.degraded ? 'No model · extractive' : payload.model ? `Model · ${payload.model}` : 'Written by the configured model'}
|
| 86 |
+
</span>
|
| 87 |
+
{payload.created_at ? <span>Generated {formatDateTime(payload.created_at)}</span> : null}
|
| 88 |
+
{payload.versions && payload.versions > 1 ? <span>· version {payload.versions}</span> : null}
|
| 89 |
+
{a.pending ? <span className="inline-flex items-center gap-1 text-violet-200"><Loader2 size={12} className="animate-spin" /> Updating…</span> : null}
|
| 90 |
+
<div className="ml-auto flex items-center gap-2">
|
| 91 |
+
<button type="button" onClick={download} className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-white/10 px-2.5 text-xs text-white/80 hover:bg-white/10"><Download size={13} /> Download .md</button>
|
| 92 |
+
<button type="button" onClick={() => void a.regenerate()} disabled={a.busy || a.pending} className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-white/10 px-2.5 text-xs text-white/80 hover:bg-white/10 disabled:opacity-50"><RefreshCw size={13} /> Regenerate</button>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
{onOpenVisual ? (
|
| 96 |
+
<CompactBrief analysis={payload.analysis} brief={payload.visual.brief} report={payload.report} onOpen={onOpenVisual} />
|
| 97 |
+
) : (
|
| 98 |
+
<VisualSummary analysis={payload.analysis} spec={payload.visual} onSeek={onSeek} footer={<ValidatorLine report={payload.report} />} />
|
| 99 |
+
)}
|
| 100 |
+
{payload.questions?.length ? <SuggestedQuestions questions={payload.questions} onAsk={onAsk} /> : null}
|
| 101 |
+
</>
|
| 102 |
+
) : null}
|
| 103 |
+
</section>
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/** The analysis at a glance, and the door to the full brief. Counts only; no text is written here. */
|
| 108 |
+
function CompactBrief({ analysis, brief, report, onOpen }: { analysis: MeetingAnalysis; brief?: ExecutiveBrief | null; report?: ValidatorReport | null; onOpen: () => void }) {
|
| 109 |
+
const counts = [
|
| 110 |
+
{ label: 'decisions', n: analysis.decisions.length },
|
| 111 |
+
{ label: 'risks', n: analysis.risks.length },
|
| 112 |
+
{ label: 'actions', n: analysis.action_items.length },
|
| 113 |
+
{ label: 'open questions', n: analysis.open_questions.length },
|
| 114 |
+
].filter((c) => c.n > 0);
|
| 115 |
+
const headline = brief?.title || analysis.title;
|
| 116 |
+
const line = brief?.intro || analysis.one_line;
|
| 117 |
+
return (
|
| 118 |
+
<div className="rounded-2xl border border-violet-400/25 bg-gradient-to-br from-violet-600/15 via-fuchsia-600/10 to-transparent p-4 sm:p-5" data-testid="insights-compact">
|
| 119 |
+
<div className="flex flex-wrap items-start gap-4">
|
| 120 |
+
<div className="min-w-0 flex-1">
|
| 121 |
+
<div className="text-[10px] font-bold uppercase tracking-[0.14em] text-violet-200/80">{brief?.eyebrow || 'Executive brief'}</div>
|
| 122 |
+
<h3 className="mt-1 text-base font-semibold leading-snug">{headline}</h3>
|
| 123 |
+
{line ? <p className="mt-1 text-sm leading-relaxed text-white/70">{line}</p> : null}
|
| 124 |
+
{counts.length ? (
|
| 125 |
+
<ul className="mt-3 flex flex-wrap gap-2 text-xs">
|
| 126 |
+
{counts.map((c) => (
|
| 127 |
+
<li key={c.label} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-white/80"><strong className="font-semibold text-white">{c.n}</strong> {c.label}</li>
|
| 128 |
+
))}
|
| 129 |
+
</ul>
|
| 130 |
+
) : null}
|
| 131 |
+
</div>
|
| 132 |
+
<button type="button" onClick={onOpen}
|
| 133 |
+
className="inline-flex h-10 items-center gap-2 self-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-violet-600 px-4 text-sm font-semibold shadow-lg shadow-fuchsia-900/30 hover:from-fuchsia-500 hover:to-violet-500">
|
| 134 |
+
Open visual summary <ArrowRight size={15} aria-hidden />
|
| 135 |
+
</button>
|
| 136 |
+
</div>
|
| 137 |
+
{brief?.takeaway ? <p className="mt-3 border-t border-white/10 pt-3 text-sm text-white/75"><span className="text-fuchsia-300" aria-hidden>✦ </span>{brief.takeaway}</p> : null}
|
| 138 |
+
<p className="mt-3 text-xs text-white/45"><ValidatorLine report={report} /></p>
|
| 139 |
+
</div>
|
| 140 |
+
);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function Progress({ stage, progress }: { stage: string; progress: number }) {
|
| 144 |
+
const pct = Math.max(4, Math.min(100, Math.round(progress * 100)));
|
| 145 |
+
return (
|
| 146 |
+
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-4" role="status" aria-live="polite" data-testid="insights-progress">
|
| 147 |
+
<div className="flex items-center gap-2 text-sm"><Loader2 size={15} className="animate-spin text-violet-300" /> Writing the insights — <span className="text-white/70">{stage}</span></div>
|
| 148 |
+
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-white/10"><div className="h-full rounded-full bg-gradient-to-r from-fuchsia-500 to-violet-500 transition-all" style={{ width: `${pct}%` }} /></div>
|
| 149 |
+
<p className="mt-2 text-xs text-white/45">Runs on this computer. You can keep reading the transcript meanwhile.</p>
|
| 150 |
+
</div>
|
| 151 |
+
);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
function ValidatorLine({ report }: { report?: ValidatorReport | null }) {
|
| 155 |
+
if (!report) return <span>Every item links to the transcript line it came from.</span>;
|
| 156 |
+
return (
|
| 157 |
+
<span title={report.notes?.join('\n') || undefined}>
|
| 158 |
+
{report.kept} item{report.kept === 1 ? '' : 's'} verified against the transcript
|
| 159 |
+
{report.dropped ? ` · ${report.dropped} dropped because the quoted words were not found` : ''}
|
| 160 |
+
{report.repaired ? ` · ${report.repaired} re-anchored to the right line` : ''}.
|
| 161 |
+
</span>
|
| 162 |
+
);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
export function SuggestedQuestions({ questions, onAsk }: { questions: SuggestedQuestion[]; onAsk?: (q: string) => void }) {
|
| 166 |
+
return (
|
| 167 |
+
<div data-testid="suggested-questions">
|
| 168 |
+
<h3 className="mb-2 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-white/70"><HelpCircle size={15} className="text-violet-300" /> Ask about this meeting</h3>
|
| 169 |
+
<div className="grid gap-2 sm:grid-cols-3">
|
| 170 |
+
{questions.slice(0, 3).map((q, i) => (
|
| 171 |
+
<button key={q.id} type="button" onClick={() => onAsk?.(q.text)} disabled={!onAsk}
|
| 172 |
+
className="group flex items-start gap-2 rounded-xl border border-white/10 bg-white/[0.03] p-3 text-left text-sm text-white/80 transition-colors hover:border-violet-400/40 hover:bg-violet-500/10 disabled:cursor-default">
|
| 173 |
+
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-full bg-white/10 text-[11px] font-semibold text-white/70 group-hover:bg-violet-500/30">{i + 1}</span>
|
| 174 |
+
<span className="min-w-0 flex-1 leading-snug">{q.text}</span>
|
| 175 |
+
<Bot size={14} className="mt-0.5 shrink-0 text-white/30 group-hover:text-violet-200" aria-hidden />
|
| 176 |
+
</button>
|
| 177 |
+
))}
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
export default InsightsPanel;
|
frontend/src/ui/meetingsense/library/MeetingDetailPage.tsx
ADDED
|
@@ -0,0 +1,929 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Meeting Detail — one page for every library meeting, recorded or imported, with or without
|
| 3 |
+
* a conversation behind it.
|
| 4 |
+
*
|
| 5 |
+
* The main column holds the task at hand (recording, overview, transcript, actions, AI chat);
|
| 6 |
+
* the right column holds supporting details and can be hidden ("Hide details") for a focus
|
| 7 |
+
* view, resized by dragging its edge, and opens as a drawer on small screens. The choice is
|
| 8 |
+
* remembered on this device and never changes the meeting.
|
| 9 |
+
*
|
| 10 |
+
* Nothing here invents content: no summary until one has been written, "Speaker unknown"
|
| 11 |
+
* when the transcript has no speakers, and the AI chat answers only from this meeting.
|
| 12 |
+
*/
|
| 13 |
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
| 14 |
+
import {
|
| 15 |
+
AlertTriangle, ArrowLeft, Bot, CalendarDays, CheckSquare, ChevronDown, Clock3, Download, ExternalLink, FileText,
|
| 16 |
+
Folder, Info, Lightbulb, ListChecks, Loader2, MoreHorizontal, PanelRightClose, PanelRightOpen, Pencil, RefreshCw,
|
| 17 |
+
Presentation, Send, Sparkles, Users, X,
|
| 18 |
+
} from 'lucide-react';
|
| 19 |
+
import {
|
| 20 |
+
LibraryError, libraryApi, mediaUrl, transcriptUrl, type LibraryDetail, type LibraryItem, type Segment,
|
| 21 |
+
} from './api';
|
| 22 |
+
import {
|
| 23 |
+
TONE_CLASSES, bytes, clock, formatDate, formatDateTime, formatDuration, isFallbackSummary, overviewSummary, plainText, statusChip,
|
| 24 |
+
} from './format';
|
| 25 |
+
import { SummaryMarkdown } from './SummaryMarkdown';
|
| 26 |
+
import { WaveformPlayer, type PlayerHandle } from './WaveformPlayer';
|
| 27 |
+
import { ProcessingPanel } from './ProcessingPanel';
|
| 28 |
+
import { InsightsPanel } from './InsightsPanel';
|
| 29 |
+
import { VisualSummaryTab } from './VisualSummaryTab';
|
| 30 |
+
import { AssistantDrawer, AssistantLauncher } from './AssistantDrawer';
|
| 31 |
+
|
| 32 |
+
const PANEL_OPEN_KEY = 'hp_meeting_details_open';
|
| 33 |
+
const PANEL_WIDTH_KEY = 'hp_meeting_details_width';
|
| 34 |
+
const PANEL_MIN = 280;
|
| 35 |
+
const PANEL_MAX = 520;
|
| 36 |
+
|
| 37 |
+
type Tab = 'overview' | 'visual' | 'transcript' | 'actions' | 'audio' | 'summary' | 'chat' | 'properties';
|
| 38 |
+
|
| 39 |
+
/** The line being spoken at `ms`: the last segment that starts at or before it. */
|
| 40 |
+
function indexAt(segments: Segment[], ms: number): number {
|
| 41 |
+
let hit = -1;
|
| 42 |
+
for (let i = 0; i < segments.length; i += 1) { if (segments[i].t0_ms <= ms) hit = i; else break; }
|
| 43 |
+
return hit;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function readPanelOpen(): boolean {
|
| 47 |
+
try {
|
| 48 |
+
const v = localStorage.getItem(PANEL_OPEN_KEY);
|
| 49 |
+
if (v === '1') return true;
|
| 50 |
+
if (v === '0') return false;
|
| 51 |
+
} catch { /* ignore */ }
|
| 52 |
+
return typeof window === 'undefined' || window.innerWidth >= 1280;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function readPanelWidth(): number {
|
| 56 |
+
try {
|
| 57 |
+
const v = Number(localStorage.getItem(PANEL_WIDTH_KEY));
|
| 58 |
+
if (v >= PANEL_MIN && v <= PANEL_MAX) return v;
|
| 59 |
+
} catch { /* ignore */ }
|
| 60 |
+
return 360;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function useNarrow(query = '(max-width: 1023.98px)'): boolean {
|
| 64 |
+
const get = () => typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(query).matches;
|
| 65 |
+
const [narrow, setNarrow] = useState(get);
|
| 66 |
+
useEffect(() => {
|
| 67 |
+
if (!window.matchMedia) return undefined;
|
| 68 |
+
const mq = window.matchMedia(query);
|
| 69 |
+
const on = () => setNarrow(mq.matches);
|
| 70 |
+
mq.addEventListener?.('change', on);
|
| 71 |
+
return () => mq.removeEventListener?.('change', on);
|
| 72 |
+
}, [query]);
|
| 73 |
+
return narrow;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function initials(name?: string | null): string {
|
| 77 |
+
const parts = (name || '').trim().split(/\s+/).filter(Boolean);
|
| 78 |
+
return parts.length ? (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase() : '?';
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function noteItems(list: any[] | undefined): Array<{ text: string; owner?: string; due?: string; t0?: number }> {
|
| 82 |
+
return (list || []).map((it: any) => typeof it === 'string'
|
| 83 |
+
? { text: it }
|
| 84 |
+
: { text: String(it?.text || it?.title || it?.item || ''), owner: it?.owner || it?.who || undefined,
|
| 85 |
+
due: it?.due || it?.when || undefined, t0: typeof it?.t0_ms === 'number' ? it.t0_ms : undefined })
|
| 86 |
+
.filter((it) => it.text.trim());
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
export function MeetingDetailPage({ libraryId, onBack, onOpenConversation, onOpenTeamsRoom, onOpenProject }: {
|
| 90 |
+
libraryId: string;
|
| 91 |
+
onBack: () => void;
|
| 92 |
+
onOpenConversation?: (conversationId: string) => void;
|
| 93 |
+
onOpenTeamsRoom?: (roomId: string) => void;
|
| 94 |
+
onOpenProject?: (projectId: string) => void;
|
| 95 |
+
}) {
|
| 96 |
+
const [detail, setDetail] = useState<LibraryDetail | null>(null);
|
| 97 |
+
const [error, setError] = useState<string | null>(null);
|
| 98 |
+
const [tab, setTab] = useState<Tab>('overview');
|
| 99 |
+
const [moreOpen, setMoreOpen] = useState(false);
|
| 100 |
+
const [menuOpen, setMenuOpen] = useState(false);
|
| 101 |
+
const [panelOpen, setPanelOpen] = useState(readPanelOpen);
|
| 102 |
+
const [panelWidth, setPanelWidth] = useState(readPanelWidth);
|
| 103 |
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
| 104 |
+
const [peaks, setPeaks] = useState<number[]>([]);
|
| 105 |
+
const [notice, setNotice] = useState<string | null>(null);
|
| 106 |
+
// The playhead (ms), fed by the one player above the tabs; the transcript highlights the
|
| 107 |
+
// line being spoken from it.
|
| 108 |
+
const [activeMs, setActiveMs] = useState<number | undefined>(undefined);
|
| 109 |
+
const [askOpen, setAskOpen] = useState(false);
|
| 110 |
+
const [askSeed, setAskSeed] = useState<string | null>(null);
|
| 111 |
+
// A transcript line to open from "view source" in the visual summary; the nonce lets the
|
| 112 |
+
// same stamp be clicked twice.
|
| 113 |
+
const [focus, setFocus] = useState<{ ms: number; n: number } | null>(null);
|
| 114 |
+
const narrow = useNarrow();
|
| 115 |
+
const player = useRef<PlayerHandle>(null);
|
| 116 |
+
const toggleRef = useRef<HTMLButtonElement>(null);
|
| 117 |
+
|
| 118 |
+
const load = useCallback(async () => {
|
| 119 |
+
try {
|
| 120 |
+
const body = await libraryApi.item(libraryId);
|
| 121 |
+
setDetail(body);
|
| 122 |
+
setError(null);
|
| 123 |
+
} catch (e) {
|
| 124 |
+
setError(e instanceof LibraryError ? (e.status === 404 ? 'This meeting no longer exists.' : e.message) : 'The meeting could not be loaded.');
|
| 125 |
+
}
|
| 126 |
+
}, [libraryId]);
|
| 127 |
+
|
| 128 |
+
useEffect(() => { void load(); }, [load]);
|
| 129 |
+
|
| 130 |
+
const item = detail?.item;
|
| 131 |
+
const meetingId = item?.meeting_id || null;
|
| 132 |
+
const audio = detail?.assets?.find((a) => a.kind === 'audio') || null;
|
| 133 |
+
|
| 134 |
+
useEffect(() => {
|
| 135 |
+
if (!meetingId || !audio) return;
|
| 136 |
+
let cancelled = false;
|
| 137 |
+
void libraryApi.peaks(meetingId, audio.id).then((b) => { if (!cancelled) setPeaks(b.peaks || []); }).catch(() => undefined);
|
| 138 |
+
return () => { cancelled = true; };
|
| 139 |
+
}, [meetingId, audio?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
| 140 |
+
|
| 141 |
+
const working = !!item && (['queued', 'transcribing'].includes(item.transcript_state) || item.summary_state === 'pending' || item.live);
|
| 142 |
+
useEffect(() => {
|
| 143 |
+
if (!working) return undefined;
|
| 144 |
+
const t = setInterval(() => void load(), 4000);
|
| 145 |
+
return () => clearInterval(t);
|
| 146 |
+
}, [working, load]);
|
| 147 |
+
|
| 148 |
+
useEffect(() => { try { localStorage.setItem(PANEL_OPEN_KEY, panelOpen ? '1' : '0'); } catch { /* ignore */ } }, [panelOpen]);
|
| 149 |
+
useEffect(() => { try { localStorage.setItem(PANEL_WIDTH_KEY, String(panelWidth)); } catch { /* ignore */ } }, [panelWidth]);
|
| 150 |
+
|
| 151 |
+
const startResize = (e: React.PointerEvent) => {
|
| 152 |
+
e.preventDefault();
|
| 153 |
+
const startX = e.clientX;
|
| 154 |
+
const startW = panelWidth;
|
| 155 |
+
const move = (ev: PointerEvent) => setPanelWidth(Math.min(PANEL_MAX, Math.max(PANEL_MIN, startW + (startX - ev.clientX))));
|
| 156 |
+
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); };
|
| 157 |
+
window.addEventListener('pointermove', move);
|
| 158 |
+
window.addEventListener('pointerup', up);
|
| 159 |
+
};
|
| 160 |
+
|
| 161 |
+
const notes = detail?.notes?.notes;
|
| 162 |
+
const decisions = noteItems(notes?.decisions);
|
| 163 |
+
const actions = noteItems(notes?.actions);
|
| 164 |
+
const overview = overviewSummary(detail?.summaries, notes?.summary, item?.description);
|
| 165 |
+
const overviewText = overview.markdown ? plainText(overview.text) : overview.text;
|
| 166 |
+
const segments = detail?.segments || [];
|
| 167 |
+
const speakers = useMemo(() => Array.from(new Set(segments.map((s) => s.speaker).filter(Boolean) as string[])), [segments]);
|
| 168 |
+
const timed = (detail?.versions || []).find((v) => v.active)?.timing !== 'none';
|
| 169 |
+
|
| 170 |
+
const seekTo = (ms: number) => player.current?.seek(ms);
|
| 171 |
+
// "View source" from the visual summary: open the transcript on that line and, when there
|
| 172 |
+
// is a recording, play from it.
|
| 173 |
+
const viewSource = (ms: number) => {
|
| 174 |
+
setTab('transcript');
|
| 175 |
+
setFocus((f) => ({ ms, n: (f?.n || 0) + 1 }));
|
| 176 |
+
if (audio) seekTo(ms);
|
| 177 |
+
};
|
| 178 |
+
|
| 179 |
+
const act = async (fn: () => Promise<unknown>, message: string) => {
|
| 180 |
+
try {
|
| 181 |
+
await fn();
|
| 182 |
+
setNotice(message);
|
| 183 |
+
await load();
|
| 184 |
+
} catch (e) {
|
| 185 |
+
setNotice(e instanceof LibraryError ? e.message : 'That did not work.');
|
| 186 |
+
}
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
if (error) {
|
| 190 |
+
return (
|
| 191 |
+
<div className="grid h-full place-items-center bg-black p-6 text-center text-white">
|
| 192 |
+
<div>
|
| 193 |
+
<p className="text-sm text-white/70">{error}</p>
|
| 194 |
+
<button type="button" onClick={onBack} className="mt-4 rounded-xl border border-white/15 px-4 py-2 text-sm hover:bg-white/10">Back to Meetings</button>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
);
|
| 198 |
+
}
|
| 199 |
+
if (!detail || !item) {
|
| 200 |
+
return <div className="grid h-full place-items-center bg-black text-white/60" aria-busy="true"><Loader2 className="animate-spin" /></div>;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
const chip = statusChip(item);
|
| 204 |
+
const statusPill = item.live ? { label: 'Recording', tone: 'red' as const }
|
| 205 |
+
: item.recording_state === 'awaiting' ? { label: item.scheduled_for && item.scheduled_for * 1000 > Date.now() ? 'Scheduled' : 'Awaiting recording', tone: 'slate' as const }
|
| 206 |
+
: ['queued', 'transcribing'].includes(item.transcript_state) ? { label: 'Processing', tone: 'sky' as const }
|
| 207 |
+
: { label: 'Completed', tone: 'green' as const };
|
| 208 |
+
const project = item.projects[0];
|
| 209 |
+
const teamsRoom = detail.teams_room;
|
| 210 |
+
const details = (
|
| 211 |
+
<DetailsPanel
|
| 212 |
+
item={item} detail={detail} overviewText={overviewText} actions={actions} speakers={speakers}
|
| 213 |
+
onViewActions={() => { setTab('actions'); setDrawerOpen(false); }}
|
| 214 |
+
onOpenProject={onOpenProject}
|
| 215 |
+
onTranscribe={meetingId && audio ? () => act(() => libraryApi.transcribe(meetingId), 'Queued for transcription on this computer.') : undefined}
|
| 216 |
+
onEditProjects={meetingId ? async (ids) => act(() => libraryApi.setProjects(meetingId, ids), 'Projects updated.') : undefined}
|
| 217 |
+
/>
|
| 218 |
+
);
|
| 219 |
+
|
| 220 |
+
const showPanelInline = panelOpen && !narrow;
|
| 221 |
+
|
| 222 |
+
return (
|
| 223 |
+
<div className="flex h-full flex-col bg-black text-white" data-testid="meeting-detail">
|
| 224 |
+
<div className="hp-clear-nav flex items-center gap-3 border-b border-white/[0.06] px-4 py-3 sm:px-6">
|
| 225 |
+
<button type="button" onClick={onBack} className="inline-flex h-10 items-center gap-2 rounded-xl border border-white/10 bg-white/[0.03] px-3 text-sm hover:bg-white/[0.08]">
|
| 226 |
+
<ArrowLeft size={16} /> Back
|
| 227 |
+
</button>
|
| 228 |
+
<nav aria-label="Breadcrumb" className="hidden min-w-0 items-center gap-2 text-sm text-white/55 sm:flex">
|
| 229 |
+
<button type="button" onClick={onBack} className="hover:text-white">Meetings</button>
|
| 230 |
+
<span aria-hidden>›</span>
|
| 231 |
+
<span className="truncate text-white/90">{item.title}</span>
|
| 232 |
+
</nav>
|
| 233 |
+
<div className="relative ml-auto flex items-center gap-2">
|
| 234 |
+
{meetingId && segments.length ? (
|
| 235 |
+
<a href={transcriptUrl(meetingId, 'md')} className="hidden h-10 items-center gap-2 rounded-xl bg-violet-600 px-4 text-sm font-semibold hover:bg-violet-500 sm:inline-flex">
|
| 236 |
+
<Download size={15} /> Export
|
| 237 |
+
</a>
|
| 238 |
+
) : null}
|
| 239 |
+
{meetingId ? (
|
| 240 |
+
<button type="button" onClick={() => setMenuOpen((m) => !m)} aria-label="More actions" aria-expanded={menuOpen}
|
| 241 |
+
className="grid h-10 w-10 place-items-center rounded-full border border-white/10 hover:bg-white/10"><MoreHorizontal size={18} /></button>
|
| 242 |
+
) : null}
|
| 243 |
+
{menuOpen && meetingId ? (
|
| 244 |
+
<div className="absolute right-0 top-12 z-30 w-56 overflow-hidden rounded-xl border border-white/10 bg-[#121218] py-1 text-sm shadow-2xl" role="menu">
|
| 245 |
+
<MenuItem onClick={() => { setMenuOpen(false); const t = window.prompt('Rename meeting', item.title); if (t && t.trim()) void act(() => libraryApi.update(meetingId, { title: t.trim() }), 'Renamed.'); }}>Rename</MenuItem>
|
| 246 |
+
{audio ? <MenuItem onClick={() => { setMenuOpen(false); void act(() => libraryApi.transcribe(meetingId), 'Queued for transcription on this computer.'); }}>{segments.length ? 'Transcribe again' : 'Transcribe'}</MenuItem> : null}
|
| 247 |
+
{segments.length ? <MenuItem onClick={() => { setMenuOpen(false); void act(() => libraryApi.summarize(meetingId), 'Writing a summary…'); }}>Write summary again</MenuItem> : null}
|
| 248 |
+
{(['txt', 'srt', 'vtt', 'json', 'md'] as const).map((fmt) => segments.length ? (
|
| 249 |
+
<a key={fmt} href={transcriptUrl(meetingId, fmt)} role="menuitem" className="block px-3 py-2 text-white/85 hover:bg-white/[0.06]">Export transcript (.{fmt})</a>
|
| 250 |
+
) : null)}
|
| 251 |
+
{audio ? <a href={mediaUrl(meetingId, audio.id, true)} role="menuitem" className="block px-3 py-2 text-white/85 hover:bg-white/[0.06]">Download recording</a> : null}
|
| 252 |
+
<MenuItem onClick={() => { setMenuOpen(false); void act(() => libraryApi.remove(meetingId, 'remove'), 'Removed from the library.').then(onBack); }}>Remove from library</MenuItem>
|
| 253 |
+
<MenuItem danger onClick={() => {
|
| 254 |
+
setMenuOpen(false);
|
| 255 |
+
if (window.confirm(`Delete "${item.title}" and the copies HomePilot keeps? Originals in a synced folder are not touched.`)) {
|
| 256 |
+
void act(() => libraryApi.remove(meetingId, 'delete'), 'Deleted.').then(onBack);
|
| 257 |
+
}
|
| 258 |
+
}}>Delete meeting data…</MenuItem>
|
| 259 |
+
</div>
|
| 260 |
+
) : null}
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
|
| 264 |
+
<div className="flex min-h-0 flex-1">
|
| 265 |
+
<main className="min-w-0 flex-1 overflow-y-auto px-4 pb-10 pt-5 sm:px-6">
|
| 266 |
+
<div className="flex flex-wrap items-start gap-3">
|
| 267 |
+
<h1 className="min-w-0 text-2xl font-semibold sm:text-3xl">{item.title}</h1>
|
| 268 |
+
<span className={`mt-1 inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm ${TONE_CLASSES[statusPill.tone]}`}>
|
| 269 |
+
<span className="h-2 w-2 rounded-full bg-current" aria-hidden /> {statusPill.label}
|
| 270 |
+
</span>
|
| 271 |
+
<button
|
| 272 |
+
ref={toggleRef}
|
| 273 |
+
type="button"
|
| 274 |
+
onClick={() => (narrow ? setDrawerOpen(true) : setPanelOpen((o) => !o))}
|
| 275 |
+
aria-expanded={narrow ? drawerOpen : panelOpen}
|
| 276 |
+
aria-controls="meeting-details-panel"
|
| 277 |
+
className="ml-auto inline-flex h-10 items-center gap-2 rounded-xl border border-white/10 bg-white/[0.03] px-3 text-sm text-white/80 hover:bg-white/[0.08]"
|
| 278 |
+
data-testid="details-toggle"
|
| 279 |
+
>
|
| 280 |
+
{(narrow ? drawerOpen : panelOpen) ? <PanelRightClose size={16} /> : <PanelRightOpen size={16} />}
|
| 281 |
+
{(narrow ? drawerOpen : panelOpen) ? 'Hide details' : 'Show details'}
|
| 282 |
+
</button>
|
| 283 |
+
</div>
|
| 284 |
+
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm text-white/60">
|
| 285 |
+
<span className="inline-flex items-center gap-1.5"><CalendarDays size={15} /> {formatDate(item.started_at || item.scheduled_for)}</span>
|
| 286 |
+
<span className="inline-flex items-center gap-1.5"><Clock3 size={15} /> {formatDuration(item.duration_ms)}</span>
|
| 287 |
+
{project ? (
|
| 288 |
+
<button type="button" onClick={() => onOpenProject?.(project.id)} className="inline-flex items-center gap-1.5 hover:text-white"><Folder size={15} /> {project.name}</button>
|
| 289 |
+
) : null}
|
| 290 |
+
<span className="inline-flex items-center gap-1.5"><Info size={15} /> {item.source.label}</span>
|
| 291 |
+
{chip.label.toLowerCase() !== item.source.label.toLowerCase() ? (
|
| 292 |
+
<span className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs ${TONE_CLASSES[chip.tone]}`}>{chip.label}</span>
|
| 293 |
+
) : null}
|
| 294 |
+
</div>
|
| 295 |
+
|
| 296 |
+
{notice ? (
|
| 297 |
+
<div className="mt-4 flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm" role="status">
|
| 298 |
+
<span className="flex-1">{notice}</span>
|
| 299 |
+
<button type="button" aria-label="Dismiss" onClick={() => setNotice(null)} className="text-white/50 hover:text-white"><X size={14} /></button>
|
| 300 |
+
</div>
|
| 301 |
+
) : null}
|
| 302 |
+
{item.live && item.conversation_id ? (
|
| 303 |
+
<div className="mt-4 flex flex-wrap items-center gap-3 rounded-xl border border-rose-400/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-100">
|
| 304 |
+
<span className="h-2 w-2 animate-pulse rounded-full bg-rose-400" aria-hidden /> This meeting is recording now.
|
| 305 |
+
<button type="button" onClick={() => onOpenConversation?.(item.conversation_id!)} className="ml-auto rounded-lg border border-rose-300/30 px-3 py-1 hover:bg-rose-500/20">Open live meeting</button>
|
| 306 |
+
</div>
|
| 307 |
+
) : null}
|
| 308 |
+
{item.warnings.length ? (
|
| 309 |
+
<ul className="mt-4 space-y-1 rounded-xl border border-amber-400/25 bg-amber-500/[0.07] px-3 py-2">
|
| 310 |
+
{item.warnings.map((w) => <li key={w} className="flex gap-2 text-xs text-amber-100/90"><AlertTriangle size={13} className="mt-0.5 shrink-0" /> {w}</li>)}
|
| 311 |
+
</ul>
|
| 312 |
+
) : null}
|
| 313 |
+
|
| 314 |
+
{audio && meetingId && !teamsRoom ? (
|
| 315 |
+
<div className="mt-5">
|
| 316 |
+
{/* One player for the whole page: playback continues across tabs, and every
|
| 317 |
+
timestamp — transcript, insights, assistant — seeks the same recording. */}
|
| 318 |
+
<WaveformPlayer ref={player} src={mediaUrl(meetingId, audio.id)} peaks={peaks}
|
| 319 |
+
durationMs={audio.media_duration_ms} downloadUrl={mediaUrl(meetingId, audio.id, true)} onTime={setActiveMs} />
|
| 320 |
+
</div>
|
| 321 |
+
) : null}
|
| 322 |
+
|
| 323 |
+
{teamsRoom ? (
|
| 324 |
+
<TeamsRoomView room={teamsRoom} onOpen={() => onOpenTeamsRoom?.(teamsRoom.id)} />
|
| 325 |
+
) : (
|
| 326 |
+
<>
|
| 327 |
+
<div className="mt-5 flex items-center gap-0.5 rounded-2xl border border-white/10 bg-white/[0.02] p-1 sm:gap-1" role="tablist" aria-label="Meeting sections">
|
| 328 |
+
<TabButton id="overview" tab={tab} setTab={setTab} icon={<Sparkles size={16} />}>Overview</TabButton>
|
| 329 |
+
{/* On phones the visual summary keeps its icon only, so the five tabs still share one line. */}
|
| 330 |
+
<TabButton id="visual" tab={tab} setTab={setTab} icon={<Presentation size={16} />} label="Visual Summary">
|
| 331 |
+
<span className="sm:hidden" aria-hidden><Presentation size={16} /></span><span className="hidden sm:inline">Visual Summary</span>
|
| 332 |
+
</TabButton>
|
| 333 |
+
<TabButton id="transcript" tab={tab} setTab={setTab} icon={<FileText size={16} />}>Transcript</TabButton>
|
| 334 |
+
<TabButton id="actions" tab={tab} setTab={setTab} icon={<CheckSquare size={16} />}>
|
| 335 |
+
Actions {actions.length ? <span className="ml-1 rounded-full bg-white/10 px-1.5 text-xs">{actions.length}</span> : null}
|
| 336 |
+
</TabButton>
|
| 337 |
+
<div className="relative ml-auto">
|
| 338 |
+
<button type="button" onClick={() => setMoreOpen((o) => !o)} aria-expanded={moreOpen}
|
| 339 |
+
className={`inline-flex h-10 shrink-0 items-center gap-1.5 rounded-xl px-2 text-[13px] sm:px-3 sm:text-sm ${['audio', 'summary', 'chat', 'properties'].includes(tab) ? 'bg-violet-600/30 text-white' : 'text-white/70 hover:bg-white/[0.06]'}`}>
|
| 340 |
+
<MoreHorizontal size={16} className="hidden sm:block" aria-hidden /> More <ChevronDown size={14} aria-hidden />
|
| 341 |
+
</button>
|
| 342 |
+
{moreOpen ? (
|
| 343 |
+
<div className="absolute right-0 z-20 mt-1 w-44 overflow-hidden rounded-xl border border-white/10 bg-[#121218] py-1 text-sm shadow-2xl" role="menu">
|
| 344 |
+
{([['audio', 'Audio'], ['summary', 'Summary'], ['chat', 'AI Chat'], ['properties', 'Properties']] as const).map(([id, label]) => (
|
| 345 |
+
<button key={id} type="button" role="menuitem" onClick={() => { setTab(id); setMoreOpen(false); }}
|
| 346 |
+
className="block w-full px-3 py-2 text-left text-white/85 hover:bg-white/[0.06]">{label}</button>
|
| 347 |
+
))}
|
| 348 |
+
</div>
|
| 349 |
+
) : null}
|
| 350 |
+
</div>
|
| 351 |
+
</div>
|
| 352 |
+
|
| 353 |
+
<div className="mt-4 space-y-4" role="tabpanel">
|
| 354 |
+
{tab === 'overview' ? (
|
| 355 |
+
<>
|
| 356 |
+
{meetingId ? (
|
| 357 |
+
<InsightsPanel meetingId={meetingId} hasTranscript={!!segments.length} title={item.title}
|
| 358 |
+
onSeek={audio ? seekTo : undefined}
|
| 359 |
+
onAsk={(q) => { setAskSeed(q); setAskOpen(true); }}
|
| 360 |
+
onOpenVisual={() => setTab('visual')}
|
| 361 |
+
onChanged={() => void load()} />
|
| 362 |
+
) : null}
|
| 363 |
+
<Card icon={<ListChecks size={18} />} title="Meeting overview">
|
| 364 |
+
{overview.text ? (
|
| 365 |
+
overview.markdown ? <SummaryMarkdown text={overview.text} />
|
| 366 |
+
: <p className="whitespace-pre-line text-[15px] leading-relaxed text-white/80">{overview.text}</p>
|
| 367 |
+
) : <EmptyLine item={item} hasTranscript={!!segments.length}
|
| 368 |
+
onSummarize={meetingId ? () => act(() => libraryApi.summarize(meetingId), 'Writing a summary…') : undefined} />}
|
| 369 |
+
</Card>
|
| 370 |
+
{decisions.length ? (
|
| 371 |
+
<Card icon={<Lightbulb size={18} />} title="Key decisions">
|
| 372 |
+
<ol className="space-y-2">
|
| 373 |
+
{decisions.map((d, i) => (
|
| 374 |
+
<li key={i} className="flex gap-3 text-[15px] text-white/85">
|
| 375 |
+
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-full bg-violet-600/80 text-xs font-semibold">{i + 1}</span>{d.text}
|
| 376 |
+
</li>
|
| 377 |
+
))}
|
| 378 |
+
</ol>
|
| 379 |
+
</Card>
|
| 380 |
+
) : null}
|
| 381 |
+
<Card icon={<FileText size={18} />} title="Transcript preview"
|
| 382 |
+
action={segments.length ? <button type="button" onClick={() => setTab('transcript')} className="text-sm text-violet-300 hover:text-violet-200">View full transcript →</button> : null}>
|
| 383 |
+
{segments.length ? (
|
| 384 |
+
<ul className="space-y-3">
|
| 385 |
+
{segments.slice(0, 3).map((s, i) => <TranscriptLine key={s.id || i} seg={s} timed={timed} speakers={speakers.length > 0} onSeek={audio ? (ms) => player.current?.seek(ms) : undefined} />)}
|
| 386 |
+
</ul>
|
| 387 |
+
) : <TranscriptEmpty item={item} />}
|
| 388 |
+
</Card>
|
| 389 |
+
</>
|
| 390 |
+
) : null}
|
| 391 |
+
{tab === 'visual' ? (
|
| 392 |
+
meetingId ? (
|
| 393 |
+
<VisualSummaryTab meetingId={meetingId} item={item} hasTranscript={!!segments.length}
|
| 394 |
+
onEvidence={(ms) => viewSource(ms)}
|
| 395 |
+
onAsk={(q) => { setAskSeed(q); setAskOpen(true); }}
|
| 396 |
+
onChanged={() => void load()} />
|
| 397 |
+
) : <Card icon={<Presentation size={18} />} title="Visual summary"><p className="text-sm text-white/55">A visual summary is written once this meeting has a transcript.</p></Card>
|
| 398 |
+
) : null}
|
| 399 |
+
{tab === 'transcript' ? (
|
| 400 |
+
<TranscriptTab meetingId={meetingId} detail={detail} timed={timed} activeMs={activeMs} focus={focus}
|
| 401 |
+
onSeek={audio ? seekTo : undefined}
|
| 402 |
+
onSaved={() => { setNotice('Saved as a new transcript version.'); void load(); }} />
|
| 403 |
+
) : null}
|
| 404 |
+
{tab === 'actions' ? <ActionsTab meetingId={meetingId || libraryId} actions={actions} item={item} /> : null}
|
| 405 |
+
{tab === 'audio' ? (
|
| 406 |
+
audio && meetingId ? (
|
| 407 |
+
<>
|
| 408 |
+
<Card title="Audio" icon={<FileText size={18} />}>
|
| 409 |
+
<p className="text-sm text-white/60">The player stays at the top of the page, so playback continues while you read any tab.</p>
|
| 410 |
+
</Card>
|
| 411 |
+
<FilesList detail={detail} meetingId={meetingId} />
|
| 412 |
+
</>
|
| 413 |
+
) : <Card title="Audio" icon={<FileText size={18} />}><p className="text-sm text-white/60">This meeting has no recording.</p></Card>
|
| 414 |
+
) : null}
|
| 415 |
+
{tab === 'summary' ? (
|
| 416 |
+
<SummaryTab detail={detail} onWrite={meetingId && segments.length ? (style, length) => act(() => libraryApi.summarize(meetingId, { outputs: { style, length } }), 'Writing a summary…') : undefined} />
|
| 417 |
+
) : null}
|
| 418 |
+
{tab === 'chat' ? <ChatTab meetingId={meetingId} hasTranscript={!!segments.length} /> : null}
|
| 419 |
+
{tab === 'properties' ? <PropertiesTab detail={detail} meetingId={meetingId} /> : null}
|
| 420 |
+
{meetingId && (detail.jobs || []).some((j) => ['queued', 'running', 'needs_setup', 'failed'].includes(j.state)) ? (
|
| 421 |
+
<ProcessingPanel meetingId={meetingId} onChanged={() => void load()} />
|
| 422 |
+
) : null}
|
| 423 |
+
</div>
|
| 424 |
+
</>
|
| 425 |
+
)}
|
| 426 |
+
</main>
|
| 427 |
+
|
| 428 |
+
{showPanelInline ? (
|
| 429 |
+
<div className="relative hidden shrink-0 border-l border-white/[0.06] lg:block" style={{ width: panelWidth }}>
|
| 430 |
+
<div
|
| 431 |
+
role="separator"
|
| 432 |
+
aria-orientation="vertical"
|
| 433 |
+
aria-label="Resize details"
|
| 434 |
+
aria-valuemin={PANEL_MIN}
|
| 435 |
+
aria-valuemax={PANEL_MAX}
|
| 436 |
+
aria-valuenow={panelWidth}
|
| 437 |
+
tabIndex={0}
|
| 438 |
+
onPointerDown={startResize}
|
| 439 |
+
onKeyDown={(e) => {
|
| 440 |
+
if (e.key === 'ArrowLeft') setPanelWidth((w) => Math.min(PANEL_MAX, w + 20));
|
| 441 |
+
if (e.key === 'ArrowRight') setPanelWidth((w) => Math.max(PANEL_MIN, w - 20));
|
| 442 |
+
}}
|
| 443 |
+
className="absolute -left-1 top-0 z-10 h-full w-2 cursor-col-resize hover:bg-violet-500/30 focus-visible:bg-violet-500/40 focus-visible:outline-none"
|
| 444 |
+
/>
|
| 445 |
+
<aside id="meeting-details-panel" aria-label="Meeting details" className="h-full overflow-y-auto px-4 py-5">{details}</aside>
|
| 446 |
+
</div>
|
| 447 |
+
) : null}
|
| 448 |
+
</div>
|
| 449 |
+
|
| 450 |
+
{narrow && drawerOpen ? (
|
| 451 |
+
<div className="fixed inset-0 z-[70] flex justify-end bg-black/60" onClick={() => setDrawerOpen(false)}>
|
| 452 |
+
<aside id="meeting-details-panel" aria-label="Meeting details" role="dialog" aria-modal="true"
|
| 453 |
+
className="h-full w-[min(92vw,380px)] overflow-y-auto border-l border-white/10 bg-[#0b0b10] px-4 py-4"
|
| 454 |
+
onClick={(e) => e.stopPropagation()}
|
| 455 |
+
onKeyDown={(e) => { if (e.key === 'Escape') { setDrawerOpen(false); toggleRef.current?.focus(); } }}>
|
| 456 |
+
<div className="mb-3 flex items-center">
|
| 457 |
+
<h2 className="font-semibold">Details</h2>
|
| 458 |
+
<button type="button" autoFocus onClick={() => { setDrawerOpen(false); toggleRef.current?.focus(); }} aria-label="Close details"
|
| 459 |
+
className="ml-auto grid h-9 w-9 place-items-center rounded-full hover:bg-white/10"><X size={16} /></button>
|
| 460 |
+
</div>
|
| 461 |
+
{details}
|
| 462 |
+
</aside>
|
| 463 |
+
</div>
|
| 464 |
+
) : null}
|
| 465 |
+
|
| 466 |
+
{meetingId && !teamsRoom ? (
|
| 467 |
+
<>
|
| 468 |
+
<AssistantLauncher hidden={askOpen || !segments.length} onOpen={() => setAskOpen(true)} />
|
| 469 |
+
<AssistantDrawer meetingId={meetingId} open={askOpen} onClose={() => setAskOpen(false)} seed={askSeed}
|
| 470 |
+
onSeeded={() => setAskSeed(null)} onSeek={audio ? seekTo : undefined} hasTranscript={!!segments.length} title={item.title} />
|
| 471 |
+
</>
|
| 472 |
+
) : null}
|
| 473 |
+
</div>
|
| 474 |
+
);
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
function MenuItem({ children, onClick, danger }: { children: React.ReactNode; onClick: () => void; danger?: boolean }) {
|
| 478 |
+
return <button type="button" role="menuitem" onClick={onClick} className={`block w-full px-3 py-2 text-left hover:bg-white/[0.06] ${danger ? 'text-rose-300' : 'text-white/85'}`}>{children}</button>;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
function TabButton({ id, tab, setTab, icon, label, children }: {
|
| 482 |
+
id: Tab; tab: Tab; setTab: (t: Tab) => void; icon: React.ReactNode; label?: string; children: React.ReactNode;
|
| 483 |
+
}) {
|
| 484 |
+
const active = tab === id;
|
| 485 |
+
return (
|
| 486 |
+
<button type="button" role="tab" aria-selected={active} aria-label={label} onClick={() => setTab(id)}
|
| 487 |
+
className={`inline-flex h-10 shrink-0 items-center gap-2 rounded-xl px-2 text-[13px] sm:px-4 sm:text-sm ${active ? 'bg-violet-600/30 font-semibold text-white shadow-[inset_0_-2px_0_rgba(167,139,250,0.9)]' : 'text-white/70 hover:bg-white/[0.06]'}`}>
|
| 488 |
+
{/* Icons step aside on phones so all four tabs, More included, fit on one line. */}
|
| 489 |
+
<span className="hidden sm:inline-flex" aria-hidden>{icon}</span>{children}
|
| 490 |
+
</button>
|
| 491 |
+
);
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
function Card({ icon, title, action, children }: { icon: React.ReactNode; title: string; action?: React.ReactNode; children: React.ReactNode }) {
|
| 495 |
+
return (
|
| 496 |
+
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4 sm:p-5">
|
| 497 |
+
<div className="mb-3 flex items-center gap-2">
|
| 498 |
+
<span className="text-violet-300">{icon}</span>
|
| 499 |
+
<h3 className="flex-1 font-semibold">{title}</h3>
|
| 500 |
+
{action}
|
| 501 |
+
</div>
|
| 502 |
+
{children}
|
| 503 |
+
</section>
|
| 504 |
+
);
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
function EmptyLine({ item, hasTranscript, onSummarize }: { item: LibraryItem; hasTranscript: boolean; onSummarize?: () => void }) {
|
| 508 |
+
if (item.summary_state === 'pending') return <p className="flex items-center gap-2 text-sm text-white/60"><Loader2 size={14} className="animate-spin" /> Writing the summary…</p>;
|
| 509 |
+
if (!hasTranscript) return <p className="text-sm text-white/55">A summary is written once there is a transcript.</p>;
|
| 510 |
+
return (
|
| 511 |
+
<div className="text-sm text-white/60">
|
| 512 |
+
{item.summary_state === 'unavailable' ? 'No language model answered, so no summary was written. ' : 'No summary yet. '}
|
| 513 |
+
{onSummarize ? <button type="button" onClick={onSummarize} className="text-violet-300 hover:text-violet-200">Write one now</button> : null}
|
| 514 |
+
</div>
|
| 515 |
+
);
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
function TranscriptEmpty({ item }: { item: LibraryItem }) {
|
| 519 |
+
const text: Record<string, string> = {
|
| 520 |
+
queued: 'Waiting to be transcribed on this computer.',
|
| 521 |
+
transcribing: 'Transcribing on this computer…',
|
| 522 |
+
needs_setup: 'No local speech-to-text model is installed. The recording is kept; install one and choose Transcribe.',
|
| 523 |
+
no_speech: 'No speech was detected in the recording.',
|
| 524 |
+
failed: 'Transcription failed. Try again from the menu.',
|
| 525 |
+
live: 'The transcript appears here while the meeting is recorded.',
|
| 526 |
+
};
|
| 527 |
+
return <p className="text-sm text-white/55">{text[item.transcript_state] || (item.recording_state === 'awaiting' ? 'The recording has not arrived yet.' : 'There is no transcript.')}</p>;
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
function TranscriptLine({ seg, timed, onSeek, speakers = true, active = false, flash = 0 }: {
|
| 531 |
+
seg: Segment; timed: boolean; onSeek?: (ms: number) => void; speakers?: boolean; active?: boolean; flash?: number;
|
| 532 |
+
}) {
|
| 533 |
+
// A transcript with no speakers at all (most recordings transcribed here) drops the speaker
|
| 534 |
+
// columns rather than repeating "Speaker unknown" on every line.
|
| 535 |
+
const who = seg.speaker || 'Speaker unknown';
|
| 536 |
+
const row = useRef<HTMLLIElement>(null);
|
| 537 |
+
// The line being spoken scrolls into view as the recording plays — nearest edge only, so a
|
| 538 |
+
// reader who scrolled elsewhere is nudged, never yanked.
|
| 539 |
+
useEffect(() => { if (active) row.current?.scrollIntoView?.({ block: 'nearest' }); }, [active]);
|
| 540 |
+
// A line opened from the visual summary ("view source") is centred and glows for a moment.
|
| 541 |
+
const [glow, setGlow] = useState(false);
|
| 542 |
+
useEffect(() => {
|
| 543 |
+
if (!flash) return undefined;
|
| 544 |
+
row.current?.scrollIntoView?.({ block: 'center' });
|
| 545 |
+
setGlow(true);
|
| 546 |
+
const t = setTimeout(() => setGlow(false), 2500);
|
| 547 |
+
return () => clearTimeout(t);
|
| 548 |
+
}, [flash]);
|
| 549 |
+
const stamp = timed && onSeek ? (
|
| 550 |
+
<button type="button" onClick={() => onSeek(seg.t0_ms)} className="hidden self-start pt-2 text-left text-sm tabular-nums text-violet-300 hover:text-violet-200 sm:block" aria-label={`Play from ${clock(seg.t0_ms)}`}>{clock(seg.t0_ms)}</button>
|
| 551 |
+
) : <span className="hidden pt-2 text-sm tabular-nums text-white/40 sm:block">{timed ? clock(seg.t0_ms) : ''}</span>;
|
| 552 |
+
const phoneLabel = [speakers ? who : '', timed ? clock(seg.t0_ms) : ''].filter(Boolean).join(' · ');
|
| 553 |
+
return (
|
| 554 |
+
<li ref={row} aria-current={active ? 'true' : undefined} data-focused={glow || undefined}
|
| 555 |
+
className={`grid gap-3 rounded-lg transition-colors ${glow ? 'bg-fuchsia-500/[0.14] ring-2 ring-fuchsia-400/60' : active ? 'bg-violet-500/[0.12] ring-1 ring-violet-400/40' : ''} ${speakers ? 'grid-cols-[2.25rem_minmax(0,1fr)] sm:grid-cols-[2.25rem_7.5rem_4.5rem_minmax(0,1fr)]' : 'grid-cols-1 sm:grid-cols-[4.5rem_minmax(0,1fr)]'}`}>
|
| 556 |
+
{speakers ? (
|
| 557 |
+
<>
|
| 558 |
+
<span className="grid h-9 w-9 place-items-center rounded-full bg-violet-500/20 text-xs font-semibold text-violet-100" aria-hidden>{initials(seg.speaker)}</span>
|
| 559 |
+
<span className="hidden truncate pt-2 text-sm font-medium sm:block" title={who}>{who}</span>
|
| 560 |
+
</>
|
| 561 |
+
) : null}
|
| 562 |
+
{stamp}
|
| 563 |
+
<p className="pt-1.5 text-[15px] leading-relaxed text-white/85">
|
| 564 |
+
{phoneLabel ? (
|
| 565 |
+
timed && onSeek
|
| 566 |
+
? <button type="button" onClick={() => onSeek(seg.t0_ms)} className="mr-2 text-xs text-violet-300 sm:hidden" aria-label={`Play from ${clock(seg.t0_ms)}`}>{phoneLabel}</button>
|
| 567 |
+
: <span className="mr-2 text-xs text-white/40 sm:hidden">{phoneLabel}</span>
|
| 568 |
+
) : null}
|
| 569 |
+
{seg.text}
|
| 570 |
+
</p>
|
| 571 |
+
</li>
|
| 572 |
+
);
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
function TranscriptTab({ meetingId, detail, timed, onSeek, onSaved, activeMs, focus }: {
|
| 576 |
+
meetingId: string | null; detail: LibraryDetail; timed: boolean; onSeek?: (ms: number) => void; onSaved: () => void; activeMs?: number;
|
| 577 |
+
focus?: { ms: number; n: number } | null;
|
| 578 |
+
}) {
|
| 579 |
+
const segments = detail.segments || [];
|
| 580 |
+
// The line being spoken: the last segment that starts at or before the playhead.
|
| 581 |
+
const activeIndex = useMemo(() => (activeMs === undefined || !timed ? -1 : indexAt(segments, activeMs)), [segments, activeMs, timed]);
|
| 582 |
+
// The line a "view source" click asked for.
|
| 583 |
+
const focusIndex = focus && timed ? indexAt(segments, focus.ms) : -1;
|
| 584 |
+
const [q, setQ] = useState('');
|
| 585 |
+
const [editing, setEditing] = useState(false);
|
| 586 |
+
const [draft, setDraft] = useState<Segment[]>([]);
|
| 587 |
+
const [saving, setSaving] = useState(false);
|
| 588 |
+
const shown = q ? segments.filter((s) => `${s.speaker || ''} ${s.text}`.toLowerCase().includes(q.toLowerCase())) : segments;
|
| 589 |
+
const versions = detail.versions || [];
|
| 590 |
+
const hasSpeakers = segments.some((s) => s.speaker);
|
| 591 |
+
|
| 592 |
+
if (!segments.length) return <Card icon={<FileText size={18} />} title="Transcript"><TranscriptEmpty item={detail.item} /></Card>;
|
| 593 |
+
return (
|
| 594 |
+
<Card icon={<FileText size={18} />} title="Transcript" action={meetingId ? (
|
| 595 |
+
editing ? (
|
| 596 |
+
<div className="flex gap-2">
|
| 597 |
+
<button type="button" onClick={() => setEditing(false)} className="rounded-lg border border-white/15 px-3 py-1 text-sm hover:bg-white/10">Cancel</button>
|
| 598 |
+
<button type="button" disabled={saving} onClick={async () => {
|
| 599 |
+
setSaving(true);
|
| 600 |
+
try { await libraryApi.saveVersion(meetingId, draft); setEditing(false); onSaved(); } finally { setSaving(false); }
|
| 601 |
+
}} className="rounded-lg bg-violet-600 px-3 py-1 text-sm font-semibold hover:bg-violet-500 disabled:opacity-50">{saving ? 'Saving…' : 'Save version'}</button>
|
| 602 |
+
</div>
|
| 603 |
+
) : (
|
| 604 |
+
<button type="button" onClick={() => { setDraft(segments.map((s) => ({ ...s }))); setEditing(true); }}
|
| 605 |
+
className="inline-flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1 text-sm text-white/80 hover:bg-white/10"><Pencil size={13} /> Correct</button>
|
| 606 |
+
)
|
| 607 |
+
) : null}>
|
| 608 |
+
{!editing ? (
|
| 609 |
+
<input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search the transcript…" aria-label="Search the transcript"
|
| 610 |
+
className="mb-4 h-10 w-full rounded-xl border border-white/10 bg-white/[0.03] px-3 text-sm focus:border-violet-400/60 focus:outline-none" />
|
| 611 |
+
) : null}
|
| 612 |
+
{editing ? (
|
| 613 |
+
<ul className="space-y-2">
|
| 614 |
+
{draft.map((s, i) => (
|
| 615 |
+
<li key={i} className="grid grid-cols-[6rem_minmax(0,1fr)] gap-2">
|
| 616 |
+
<input value={s.speaker || ''} placeholder="Speaker" aria-label={`Speaker of line ${i + 1}`}
|
| 617 |
+
onChange={(e) => setDraft((d) => d.map((x, j) => j === i ? { ...x, speaker: e.target.value || null } : x))}
|
| 618 |
+
className="h-9 rounded-lg border border-white/10 bg-black px-2 text-sm" />
|
| 619 |
+
<textarea value={s.text} rows={2} aria-label={`Line ${i + 1}`}
|
| 620 |
+
onChange={(e) => setDraft((d) => d.map((x, j) => j === i ? { ...x, text: e.target.value } : x))}
|
| 621 |
+
className="rounded-lg border border-white/10 bg-black px-2 py-1.5 text-sm" />
|
| 622 |
+
</li>
|
| 623 |
+
))}
|
| 624 |
+
</ul>
|
| 625 |
+
) : (
|
| 626 |
+
<ul className="space-y-3">{shown.map((s, i) => (
|
| 627 |
+
<TranscriptLine key={s.id || i} seg={s} timed={timed} speakers={hasSpeakers} onSeek={onSeek}
|
| 628 |
+
active={activeIndex >= 0 && segments[activeIndex] === s}
|
| 629 |
+
flash={focusIndex >= 0 && segments[focusIndex] === s ? focus!.n : 0} />
|
| 630 |
+
))}</ul>
|
| 631 |
+
)}
|
| 632 |
+
{versions.length > 1 && meetingId ? (
|
| 633 |
+
<details className="mt-5 rounded-xl border border-white/10 p-3 text-sm">
|
| 634 |
+
<summary className="cursor-pointer text-white/70">Versions ({versions.length})</summary>
|
| 635 |
+
<ul className="mt-2 space-y-1.5">
|
| 636 |
+
{versions.slice().reverse().map((v) => (
|
| 637 |
+
<li key={v.id} className="flex flex-wrap items-center gap-2 text-xs text-white/60">
|
| 638 |
+
<span className="font-medium text-white/85">{v.origin === 'stt' ? 'Transcribed here' : v.origin === 'edit' ? 'Corrected' : v.origin === 'live' ? 'Recorded live' : 'Imported'}</span>
|
| 639 |
+
<span>· {formatDateTime(v.created_at)} · {v.segment_count} lines{v.provider ? ` · ${v.provider}` : ''}</span>
|
| 640 |
+
{v.active ? <span className="rounded bg-emerald-500/20 px-1.5 text-emerald-200">shown</span>
|
| 641 |
+
: <button type="button" onClick={() => void libraryApi.publishVersion(meetingId, v.id).then(onSaved)} className="text-violet-300 hover:text-violet-200">Show this version</button>}
|
| 642 |
+
</li>
|
| 643 |
+
))}
|
| 644 |
+
</ul>
|
| 645 |
+
</details>
|
| 646 |
+
) : null}
|
| 647 |
+
</Card>
|
| 648 |
+
);
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
function ActionsTab({ meetingId, actions, item }: { meetingId: string; actions: Array<{ text: string; owner?: string; due?: string }>; item: LibraryItem }) {
|
| 652 |
+
const key = `hp_meeting_actions_done:${meetingId}`;
|
| 653 |
+
const [done, setDone] = useState<Record<number, boolean>>(() => {
|
| 654 |
+
try { return JSON.parse(localStorage.getItem(key) || '{}'); } catch { return {}; }
|
| 655 |
+
});
|
| 656 |
+
useEffect(() => { try { localStorage.setItem(key, JSON.stringify(done)); } catch { /* ignore */ } }, [key, done]);
|
| 657 |
+
return (
|
| 658 |
+
<Card icon={<CheckSquare size={18} />} title={`Action items${actions.length ? ` (${actions.length})` : ''}`}>
|
| 659 |
+
{actions.length ? (
|
| 660 |
+
<ul className="space-y-2">
|
| 661 |
+
{actions.map((a, i) => (
|
| 662 |
+
<li key={i} className="flex items-start gap-3 rounded-xl border border-white/[0.06] p-3">
|
| 663 |
+
<input type="checkbox" checked={!!done[i]} onChange={(e) => setDone((d) => ({ ...d, [i]: e.target.checked }))}
|
| 664 |
+
aria-label={`Done: ${a.text}`} className="mt-1 h-4 w-4 accent-violet-500" />
|
| 665 |
+
<div className="min-w-0 flex-1">
|
| 666 |
+
<p className={`text-[15px] ${done[i] ? 'text-white/40 line-through' : 'text-white/85'}`}>{a.text}</p>
|
| 667 |
+
{a.owner ? <p className="text-xs text-white/50">{a.owner}</p> : null}
|
| 668 |
+
</div>
|
| 669 |
+
{a.due ? <span className="rounded-md border border-white/10 px-2 py-0.5 text-xs text-white/60">{a.due}</span> : null}
|
| 670 |
+
</li>
|
| 671 |
+
))}
|
| 672 |
+
</ul>
|
| 673 |
+
) : (
|
| 674 |
+
<p className="text-sm text-white/55">{item.summary_state === 'pending' ? 'Action items are being written…' : 'No action items were found in this meeting.'}</p>
|
| 675 |
+
)}
|
| 676 |
+
{actions.length ? <p className="mt-3 text-xs text-white/40">Ticks are kept on this device.</p> : null}
|
| 677 |
+
</Card>
|
| 678 |
+
);
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
function SummaryTab({ detail, onWrite }: { detail: LibraryDetail; onWrite?: (style: string, length: string) => void }) {
|
| 682 |
+
const [style, setStyle] = useState('minutes');
|
| 683 |
+
const [length, setLength] = useState('standard');
|
| 684 |
+
const docs = detail.summaries || [];
|
| 685 |
+
return (
|
| 686 |
+
<Card icon={<Sparkles size={18} />} title="Summary" action={onWrite ? (
|
| 687 |
+
<div className="flex flex-wrap gap-2">
|
| 688 |
+
<select value={style} onChange={(e) => setStyle(e.target.value)} aria-label="Summary style" className="h-8 rounded-lg border border-white/10 bg-black px-2 text-sm">
|
| 689 |
+
{[['minutes', 'Minutes'], ['email', 'Recap email'], ['notes', 'Notes'], ['brief', 'Executive brief'], ['actions', 'Action list']].map(([id, l]) => <option key={id} value={id}>{l}</option>)}
|
| 690 |
+
</select>
|
| 691 |
+
<select value={length} onChange={(e) => setLength(e.target.value)} aria-label="Summary length" className="h-8 rounded-lg border border-white/10 bg-black px-2 text-sm">
|
| 692 |
+
{['short', 'standard', 'detailed'].map((l) => <option key={l} value={l}>{l[0].toUpperCase() + l.slice(1)}</option>)}
|
| 693 |
+
</select>
|
| 694 |
+
<button type="button" onClick={() => onWrite(style, length)} className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-violet-600 px-3 text-sm font-semibold hover:bg-violet-500"><RefreshCw size={13} /> Write</button>
|
| 695 |
+
</div>
|
| 696 |
+
) : null}>
|
| 697 |
+
{docs.length ? (
|
| 698 |
+
<div className="space-y-4">
|
| 699 |
+
{docs.slice().reverse().map((d, i) => (
|
| 700 |
+
<article key={d.id || i} className="rounded-xl border border-white/[0.06] p-4">
|
| 701 |
+
<p className="mb-2 text-xs uppercase tracking-wide text-white/40">
|
| 702 |
+
{isFallbackSummary(d) ? 'Outline · no language model' : (d.label || d.style || 'summary')} · {formatDateTime(d.created_at)}
|
| 703 |
+
</p>
|
| 704 |
+
<SummaryMarkdown text={d.text || ''} />
|
| 705 |
+
</article>
|
| 706 |
+
))}
|
| 707 |
+
</div>
|
| 708 |
+
) : <EmptyLine item={detail.item} hasTranscript={!!(detail.segments || []).length} />}
|
| 709 |
+
</Card>
|
| 710 |
+
);
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
function ChatTab({ meetingId, hasTranscript }: { meetingId: string | null; hasTranscript: boolean }) {
|
| 714 |
+
const [q, setQ] = useState('');
|
| 715 |
+
const [log, setLog] = useState<Array<{ q: string; a: string }>>([]);
|
| 716 |
+
const [busy, setBusy] = useState(false);
|
| 717 |
+
const ask = async () => {
|
| 718 |
+
if (!meetingId || !q.trim()) return;
|
| 719 |
+
const question = q.trim();
|
| 720 |
+
setQ('');
|
| 721 |
+
setBusy(true);
|
| 722 |
+
try {
|
| 723 |
+
const res = await libraryApi.ask(meetingId, question);
|
| 724 |
+
const text = String(res.text || res.answer || '').trim();
|
| 725 |
+
setLog((l) => [...l, { q: question, a: text || (res.error
|
| 726 |
+
? 'No language model answered, so this could not be answered. The transcript is still searchable.'
|
| 727 |
+
: 'The meeting does not say.') }]);
|
| 728 |
+
} catch (e) {
|
| 729 |
+
setLog((l) => [...l, { q: question, a: e instanceof LibraryError ? e.message : 'The question could not be answered.' }]);
|
| 730 |
+
} finally {
|
| 731 |
+
setBusy(false);
|
| 732 |
+
}
|
| 733 |
+
};
|
| 734 |
+
return (
|
| 735 |
+
<Card icon={<Bot size={18} />} title="Ask about this meeting">
|
| 736 |
+
{!hasTranscript ? <p className="text-sm text-white/55">Questions are answered from the transcript, and this meeting does not have one yet.</p> : (
|
| 737 |
+
<>
|
| 738 |
+
<ul className="space-y-3">
|
| 739 |
+
{log.map((m, i) => (
|
| 740 |
+
<li key={i} className="space-y-1.5">
|
| 741 |
+
<p className="ml-auto w-fit max-w-[85%] rounded-2xl rounded-br-md bg-violet-600/40 px-3 py-2 text-sm">{m.q}</p>
|
| 742 |
+
<p className="w-fit max-w-[90%] whitespace-pre-line rounded-2xl rounded-bl-md border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-white/85">{m.a}</p>
|
| 743 |
+
</li>
|
| 744 |
+
))}
|
| 745 |
+
{busy ? <li className="flex items-center gap-2 text-sm text-white/50"><Loader2 size={14} className="animate-spin" /> Thinking…</li> : null}
|
| 746 |
+
</ul>
|
| 747 |
+
<form className="mt-4 flex gap-2" onSubmit={(e) => { e.preventDefault(); void ask(); }}>
|
| 748 |
+
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Ask a question about this meeting..." aria-label="Ask a question about this meeting"
|
| 749 |
+
className="h-11 min-w-0 flex-1 rounded-xl border border-white/10 bg-white/[0.03] px-3 text-sm focus:border-violet-400/60 focus:outline-none" />
|
| 750 |
+
<button type="submit" disabled={busy || !q.trim()} aria-label="Ask" className="grid h-11 w-11 place-items-center rounded-xl bg-violet-600 hover:bg-violet-500 disabled:opacity-50"><Send size={16} /></button>
|
| 751 |
+
</form>
|
| 752 |
+
<p className="mt-2 text-xs text-white/40">Answers come only from this meeting's transcript and notes.</p>
|
| 753 |
+
</>
|
| 754 |
+
)}
|
| 755 |
+
</Card>
|
| 756 |
+
);
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
function FilesList({ detail, meetingId }: { detail: LibraryDetail; meetingId: string }) {
|
| 760 |
+
const assets = detail.assets || [];
|
| 761 |
+
if (!assets.length) return null;
|
| 762 |
+
return (
|
| 763 |
+
<Card icon={<Folder size={18} />} title="Recording files">
|
| 764 |
+
<ul className="divide-y divide-white/[0.06] text-sm">
|
| 765 |
+
{assets.map((a) => (
|
| 766 |
+
<li key={a.id} className="flex flex-wrap items-center gap-2 py-2">
|
| 767 |
+
<span className="min-w-0 flex-1 truncate">{a.original_name}</span>
|
| 768 |
+
<span className="text-xs text-white/45">{a.kind} · {bytes(a.size_bytes)}{a.media_duration_ms ? ` · ${clock(a.media_duration_ms)}` : ''}</span>
|
| 769 |
+
{!a.source_available ? <span className="text-xs text-amber-200">original no longer in the folder</span> : null}
|
| 770 |
+
<a href={mediaUrl(meetingId, a.id, true)} className="text-xs text-violet-300 hover:text-violet-200">Download</a>
|
| 771 |
+
</li>
|
| 772 |
+
))}
|
| 773 |
+
</ul>
|
| 774 |
+
</Card>
|
| 775 |
+
);
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
function PropertiesTab({ detail, meetingId }: { detail: LibraryDetail; meetingId: string | null }) {
|
| 779 |
+
const active = (detail.versions || []).find((v) => v.active);
|
| 780 |
+
const raw = detail.entry?.raw_metadata;
|
| 781 |
+
return (
|
| 782 |
+
<>
|
| 783 |
+
<Card icon={<Info size={18} />} title="Properties">
|
| 784 |
+
<dl className="grid grid-cols-[9rem_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm">
|
| 785 |
+
<dt className="text-white/50">Origin</dt><dd>{detail.item.source.label} ({detail.item.origin_type})</dd>
|
| 786 |
+
<dt className="text-white/50">Started</dt><dd>{formatDateTime(detail.item.started_at)}</dd>
|
| 787 |
+
<dt className="text-white/50">Duration</dt><dd>{formatDuration(detail.item.duration_ms)}</dd>
|
| 788 |
+
<dt className="text-white/50">Transcript</dt>
|
| 789 |
+
<dd>{active ? `${active.origin === 'stt' ? 'Transcribed on this computer' : active.origin} · timing ${active.timing}${active.provider ? ` · ${active.provider}` : ''}${active.model ? ` (${active.model})` : ''}` : statusChip(detail.item).label}</dd>
|
| 790 |
+
<dt className="text-white/50">Meeting id</dt><dd className="truncate font-mono text-xs">{meetingId || detail.item.library_id}</dd>
|
| 791 |
+
</dl>
|
| 792 |
+
</Card>
|
| 793 |
+
{meetingId ? <FilesList detail={detail} meetingId={meetingId} /> : null}
|
| 794 |
+
{(detail.assets || []).length ? (
|
| 795 |
+
<Card icon={<FileText size={18} />} title="File integrity">
|
| 796 |
+
<ul className="space-y-1 font-mono text-xs text-white/55">
|
| 797 |
+
{(detail.assets || []).map((a) => <li key={a.id} className="truncate">{a.sha256.slice(0, 16)}… {a.relative_path}</li>)}
|
| 798 |
+
</ul>
|
| 799 |
+
</Card>
|
| 800 |
+
) : null}
|
| 801 |
+
{raw ? (
|
| 802 |
+
<details className="rounded-2xl border border-white/10 bg-white/[0.02] p-4 text-sm">
|
| 803 |
+
<summary className="cursor-pointer font-semibold">Original recorder metadata</summary>
|
| 804 |
+
<pre className="mt-3 overflow-x-auto whitespace-pre-wrap text-xs text-white/60">{JSON.stringify(raw, null, 2)}</pre>
|
| 805 |
+
</details>
|
| 806 |
+
) : null}
|
| 807 |
+
</>
|
| 808 |
+
);
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
function TeamsRoomView({ room, onOpen }: { room: NonNullable<LibraryDetail['teams_room']>; onOpen: () => void }) {
|
| 812 |
+
return (
|
| 813 |
+
<div className="mt-5 space-y-4">
|
| 814 |
+
<Card icon={<Users size={18} />} title="Teams room" action={
|
| 815 |
+
<button type="button" onClick={onOpen} className="inline-flex items-center gap-1.5 rounded-lg bg-violet-600 px-3 py-1.5 text-sm font-semibold hover:bg-violet-500"><ExternalLink size={14} /> Open in Teams</button>}>
|
| 816 |
+
<p className="text-sm text-white/60">A conversation between personas — there is no recording. {room.message_count} messages{room.topic ? ` about “${room.topic}”` : ''}.</p>
|
| 817 |
+
</Card>
|
| 818 |
+
<Card icon={<FileText size={18} />} title="Conversation">
|
| 819 |
+
<ul className="space-y-3">
|
| 820 |
+
{room.messages.slice(0, 40).map((m, i) => (
|
| 821 |
+
<li key={i} className="grid grid-cols-[2.25rem_minmax(0,1fr)] gap-3">
|
| 822 |
+
<span className="grid h-9 w-9 place-items-center rounded-full bg-sky-500/20 text-xs font-semibold" aria-hidden>{initials(m.speaker)}</span>
|
| 823 |
+
<p className="text-[15px] text-white/85"><span className="mr-2 text-xs text-white/45">{m.speaker}</span>{m.text}</p>
|
| 824 |
+
</li>
|
| 825 |
+
))}
|
| 826 |
+
</ul>
|
| 827 |
+
</Card>
|
| 828 |
+
</div>
|
| 829 |
+
);
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
function DetailsPanel({ item, detail, overviewText, actions, speakers, onViewActions, onOpenProject, onTranscribe, onEditProjects }: {
|
| 833 |
+
item: LibraryItem; detail: LibraryDetail; overviewText: string; actions: Array<{ text: string; owner?: string; due?: string }>;
|
| 834 |
+
speakers: string[]; onViewActions: () => void; onOpenProject?: (id: string) => void; onTranscribe?: () => void;
|
| 835 |
+
onEditProjects?: (ids: string[]) => Promise<void>;
|
| 836 |
+
}) {
|
| 837 |
+
const [expanded, setExpanded] = useState(false);
|
| 838 |
+
const [editing, setEditing] = useState(false);
|
| 839 |
+
const [projects, setProjects] = useState<Array<{ id: string; name: string }>>([]);
|
| 840 |
+
const [chosen, setChosen] = useState<string[]>(item.projects.map((p) => p.id));
|
| 841 |
+
const chip = statusChip(item);
|
| 842 |
+
const short = overviewText.length > 260 && !expanded ? `${overviewText.slice(0, 260)}…` : overviewText;
|
| 843 |
+
const assets = detail.assets || [];
|
| 844 |
+
return (
|
| 845 |
+
<div className="space-y-4 text-sm">
|
| 846 |
+
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
|
| 847 |
+
<h3 className="mb-2 flex items-center gap-2 font-semibold"><FileText size={16} className="text-violet-300" /> Quick summary</h3>
|
| 848 |
+
{short ? <p className="whitespace-pre-line leading-relaxed text-white/75">{short}</p> : <p className="text-white/50">No summary yet.</p>}
|
| 849 |
+
{overviewText.length > 260 ? (
|
| 850 |
+
<button type="button" onClick={() => setExpanded((e) => !e)} className="mt-1 text-xs text-violet-300 hover:text-violet-200">{expanded ? 'Show less' : 'Show more'}</button>
|
| 851 |
+
) : null}
|
| 852 |
+
</section>
|
| 853 |
+
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
|
| 854 |
+
<h3 className="mb-2 flex items-center gap-2 font-semibold"><CheckSquare size={16} className="text-violet-300" /> Action items
|
| 855 |
+
{actions.length ? <span className="rounded-full bg-white/10 px-1.5 text-xs">{actions.length}</span> : null}
|
| 856 |
+
{actions.length ? <button type="button" onClick={onViewActions} className="ml-auto text-xs font-normal text-violet-300 hover:text-violet-200">View all →</button> : null}
|
| 857 |
+
</h3>
|
| 858 |
+
{actions.length ? (
|
| 859 |
+
<ul className="space-y-2">{actions.slice(0, 3).map((a, i) => (
|
| 860 |
+
<li key={i} className="flex items-start gap-2 text-white/80"><span className="mt-1 h-3.5 w-3.5 shrink-0 rounded border border-white/30" aria-hidden />
|
| 861 |
+
<span className="min-w-0">{a.text}{a.owner ? <span className="block text-xs text-white/45">{a.owner}</span> : null}</span>
|
| 862 |
+
{a.due ? <span className="ml-auto shrink-0 rounded border border-white/10 px-1.5 text-xs text-white/55">{a.due}</span> : null}
|
| 863 |
+
</li>))}
|
| 864 |
+
</ul>
|
| 865 |
+
) : <p className="text-white/50">None found.</p>}
|
| 866 |
+
</section>
|
| 867 |
+
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
|
| 868 |
+
<h3 className="mb-3 flex items-center gap-2 font-semibold"><Info size={16} className="text-violet-300" /> Details</h3>
|
| 869 |
+
<dl className="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-x-3 gap-y-2.5">
|
| 870 |
+
<dt className="text-white/50">Date</dt><dd>{formatDateTime(item.started_at || item.scheduled_for)}</dd>
|
| 871 |
+
<dt className="text-white/50">Duration</dt><dd>{formatDuration(item.duration_ms)}</dd>
|
| 872 |
+
<dt className="text-white/50">Project</dt>
|
| 873 |
+
<dd>
|
| 874 |
+
{editing ? (
|
| 875 |
+
<div className="space-y-1">
|
| 876 |
+
{projects.map((p) => (
|
| 877 |
+
<label key={p.id} className="flex items-center gap-2"><input type="checkbox" checked={chosen.includes(p.id)} className="accent-violet-500"
|
| 878 |
+
onChange={(e) => setChosen((c) => e.target.checked ? [...c, p.id] : c.filter((x) => x !== p.id))} /> {p.name}</label>
|
| 879 |
+
))}
|
| 880 |
+
<div className="flex gap-2 pt-1">
|
| 881 |
+
<button type="button" onClick={() => setEditing(false)} className="text-xs text-white/60 hover:text-white">Cancel</button>
|
| 882 |
+
<button type="button" onClick={async () => { await onEditProjects?.(chosen); setEditing(false); }} className="text-xs text-violet-300 hover:text-violet-200">Save</button>
|
| 883 |
+
</div>
|
| 884 |
+
</div>
|
| 885 |
+
) : (
|
| 886 |
+
<div className="flex flex-wrap items-center gap-1.5">
|
| 887 |
+
{item.projects.length ? item.projects.map((p) => (
|
| 888 |
+
<button key={p.id} type="button" onClick={() => onOpenProject?.(p.id)} className="inline-flex items-center gap-1 text-violet-300 hover:text-violet-200">
|
| 889 |
+
{p.name} <ExternalLink size={12} />
|
| 890 |
+
</button>
|
| 891 |
+
)) : <span className="text-white/50">Unsorted</span>}
|
| 892 |
+
{onEditProjects ? (
|
| 893 |
+
<button type="button" aria-label="Change projects" onClick={async () => { setProjects(await libraryApi.projects()); setEditing(true); }}
|
| 894 |
+
className="text-white/40 hover:text-white"><Pencil size={12} /></button>
|
| 895 |
+
) : null}
|
| 896 |
+
</div>
|
| 897 |
+
)}
|
| 898 |
+
</dd>
|
| 899 |
+
<dt className="text-white/50">Source</dt><dd>{item.source.label}</dd>
|
| 900 |
+
<dt className="text-white/50">Transcription</dt>
|
| 901 |
+
<dd className="flex flex-wrap items-center gap-2">
|
| 902 |
+
<span className={`inline-flex items-center gap-1.5 ${chip.tone === 'green' ? 'text-emerald-300' : chip.tone === 'red' ? 'text-rose-300' : chip.tone === 'amber' ? 'text-amber-200' : 'text-white/75'}`}>
|
| 903 |
+
<span className="h-2 w-2 rounded-full bg-current" aria-hidden /> {chip.label}
|
| 904 |
+
</span>
|
| 905 |
+
{onTranscribe && ['none', 'needs_setup', 'failed'].includes(item.transcript_state) ? (
|
| 906 |
+
<button type="button" onClick={onTranscribe} className="text-xs text-violet-300 hover:text-violet-200">Transcribe</button>
|
| 907 |
+
) : null}
|
| 908 |
+
</dd>
|
| 909 |
+
</dl>
|
| 910 |
+
</section>
|
| 911 |
+
{speakers.length ? (
|
| 912 |
+
<details className="rounded-2xl border border-white/10 bg-white/[0.02] p-4" open>
|
| 913 |
+
<summary className="flex cursor-pointer items-center gap-2 font-semibold"><Users size={16} className="text-violet-300" /> Participants ({speakers.length})</summary>
|
| 914 |
+
<ul className="mt-2 space-y-1.5">{speakers.map((s) => <li key={s} className="flex items-center gap-2"><span className="grid h-6 w-6 place-items-center rounded-full bg-violet-500/20 text-[10px]">{initials(s)}</span>{s}</li>)}</ul>
|
| 915 |
+
</details>
|
| 916 |
+
) : null}
|
| 917 |
+
{assets.length ? (
|
| 918 |
+
<details className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
|
| 919 |
+
<summary className="flex cursor-pointer items-center gap-2 font-semibold"><Folder size={16} className="text-violet-300" /> Recording files ({assets.length})</summary>
|
| 920 |
+
<ul className="mt-2 space-y-1.5 text-white/70">{assets.map((a) => (
|
| 921 |
+
<li key={a.id} className="flex gap-2"><span className="min-w-0 flex-1 truncate">{a.original_name}</span><span className="shrink-0 text-xs text-white/45">{bytes(a.size_bytes)}</span></li>
|
| 922 |
+
))}</ul>
|
| 923 |
+
</details>
|
| 924 |
+
) : null}
|
| 925 |
+
</div>
|
| 926 |
+
);
|
| 927 |
+
}
|
| 928 |
+
|
| 929 |
+
export default MeetingDetailPage;
|
frontend/src/ui/meetingsense/library/MeetingsHome.tsx
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Meetings — the library landing page (MEETINGS_LIBRARY_UPGRADE).
|
| 3 |
+
*
|
| 4 |
+
* Laid out like Projects: a header with the page's actions, a tab strip, a search row and a
|
| 5 |
+
* card grid (1 / 2 / 3 columns). Every meeting appears once — recorded live, imported,
|
| 6 |
+
* synced, scheduled, or a Teams room — and opens in Meeting Detail (or in Teams for a room).
|
| 7 |
+
* Nothing here records or uploads by itself: New Meeting opens the wizard, Import Files the
|
| 8 |
+
* import preview, Folder Sync the folder panel.
|
| 9 |
+
*/
|
| 10 |
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
| 11 |
+
import {
|
| 12 |
+
ArrowUpDown, Building2, CalendarDays, Check, ChevronDown, Clock3, CloudUpload, FileText, Filter, Folder,
|
| 13 |
+
Loader2, MoreHorizontal, Plus, RefreshCw, Search, X,
|
| 14 |
+
} from 'lucide-react';
|
| 15 |
+
import { LibraryError, libraryApi, type LibraryItem, type LibraryPage, type LibraryStatus } from './api';
|
| 16 |
+
import {
|
| 17 |
+
SORTS, STATE_FILTERS, TABS, TONE_CLASSES, categoryTone, footerPlace, formatDate, formatDuration, isWorking, statusChip,
|
| 18 |
+
} from './format';
|
| 19 |
+
import { ImportDialog } from './ImportDialog';
|
| 20 |
+
import { FolderSyncPanel } from './FolderSyncPanel';
|
| 21 |
+
import { ProcessingPanel } from './ProcessingPanel';
|
| 22 |
+
import { StartMeetingWizard } from './StartMeetingWizard';
|
| 23 |
+
|
| 24 |
+
export interface MeetingsHomeProps {
|
| 25 |
+
status: LibraryStatus | null;
|
| 26 |
+
onOpen: (item: LibraryItem) => void;
|
| 27 |
+
/** Live start, provided by MeetingSense; absent when this install cannot record. */
|
| 28 |
+
canRecordLive?: boolean;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const PAGE = 24;
|
| 32 |
+
|
| 33 |
+
function useDebounced<T>(value: T, ms: number): T {
|
| 34 |
+
const [v, setV] = useState(value);
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
const t = setTimeout(() => setV(value), ms);
|
| 37 |
+
return () => clearTimeout(t);
|
| 38 |
+
}, [value, ms]);
|
| 39 |
+
return v;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export function MeetingsHome({ status, onOpen, canRecordLive = true }: MeetingsHomeProps) {
|
| 43 |
+
const [tab, setTab] = useState(() => {
|
| 44 |
+
try { return localStorage.getItem('hp_meetings_tab') || 'all'; } catch { return 'all'; }
|
| 45 |
+
});
|
| 46 |
+
const [query, setQuery] = useState('');
|
| 47 |
+
const q = useDebounced(query.trim(), 250);
|
| 48 |
+
const [sort, setSort] = useState('newest');
|
| 49 |
+
const [source, setSource] = useState('');
|
| 50 |
+
const [projectId, setProjectId] = useState('');
|
| 51 |
+
const [state, setState] = useState('');
|
| 52 |
+
const [page, setPage] = useState<LibraryPage | null>(null);
|
| 53 |
+
const [items, setItems] = useState<LibraryItem[]>([]);
|
| 54 |
+
const [loading, setLoading] = useState(true);
|
| 55 |
+
const [loadingMore, setLoadingMore] = useState(false);
|
| 56 |
+
const [error, setError] = useState<string | null>(null);
|
| 57 |
+
const [sortOpen, setSortOpen] = useState(false);
|
| 58 |
+
const [filterOpen, setFilterOpen] = useState(false);
|
| 59 |
+
const [dialog, setDialog] = useState<null | 'import' | 'sync' | 'new'>(null);
|
| 60 |
+
const [notice, setNotice] = useState<string | null>(null);
|
| 61 |
+
const requestRef = useRef(0);
|
| 62 |
+
|
| 63 |
+
useEffect(() => { try { localStorage.setItem('hp_meetings_tab', tab); } catch { /* ignore */ } }, [tab]);
|
| 64 |
+
|
| 65 |
+
const params = useMemo(() => ({ tab, q, sort, source, project_id: projectId, state, limit: PAGE }),
|
| 66 |
+
[tab, q, sort, source, projectId, state]);
|
| 67 |
+
|
| 68 |
+
const load = useCallback(async (quiet = false) => {
|
| 69 |
+
const id = ++requestRef.current;
|
| 70 |
+
if (!quiet) setLoading(true);
|
| 71 |
+
try {
|
| 72 |
+
const body = await libraryApi.list(params);
|
| 73 |
+
if (id !== requestRef.current) return;
|
| 74 |
+
setPage(body);
|
| 75 |
+
setItems(body.items);
|
| 76 |
+
setError(null);
|
| 77 |
+
} catch (e) {
|
| 78 |
+
if (id !== requestRef.current) return;
|
| 79 |
+
setError(e instanceof LibraryError ? e.message : 'The meetings could not be loaded.');
|
| 80 |
+
} finally {
|
| 81 |
+
if (id === requestRef.current) setLoading(false);
|
| 82 |
+
}
|
| 83 |
+
}, [params]);
|
| 84 |
+
|
| 85 |
+
useEffect(() => { void load(); }, [load]);
|
| 86 |
+
|
| 87 |
+
// Keep cards current while something is being transcribed or summarised.
|
| 88 |
+
const working = items.some(isWorking) || tab === 'processing';
|
| 89 |
+
useEffect(() => {
|
| 90 |
+
if (!working) return undefined;
|
| 91 |
+
const t = setInterval(() => { void load(true); }, 4000);
|
| 92 |
+
return () => clearInterval(t);
|
| 93 |
+
}, [working, load]);
|
| 94 |
+
|
| 95 |
+
const loadMore = async () => {
|
| 96 |
+
if (!page?.next_cursor) return;
|
| 97 |
+
setLoadingMore(true);
|
| 98 |
+
try {
|
| 99 |
+
const body = await libraryApi.list({ ...params, cursor: page.next_cursor });
|
| 100 |
+
setPage(body);
|
| 101 |
+
setItems((cur) => [...cur, ...body.items]);
|
| 102 |
+
} catch (e) {
|
| 103 |
+
setError(e instanceof LibraryError ? e.message : 'More meetings could not be loaded.');
|
| 104 |
+
} finally {
|
| 105 |
+
setLoadingMore(false);
|
| 106 |
+
}
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
const filtersActive = Boolean(source || projectId || state);
|
| 110 |
+
const counts = page?.counts || {};
|
| 111 |
+
|
| 112 |
+
return (
|
| 113 |
+
<div className="hp-meetings h-full flex flex-col bg-black text-white" data-testid="meetings-home">
|
| 114 |
+
<header className="hp-clear-nav flex flex-wrap items-center gap-3 border-b border-white/[0.06] px-4 py-3 sm:px-6 sm:py-4">
|
| 115 |
+
<div className="grid h-11 w-11 shrink-0 place-items-center rounded-xl border border-white/10 bg-white/[0.04]">
|
| 116 |
+
<CalendarDays size={20} className="text-violet-300" />
|
| 117 |
+
</div>
|
| 118 |
+
<div className="min-w-0">
|
| 119 |
+
<h1 className="text-lg font-semibold leading-tight sm:text-xl">Meetings</h1>
|
| 120 |
+
<p className="text-xs text-white/50 sm:text-sm">All meetings in one place</p>
|
| 121 |
+
</div>
|
| 122 |
+
<div className="ml-auto flex items-center gap-2">
|
| 123 |
+
<button
|
| 124 |
+
type="button"
|
| 125 |
+
onClick={() => setDialog('new')}
|
| 126 |
+
aria-label="New Meeting"
|
| 127 |
+
title="New Meeting"
|
| 128 |
+
className="inline-flex h-10 items-center gap-2 rounded-xl bg-gradient-to-r from-fuchsia-600 to-violet-600 px-3 text-sm font-semibold shadow-lg shadow-fuchsia-900/30 hover:from-fuchsia-500 hover:to-violet-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fuchsia-300 sm:px-4"
|
| 129 |
+
>
|
| 130 |
+
<Plus size={16} /> <span className="hidden sm:inline">New Meeting</span>
|
| 131 |
+
</button>
|
| 132 |
+
<button
|
| 133 |
+
type="button"
|
| 134 |
+
onClick={() => setDialog('import')}
|
| 135 |
+
aria-label="Import Files"
|
| 136 |
+
title="Import Files"
|
| 137 |
+
className="inline-flex h-10 items-center gap-2 rounded-xl border border-teal-400/40 bg-teal-500/10 px-3 text-sm font-semibold text-teal-200 hover:bg-teal-500/20 sm:px-4"
|
| 138 |
+
>
|
| 139 |
+
<CloudUpload size={16} /> <span className="hidden sm:inline">Import Files</span>
|
| 140 |
+
</button>
|
| 141 |
+
<button
|
| 142 |
+
type="button"
|
| 143 |
+
onClick={() => setDialog('sync')}
|
| 144 |
+
aria-label="Folder Sync"
|
| 145 |
+
title="Folder Sync"
|
| 146 |
+
className="inline-flex h-10 items-center gap-2 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-sm font-medium text-white/80 hover:bg-white/[0.08] sm:px-4"
|
| 147 |
+
>
|
| 148 |
+
<RefreshCw size={15} /> <span className="hidden sm:inline">Folder Sync</span>
|
| 149 |
+
</button>
|
| 150 |
+
</div>
|
| 151 |
+
</header>
|
| 152 |
+
|
| 153 |
+
<nav className="flex gap-2 overflow-x-auto px-4 pt-4 scrollbar-hide sm:px-6" aria-label="Meeting groups" role="tablist">
|
| 154 |
+
{TABS.map((t) => (
|
| 155 |
+
<button
|
| 156 |
+
key={t.id}
|
| 157 |
+
type="button"
|
| 158 |
+
role="tab"
|
| 159 |
+
aria-selected={tab === t.id}
|
| 160 |
+
onClick={() => setTab(t.id)}
|
| 161 |
+
data-testid={`meetings-tab-${t.id}`}
|
| 162 |
+
className={`shrink-0 rounded-full px-4 py-1.5 text-sm transition-colors ${
|
| 163 |
+
tab === t.id
|
| 164 |
+
? 'bg-violet-600/80 font-semibold text-white'
|
| 165 |
+
: 'border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.07] hover:text-white'
|
| 166 |
+
}`}
|
| 167 |
+
>
|
| 168 |
+
{t.label}
|
| 169 |
+
{t.id === 'processing' && counts.processing ? (
|
| 170 |
+
<span className="ml-1.5 rounded-full bg-sky-500/30 px-1.5 text-[11px] text-sky-100">{counts.processing}</span>
|
| 171 |
+
) : null}
|
| 172 |
+
</button>
|
| 173 |
+
))}
|
| 174 |
+
</nav>
|
| 175 |
+
|
| 176 |
+
<div className="flex flex-wrap items-center gap-2 px-4 pt-3 sm:gap-3 sm:px-6">
|
| 177 |
+
<label className="relative min-w-0 flex-1 basis-[16rem]">
|
| 178 |
+
<span className="sr-only">Search meetings</span>
|
| 179 |
+
<Search size={16} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-white/40" />
|
| 180 |
+
<input
|
| 181 |
+
type="search"
|
| 182 |
+
value={query}
|
| 183 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 184 |
+
placeholder="Search meetings by title, transcript, or project..."
|
| 185 |
+
className="h-11 w-full rounded-xl border border-white/10 bg-white/[0.03] pl-10 pr-3 text-sm text-white placeholder-white/40 focus:border-violet-400/60 focus:outline-none"
|
| 186 |
+
data-testid="meetings-search"
|
| 187 |
+
/>
|
| 188 |
+
</label>
|
| 189 |
+
<div className="relative">
|
| 190 |
+
<button type="button" onClick={() => { setSortOpen((o) => !o); setFilterOpen(false); }} aria-expanded={sortOpen}
|
| 191 |
+
className="inline-flex h-11 items-center gap-2 rounded-xl border border-white/10 bg-white/[0.03] px-3.5 text-sm text-white/80 hover:bg-white/[0.07]">
|
| 192 |
+
<ArrowUpDown size={15} /> Sort <ChevronDown size={14} className="text-white/50" />
|
| 193 |
+
</button>
|
| 194 |
+
{sortOpen ? (
|
| 195 |
+
<div className="absolute right-0 z-30 mt-2 w-48 overflow-hidden rounded-xl border border-white/10 bg-[#121218] py-1 shadow-2xl" role="menu">
|
| 196 |
+
{SORTS.map((s) => (
|
| 197 |
+
<button key={s.id} type="button" role="menuitemradio" aria-checked={sort === s.id}
|
| 198 |
+
onClick={() => { setSort(s.id); setSortOpen(false); }}
|
| 199 |
+
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-white/80 hover:bg-white/[0.06]">
|
| 200 |
+
{s.label} {sort === s.id ? <Check size={14} className="text-violet-300" /> : null}
|
| 201 |
+
</button>
|
| 202 |
+
))}
|
| 203 |
+
</div>
|
| 204 |
+
) : null}
|
| 205 |
+
</div>
|
| 206 |
+
<div className="relative">
|
| 207 |
+
<button type="button" onClick={() => { setFilterOpen((o) => !o); setSortOpen(false); }} aria-expanded={filterOpen}
|
| 208 |
+
className={`inline-flex h-11 items-center gap-2 rounded-xl border px-3.5 text-sm hover:bg-white/[0.07] ${
|
| 209 |
+
filtersActive ? 'border-violet-400/50 bg-violet-500/10 text-violet-100' : 'border-white/10 bg-white/[0.03] text-white/80'}`}>
|
| 210 |
+
<Filter size={15} /> Filter{filtersActive ? ' •' : ''}
|
| 211 |
+
</button>
|
| 212 |
+
{filterOpen ? (
|
| 213 |
+
<div className="absolute right-0 z-30 mt-2 w-72 space-y-3 rounded-xl border border-white/10 bg-[#121218] p-3 shadow-2xl">
|
| 214 |
+
<FilterSelect label="Source" value={source} onChange={setSource}
|
| 215 |
+
options={[{ id: '', label: 'Every source' }, ...(page?.facets.sources || []).map((s) => ({ id: s.kind, label: s.label }))]} />
|
| 216 |
+
<FilterSelect label="Project" value={projectId} onChange={setProjectId}
|
| 217 |
+
options={[{ id: '', label: 'Every project' }, ...(page?.facets.projects || []).map((p) => ({ id: p.id, label: p.name }))]} />
|
| 218 |
+
<FilterSelect label="Transcript" value={state} onChange={setState} options={STATE_FILTERS} />
|
| 219 |
+
{filtersActive ? (
|
| 220 |
+
<button type="button" onClick={() => { setSource(''); setProjectId(''); setState(''); }}
|
| 221 |
+
className="w-full rounded-lg border border-white/10 py-1.5 text-xs text-white/70 hover:bg-white/[0.06]">Clear filters</button>
|
| 222 |
+
) : null}
|
| 223 |
+
</div>
|
| 224 |
+
) : null}
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
+
<div className="flex-1 overflow-y-auto px-4 pb-8 pt-4 sm:px-6" onClick={() => { setSortOpen(false); setFilterOpen(false); }}>
|
| 229 |
+
{notice ? (
|
| 230 |
+
<div className="mb-3 flex items-center gap-3 rounded-xl border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-100" role="status">
|
| 231 |
+
<Check size={16} /> <span className="flex-1">{notice}</span>
|
| 232 |
+
<button type="button" onClick={() => setNotice(null)} aria-label="Dismiss" className="text-emerald-200/70 hover:text-white"><X size={14} /></button>
|
| 233 |
+
</div>
|
| 234 |
+
) : null}
|
| 235 |
+
{tab === 'processing' ? <ProcessingPanel onChanged={() => void load(true)} /> : null}
|
| 236 |
+
{error ? (
|
| 237 |
+
<div className="rounded-2xl border border-rose-400/30 bg-rose-500/10 p-6 text-center" role="alert">
|
| 238 |
+
<p className="text-sm text-rose-100">{error}</p>
|
| 239 |
+
<button type="button" onClick={() => void load()} className="mt-3 rounded-lg border border-white/15 px-3 py-1.5 text-sm hover:bg-white/10">Try again</button>
|
| 240 |
+
</div>
|
| 241 |
+
) : loading && !items.length ? (
|
| 242 |
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3" aria-busy="true" aria-label="Loading meetings">
|
| 243 |
+
{Array.from({ length: 6 }).map((_, i) => (
|
| 244 |
+
<div key={i} className="h-[174px] animate-pulse rounded-2xl border border-white/[0.06] bg-white/[0.03]" />
|
| 245 |
+
))}
|
| 246 |
+
</div>
|
| 247 |
+
) : items.length === 0 ? (
|
| 248 |
+
<EmptyState tab={tab} filtered={Boolean(q || filtersActive)} onImport={() => setDialog('import')} onNew={() => setDialog('new')} onSync={() => setDialog('sync')} />
|
| 249 |
+
) : (
|
| 250 |
+
<>
|
| 251 |
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3" data-testid="meetings-grid">
|
| 252 |
+
{items.map((item) => (
|
| 253 |
+
<LibraryCard key={item.library_id} item={item} onOpen={() => onOpen(item)}
|
| 254 |
+
onChanged={(msg) => { if (msg) setNotice(msg); void load(true); }} />
|
| 255 |
+
))}
|
| 256 |
+
</div>
|
| 257 |
+
{page?.next_cursor ? (
|
| 258 |
+
<div className="mt-6 text-center">
|
| 259 |
+
<button type="button" onClick={() => void loadMore()} disabled={loadingMore}
|
| 260 |
+
className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white/80 hover:bg-white/[0.08] disabled:opacity-50">
|
| 261 |
+
{loadingMore ? <Loader2 size={14} className="animate-spin" /> : null}
|
| 262 |
+
Show more ({(page.total || 0) - items.length} left)
|
| 263 |
+
</button>
|
| 264 |
+
</div>
|
| 265 |
+
) : null}
|
| 266 |
+
</>
|
| 267 |
+
)}
|
| 268 |
+
</div>
|
| 269 |
+
|
| 270 |
+
{dialog === 'import' ? (
|
| 271 |
+
<ImportDialog
|
| 272 |
+
onClose={() => setDialog(null)}
|
| 273 |
+
maxUploadMb={status?.max_upload_mb}
|
| 274 |
+
onImported={(n) => { setDialog(null); setNotice(n === 1 ? 'Imported 1 meeting.' : `Imported ${n} meetings.`); void load(true); }}
|
| 275 |
+
/>
|
| 276 |
+
) : null}
|
| 277 |
+
{dialog === 'sync' ? (
|
| 278 |
+
<FolderSyncPanel onClose={() => setDialog(null)} onImported={() => void load(true)} suggestion={status?.sync_suggestion} />
|
| 279 |
+
) : null}
|
| 280 |
+
{dialog === 'new' ? (
|
| 281 |
+
<StartMeetingWizard
|
| 282 |
+
canRecordLive={canRecordLive}
|
| 283 |
+
onClose={() => setDialog(null)}
|
| 284 |
+
onCreated={(msg) => { setDialog(null); setNotice(msg); void load(true); }}
|
| 285 |
+
/>
|
| 286 |
+
) : null}
|
| 287 |
+
</div>
|
| 288 |
+
);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
function FilterSelect({ label, value, onChange, options }: {
|
| 292 |
+
label: string; value: string; onChange: (v: string) => void; options: Array<{ id: string; label: string }>;
|
| 293 |
+
}) {
|
| 294 |
+
return (
|
| 295 |
+
<label className="block text-xs text-white/60">
|
| 296 |
+
{label}
|
| 297 |
+
<select value={value} onChange={(e) => onChange(e.target.value)}
|
| 298 |
+
className="mt-1 h-9 w-full rounded-lg border border-white/10 bg-black px-2 text-sm text-white focus:border-violet-400/60 focus:outline-none">
|
| 299 |
+
{options.map((o) => <option key={o.id || 'any'} value={o.id}>{o.label}</option>)}
|
| 300 |
+
</select>
|
| 301 |
+
</label>
|
| 302 |
+
);
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
function EmptyState({ tab, filtered, onImport, onNew, onSync }: {
|
| 306 |
+
tab: string; filtered: boolean; onImport: () => void; onNew: () => void; onSync: () => void;
|
| 307 |
+
}) {
|
| 308 |
+
const text = filtered
|
| 309 |
+
? 'No meetings match your search or filters.'
|
| 310 |
+
: tab === 'processing'
|
| 311 |
+
? 'Nothing is being processed right now.'
|
| 312 |
+
: tab === 'all'
|
| 313 |
+
? 'No meetings yet. Start one, import recordings, or connect a folder such as D:\\AIRecord.'
|
| 314 |
+
: 'No meetings in this group yet.';
|
| 315 |
+
return (
|
| 316 |
+
<div className="mx-auto max-w-xl rounded-2xl border border-white/[0.08] bg-white/[0.02] p-10 text-center" data-testid="meetings-empty">
|
| 317 |
+
<CalendarDays size={28} className="mx-auto text-white/30" />
|
| 318 |
+
<p className="mt-3 text-sm text-white/60">{text}</p>
|
| 319 |
+
{!filtered && tab === 'all' ? (
|
| 320 |
+
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
| 321 |
+
<button type="button" onClick={onNew} className="rounded-xl bg-violet-600 px-3 py-2 text-sm font-semibold hover:bg-violet-500">New Meeting</button>
|
| 322 |
+
<button type="button" onClick={onImport} className="rounded-xl border border-teal-400/40 bg-teal-500/10 px-3 py-2 text-sm text-teal-200 hover:bg-teal-500/20">Import Files</button>
|
| 323 |
+
<button type="button" onClick={onSync} className="rounded-xl border border-white/10 px-3 py-2 text-sm text-white/80 hover:bg-white/[0.06]">Folder Sync</button>
|
| 324 |
+
</div>
|
| 325 |
+
) : null}
|
| 326 |
+
</div>
|
| 327 |
+
);
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
export function LibraryCard({ item, onOpen, onChanged }: {
|
| 331 |
+
item: LibraryItem; onOpen: () => void; onChanged?: (message?: string) => void;
|
| 332 |
+
}) {
|
| 333 |
+
const chip = statusChip(item);
|
| 334 |
+
const place = footerPlace(item);
|
| 335 |
+
const PlaceIcon = place.kind === 'project' ? Folder
|
| 336 |
+
: item.source.kind === 'teams_room' ? Building2
|
| 337 |
+
: (item.source.kind === 'insta360' || item.source.kind === 'upload' || item.source.kind === 'transcript') ? FileText : Folder;
|
| 338 |
+
const [menu, setMenu] = useState(false);
|
| 339 |
+
const [busy, setBusy] = useState(false);
|
| 340 |
+
const meetingId = item.meeting_id;
|
| 341 |
+
|
| 342 |
+
const act = async (fn: () => Promise<unknown>, message?: string) => {
|
| 343 |
+
setBusy(true);
|
| 344 |
+
try {
|
| 345 |
+
await fn();
|
| 346 |
+
onChanged?.(message);
|
| 347 |
+
} catch (e) {
|
| 348 |
+
onChanged?.(e instanceof LibraryError ? e.message : 'That did not work.');
|
| 349 |
+
} finally {
|
| 350 |
+
setBusy(false);
|
| 351 |
+
setMenu(false);
|
| 352 |
+
}
|
| 353 |
+
};
|
| 354 |
+
|
| 355 |
+
return (
|
| 356 |
+
<div
|
| 357 |
+
role="button"
|
| 358 |
+
tabIndex={0}
|
| 359 |
+
onClick={onOpen}
|
| 360 |
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(); } }}
|
| 361 |
+
aria-label={`${item.title}, ${chip.label}`}
|
| 362 |
+
data-testid={`meeting-card-${item.library_id}`}
|
| 363 |
+
className="group relative cursor-pointer rounded-2xl border border-white/10 bg-white/[0.03] p-5 transition-colors hover:border-white/20 hover:bg-white/[0.05] focus-visible:outline focus-visible:outline-2 focus-visible:outline-violet-400"
|
| 364 |
+
>
|
| 365 |
+
<div className="flex flex-wrap items-center gap-2 pr-8">
|
| 366 |
+
<span className={`rounded-lg border px-2.5 py-0.5 text-xs font-medium ${TONE_CLASSES[categoryTone(item.category)]}`}>{item.category}</span>
|
| 367 |
+
<span className={`inline-flex items-center gap-1 rounded-lg border px-2.5 py-0.5 text-xs font-medium ${TONE_CLASSES[chip.tone]}`} title={chip.title}>
|
| 368 |
+
{chip.busy ? <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-current" aria-hidden /> : null}
|
| 369 |
+
{chip.label}
|
| 370 |
+
</span>
|
| 371 |
+
</div>
|
| 372 |
+
<h3 className="mt-3 line-clamp-1 text-lg font-semibold text-white">{item.title}</h3>
|
| 373 |
+
<p className="mt-1 line-clamp-2 min-h-[2.5rem] text-sm text-white/55">
|
| 374 |
+
{item.description || (item.recording_state === 'awaiting' ? 'Waiting for its recording.' : item.warnings[0] || '\u00a0')}
|
| 375 |
+
</p>
|
| 376 |
+
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-1 text-xs text-white/55">
|
| 377 |
+
<span className="inline-flex items-center gap-1.5"><CalendarDays size={14} /> {formatDate(item.started_at || item.scheduled_for)}</span>
|
| 378 |
+
<span className="inline-flex items-center gap-1.5"><Clock3 size={14} /> {formatDuration(item.duration_ms)}</span>
|
| 379 |
+
<span className="inline-flex min-w-0 items-center gap-1.5"><PlaceIcon size={14} /> <span className="truncate">{place.label}</span></span>
|
| 380 |
+
</div>
|
| 381 |
+
{meetingId ? (
|
| 382 |
+
<div className="absolute right-3 top-3" onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
|
| 383 |
+
<button
|
| 384 |
+
type="button"
|
| 385 |
+
aria-label={`More actions for ${item.title}`}
|
| 386 |
+
aria-expanded={menu}
|
| 387 |
+
onClick={() => setMenu((m) => !m)}
|
| 388 |
+
className="grid h-8 w-8 place-items-center rounded-lg text-white/50 opacity-100 hover:bg-white/10 hover:text-white focus-visible:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
| 389 |
+
>
|
| 390 |
+
{busy ? <Loader2 size={15} className="animate-spin" /> : <MoreHorizontal size={16} />}
|
| 391 |
+
</button>
|
| 392 |
+
{menu ? (
|
| 393 |
+
<div className="absolute right-0 z-20 mt-1 w-52 overflow-hidden rounded-xl border border-white/10 bg-[#121218] py-1 text-sm shadow-2xl" role="menu">
|
| 394 |
+
<MenuButton onClick={() => {
|
| 395 |
+
const title = window.prompt('Rename meeting', item.title);
|
| 396 |
+
if (title && title.trim() && title.trim() !== item.title) void act(() => libraryApi.update(meetingId, { title: title.trim() }), 'Renamed.');
|
| 397 |
+
else setMenu(false);
|
| 398 |
+
}}>Rename</MenuButton>
|
| 399 |
+
{item.media_present && item.transcript_state !== 'transcribing' && item.transcript_state !== 'queued' ? (
|
| 400 |
+
<MenuButton onClick={() => void act(() => libraryApi.transcribe(meetingId), 'Queued for transcription on this computer.')}>
|
| 401 |
+
{item.segments ? 'Transcribe again' : 'Transcribe'}
|
| 402 |
+
</MenuButton>
|
| 403 |
+
) : null}
|
| 404 |
+
<MenuButton onClick={() => void act(() => libraryApi.remove(meetingId, 'remove'), 'Removed from the library. Its data is kept.')}>Remove from library</MenuButton>
|
| 405 |
+
<MenuButton danger onClick={() => {
|
| 406 |
+
if (window.confirm(`Delete "${item.title}" and the copies HomePilot keeps of its recording and transcript? Original files in a synced folder are not touched.`)) {
|
| 407 |
+
void act(() => libraryApi.remove(meetingId, 'delete'), 'Deleted.');
|
| 408 |
+
} else setMenu(false);
|
| 409 |
+
}}>Delete meeting data…</MenuButton>
|
| 410 |
+
</div>
|
| 411 |
+
) : null}
|
| 412 |
+
</div>
|
| 413 |
+
) : null}
|
| 414 |
+
</div>
|
| 415 |
+
);
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
function MenuButton({ children, onClick, danger }: { children: React.ReactNode; onClick: () => void; danger?: boolean }) {
|
| 419 |
+
return (
|
| 420 |
+
<button type="button" role="menuitem" onClick={onClick}
|
| 421 |
+
className={`block w-full px-3 py-2 text-left hover:bg-white/[0.06] ${danger ? 'text-rose-300' : 'text-white/85'}`}>
|
| 422 |
+
{children}
|
| 423 |
+
</button>
|
| 424 |
+
);
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
export default MeetingsHome;
|
frontend/src/ui/meetingsense/library/PresentMode.tsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Present mode: the Executive Brief as full-screen slides, one section per slide, from the
|
| 3 |
+
* same validated brief the page shows. Arrow keys and Space move, Escape leaves, and the
|
| 4 |
+
* counter says where you are. Nothing is re-generated for presenting.
|
| 5 |
+
*/
|
| 6 |
+
import React, { useEffect, useMemo, useState } from 'react';
|
| 7 |
+
import { ChevronLeft, ChevronRight, Maximize2, X } from 'lucide-react';
|
| 8 |
+
import { ExecutiveBriefView, type BriefSection } from './meeting-summary/ExecutiveBrief';
|
| 9 |
+
import type { ExecutiveBrief, MeetingAnalysis } from './intelligenceApi';
|
| 10 |
+
|
| 11 |
+
export function slidesFor(brief: ExecutiveBrief): BriefSection[][] {
|
| 12 |
+
const slides: BriefSection[][] = [['header']];
|
| 13 |
+
if (brief.risks.length || brief.blockers.length) slides.push(['risks']);
|
| 14 |
+
if (brief.decisions.length) slides.push(['decisions']);
|
| 15 |
+
if (brief.actions.length) slides.push(['actions']);
|
| 16 |
+
if (brief.open_questions.length) slides.push(['questions']);
|
| 17 |
+
if (brief.takeaway) slides.push(['takeaway', 'footer']);
|
| 18 |
+
return slides;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function PresentMode({ analysis, brief, title, onClose, onEvidence }: {
|
| 22 |
+
analysis: MeetingAnalysis; brief: ExecutiveBrief; title: string; onClose: () => void; onEvidence?: (ms: number, itemId: string) => void;
|
| 23 |
+
}) {
|
| 24 |
+
const slides = useMemo(() => slidesFor(brief), [brief]);
|
| 25 |
+
const [index, setIndex] = useState(0);
|
| 26 |
+
const last = slides.length - 1;
|
| 27 |
+
|
| 28 |
+
useEffect(() => {
|
| 29 |
+
const onKey = (e: KeyboardEvent) => {
|
| 30 |
+
if (e.key === 'Escape') onClose();
|
| 31 |
+
if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); setIndex((i) => Math.min(last, i + 1)); }
|
| 32 |
+
if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); setIndex((i) => Math.max(0, i - 1)); }
|
| 33 |
+
if (e.key === 'Home') setIndex(0);
|
| 34 |
+
if (e.key === 'End') setIndex(last);
|
| 35 |
+
};
|
| 36 |
+
document.addEventListener('keydown', onKey);
|
| 37 |
+
return () => document.removeEventListener('keydown', onKey);
|
| 38 |
+
}, [last, onClose]);
|
| 39 |
+
|
| 40 |
+
const fullscreen = () => { void document.documentElement.requestFullscreen?.().catch(() => undefined); };
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<div className="fixed inset-0 z-[80] flex flex-col bg-[#07070a] text-white" role="dialog" aria-modal="true" aria-label={`Presenting ${title}`} data-testid="present-mode">
|
| 44 |
+
<header className="flex items-center gap-3 px-5 py-3 text-sm text-white/70">
|
| 45 |
+
<span className="truncate font-medium text-white/90">{title}</span>
|
| 46 |
+
<span className="ml-auto tabular-nums" data-testid="present-counter">{index + 1} / {slides.length}</span>
|
| 47 |
+
<button type="button" onClick={fullscreen} aria-label="Full screen" className="grid h-9 w-9 place-items-center rounded-full hover:bg-white/10"><Maximize2 size={16} /></button>
|
| 48 |
+
<button type="button" onClick={onClose} aria-label="Exit presentation" className="grid h-9 w-9 place-items-center rounded-full hover:bg-white/10"><X size={16} /></button>
|
| 49 |
+
</header>
|
| 50 |
+
<div className="flex min-h-0 flex-1 items-center justify-center px-6 pb-6 sm:px-14">
|
| 51 |
+
<button type="button" onClick={() => setIndex((i) => Math.max(0, i - 1))} disabled={index === 0} aria-label="Previous slide"
|
| 52 |
+
className="mr-4 hidden h-12 w-12 shrink-0 place-items-center rounded-full border border-white/10 hover:bg-white/10 disabled:opacity-30 sm:grid"><ChevronLeft /></button>
|
| 53 |
+
<div className="hp-present-slide max-h-full w-full max-w-5xl overflow-y-auto">
|
| 54 |
+
<ExecutiveBriefView analysis={analysis} brief={brief} only={slides[index]} onEvidence={onEvidence} className="text-[1.05rem]" />
|
| 55 |
+
</div>
|
| 56 |
+
<button type="button" onClick={() => setIndex((i) => Math.min(last, i + 1))} disabled={index === last} aria-label="Next slide"
|
| 57 |
+
className="ml-4 hidden h-12 w-12 shrink-0 place-items-center rounded-full border border-white/10 hover:bg-white/10 disabled:opacity-30 sm:grid"><ChevronRight /></button>
|
| 58 |
+
</div>
|
| 59 |
+
<div className="flex items-center justify-center gap-1.5 pb-4" aria-hidden>
|
| 60 |
+
{slides.map((_, i) => <span key={i} className={`h-1.5 rounded-full transition-all ${i === index ? 'w-6 bg-violet-400' : 'w-1.5 bg-white/25'}`} />)}
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export default PresentMode;
|
frontend/src/ui/meetingsense/library/ProcessingPanel.tsx
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Processing — what is being transcribed, indexed and summarised, with progress, and the
|
| 3 |
+
* reason when something is waiting (no local speech model) or failed.
|
| 4 |
+
*/
|
| 5 |
+
import React, { useCallback, useEffect, useState } from 'react';
|
| 6 |
+
import { AlertTriangle, Loader2, RotateCcw, X } from 'lucide-react';
|
| 7 |
+
import { LibraryError, libraryApi, type LibraryJob } from './api';
|
| 8 |
+
|
| 9 |
+
const TYPE_LABEL: Record<string, string> = { transcribe: 'Transcription', index: 'Search index', summarize: 'Summary' };
|
| 10 |
+
|
| 11 |
+
export function ProcessingPanel({ onChanged, meetingId }: { onChanged?: () => void; meetingId?: string }) {
|
| 12 |
+
const [jobs, setJobs] = useState<LibraryJob[]>([]);
|
| 13 |
+
const [stt, setStt] = useState<{ available: boolean; hint?: string | null } | null>(null);
|
| 14 |
+
const [error, setError] = useState<string | null>(null);
|
| 15 |
+
|
| 16 |
+
const load = useCallback(async () => {
|
| 17 |
+
try {
|
| 18 |
+
const body = await libraryApi.jobs({ active: !meetingId, meeting_id: meetingId });
|
| 19 |
+
setJobs(body.jobs);
|
| 20 |
+
setStt(body.stt);
|
| 21 |
+
setError(null);
|
| 22 |
+
} catch (e) {
|
| 23 |
+
setError(e instanceof LibraryError ? e.message : 'Processing status is unavailable.');
|
| 24 |
+
}
|
| 25 |
+
}, [meetingId]);
|
| 26 |
+
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
void load();
|
| 29 |
+
const t = setInterval(() => void load(), 3000);
|
| 30 |
+
return () => clearInterval(t);
|
| 31 |
+
}, [load]);
|
| 32 |
+
|
| 33 |
+
const act = async (fn: () => Promise<unknown>) => {
|
| 34 |
+
try { await fn(); } catch (e) { setError(e instanceof LibraryError ? e.message : 'That did not work.'); }
|
| 35 |
+
await load();
|
| 36 |
+
onChanged?.();
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const waitingForSetup = jobs.some((j) => j.state === 'needs_setup');
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<section className="mb-5 rounded-2xl border border-white/10 bg-white/[0.02] p-4" aria-label="Processing" data-testid="processing-panel">
|
| 43 |
+
<h2 className="text-sm font-semibold">Processing</h2>
|
| 44 |
+
{waitingForSetup && stt && !stt.available ? (
|
| 45 |
+
<p className="mt-2 flex gap-2 rounded-xl border border-amber-400/30 bg-amber-500/10 p-3 text-sm text-amber-100">
|
| 46 |
+
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
|
| 47 |
+
<span>Recordings are stored and waiting: no local speech-to-text model is installed. {stt.hint || ''} Then press Retry.</span>
|
| 48 |
+
</p>
|
| 49 |
+
) : null}
|
| 50 |
+
{error ? <p className="mt-2 text-sm text-rose-200">{error}</p> : null}
|
| 51 |
+
{jobs.length === 0 ? (
|
| 52 |
+
<p className="mt-2 text-sm text-white/50">Nothing is being processed.</p>
|
| 53 |
+
) : (
|
| 54 |
+
<ul className="mt-3 space-y-2">
|
| 55 |
+
{jobs.map((job) => (
|
| 56 |
+
<li key={job.id} className="rounded-xl border border-white/[0.08] bg-black/40 p-3">
|
| 57 |
+
<div className="flex flex-wrap items-center gap-2 text-sm">
|
| 58 |
+
<span className="font-medium">{TYPE_LABEL[job.type] || job.type}</span>
|
| 59 |
+
<span className="truncate text-white/50">· {job.meeting_title || 'Meeting'}</span>
|
| 60 |
+
<span className="ml-auto text-xs text-white/50">{stateLabel(job)}</span>
|
| 61 |
+
{job.state === 'queued' || job.state === 'running' ? (
|
| 62 |
+
<button type="button" onClick={() => void act(() => libraryApi.cancelJob(job.id))} aria-label="Cancel"
|
| 63 |
+
className="grid h-7 w-7 place-items-center rounded-lg text-white/50 hover:bg-white/10 hover:text-white"><X size={14} /></button>
|
| 64 |
+
) : (
|
| 65 |
+
<button type="button" onClick={() => void act(() => libraryApi.retryJob(job.id))}
|
| 66 |
+
className="inline-flex h-7 items-center gap-1 rounded-lg border border-white/10 px-2 text-xs hover:bg-white/10"><RotateCcw size={12} /> Retry</button>
|
| 67 |
+
)}
|
| 68 |
+
</div>
|
| 69 |
+
{job.state === 'running' || job.state === 'queued' ? (
|
| 70 |
+
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white/10" role="progressbar"
|
| 71 |
+
aria-valuenow={Math.round((job.progress || 0) * 100)} aria-valuemin={0} aria-valuemax={100}>
|
| 72 |
+
<div className="h-full bg-sky-400 transition-[width] duration-500" style={{ width: `${Math.max(3, Math.round((job.progress || 0) * 100))}%` }} />
|
| 73 |
+
</div>
|
| 74 |
+
) : null}
|
| 75 |
+
{job.error && job.state !== 'running' ? <p className="mt-1 text-xs text-amber-200/90">{job.error}</p> : null}
|
| 76 |
+
</li>
|
| 77 |
+
))}
|
| 78 |
+
</ul>
|
| 79 |
+
)}
|
| 80 |
+
</section>
|
| 81 |
+
);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function stateLabel(job: LibraryJob): React.ReactNode {
|
| 85 |
+
switch (job.state) {
|
| 86 |
+
case 'running':
|
| 87 |
+
return <span className="inline-flex items-center gap-1"><Loader2 size={12} className="animate-spin" /> {job.stage || 'Working'} · {Math.round((job.progress || 0) * 100)}%</span>;
|
| 88 |
+
case 'queued':
|
| 89 |
+
return 'Queued';
|
| 90 |
+
case 'needs_setup':
|
| 91 |
+
return 'Needs speech-to-text setup';
|
| 92 |
+
case 'failed':
|
| 93 |
+
return 'Failed';
|
| 94 |
+
case 'cancelled':
|
| 95 |
+
return 'Cancelled';
|
| 96 |
+
default:
|
| 97 |
+
return 'Done';
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
export default ProcessingPanel;
|
frontend/src/ui/meetingsense/library/StartMeetingWizard.tsx
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Start a meeting — four short steps instead of one long form: Setup, Recording, Assistant,
|
| 3 |
+
* Summary. Every setting is MeetingSense's own (capture sources, assistant roles, summary
|
| 4 |
+
* style and length, consent); the wizard only orders them. Back and Next keep everything
|
| 5 |
+
* entered, and nothing records until "Start meeting" — with the consent line ticked.
|
| 6 |
+
*
|
| 7 |
+
* "Now" + HomePilot Live starts the existing live recorder (title and project included).
|
| 8 |
+
* "Schedule for later" or an external recorder creates the meeting in the library, where its
|
| 9 |
+
* recording arrives later by upload or folder sync.
|
| 10 |
+
*/
|
| 11 |
+
import React, { useMemo, useState } from 'react';
|
| 12 |
+
import {
|
| 13 |
+
ArrowLeft, ArrowRight, AudioLines, CalendarClock, CalendarDays, Check, Clock3, FileText, Folder, HardDrive,
|
| 14 |
+
Lightbulb, Mic, Mic2, MonitorUp, Radio, ShieldCheck, Sparkles, Users, X,
|
| 15 |
+
} from 'lucide-react';
|
| 16 |
+
import { ModalSheet } from '../../components/ModalSheet';
|
| 17 |
+
import { DEFAULT_CAPTURE, MODES, type CaptureOptions } from '../CapturePopover';
|
| 18 |
+
import { SUMMARY_LENGTHS } from '../MeetingMinutes';
|
| 19 |
+
import { consentSentences } from '../meetingState';
|
| 20 |
+
import { useMeetingControls } from '../MeetingSenseProvider';
|
| 21 |
+
import { LibraryError, libraryApi } from './api';
|
| 22 |
+
|
| 23 |
+
type When = 'now' | 'later';
|
| 24 |
+
type Plan = 'live' | 'external' | 'hybrid';
|
| 25 |
+
|
| 26 |
+
const STEPS = [
|
| 27 |
+
{ title: 'Setup', note: 'Basic information' },
|
| 28 |
+
{ title: 'Recording', note: 'Choose source' },
|
| 29 |
+
{ title: 'Assistant', note: 'Meeting support' },
|
| 30 |
+
{ title: 'Summary', note: 'Output preferences' },
|
| 31 |
+
];
|
| 32 |
+
|
| 33 |
+
const OUTPUTS = [
|
| 34 |
+
{ id: 'minutes', label: 'Meeting minutes' },
|
| 35 |
+
{ id: 'transcript', label: 'Full transcript' },
|
| 36 |
+
{ id: 'summary', label: 'Meeting summary' },
|
| 37 |
+
{ id: 'brief', label: 'Executive brief' },
|
| 38 |
+
{ id: 'actions', label: 'Action items' },
|
| 39 |
+
];
|
| 40 |
+
|
| 41 |
+
/** The document a meeting writes when it ends, from the outputs ticked (first match wins). */
|
| 42 |
+
export function summaryStyleFor(outputs: string[]): string {
|
| 43 |
+
if (outputs.includes('minutes')) return 'minutes';
|
| 44 |
+
if (outputs.includes('brief')) return 'brief';
|
| 45 |
+
if (outputs.includes('summary')) return 'notes';
|
| 46 |
+
if (outputs.includes('actions')) return 'actions';
|
| 47 |
+
return 'minutes';
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function localInputValue(ts: number): string {
|
| 51 |
+
const d = new Date(ts);
|
| 52 |
+
const pad = (n: number) => String(n).padStart(2, '0');
|
| 53 |
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export function StartMeetingWizard({ onClose, onCreated, canRecordLive = true }: {
|
| 57 |
+
onClose: () => void; onCreated: (message: string) => void; canRecordLive?: boolean;
|
| 58 |
+
}) {
|
| 59 |
+
const controls = useMeetingControls();
|
| 60 |
+
const liveAvailable = canRecordLive && Boolean(controls?.status?.enabled) && !controls?.live;
|
| 61 |
+
const [step, setStep] = useState(0);
|
| 62 |
+
const [title, setTitle] = useState('');
|
| 63 |
+
const [projects, setProjects] = useState<Array<{ id: string; name: string }>>([]);
|
| 64 |
+
const [projectId, setProjectId] = useState('');
|
| 65 |
+
const [showInProject, setShowInProject] = useState(true);
|
| 66 |
+
const [when, setWhen] = useState<When>('now');
|
| 67 |
+
const [at, setAt] = useState(() => localInputValue(Date.now() + 60 * 60 * 1000));
|
| 68 |
+
const [plan, setPlan] = useState<Plan>(liveAvailable ? 'live' : 'external');
|
| 69 |
+
const [capture, setCapture] = useState<CaptureOptions>(() => ({ ...DEFAULT_CAPTURE, ...(controls?.capture || {}) }));
|
| 70 |
+
const [outputs, setOutputs] = useState<string[]>(['minutes', 'transcript', 'actions']);
|
| 71 |
+
const [length, setLength] = useState(controls?.capture?.summaryLength || 'standard');
|
| 72 |
+
const [consent, setConsent] = useState(false);
|
| 73 |
+
const [busy, setBusy] = useState(false);
|
| 74 |
+
const [error, setError] = useState<string | null>(null);
|
| 75 |
+
|
| 76 |
+
React.useEffect(() => { void libraryApi.projects().then(setProjects); }, []);
|
| 77 |
+
|
| 78 |
+
const projectName = projects.find((p) => p.id === projectId)?.name;
|
| 79 |
+
const startsLive = when === 'now' && (plan === 'live' || plan === 'hybrid');
|
| 80 |
+
const privacy = useMemo(() => consentSentences({ ...(controls?.status || {}), mode: capture.mode } as any),
|
| 81 |
+
[controls?.status, capture.mode]);
|
| 82 |
+
const local = !controls?.status?.stt?.remote;
|
| 83 |
+
const scheduledTs = when === 'now' ? Date.now() : new Date(at).getTime();
|
| 84 |
+
|
| 85 |
+
const stepValid = (i: number): string | null => {
|
| 86 |
+
if (i === 0) {
|
| 87 |
+
if (!title.trim()) return 'Give the meeting a title.';
|
| 88 |
+
if (when === 'later' && !(scheduledTs > 0)) return 'Choose when the meeting is.';
|
| 89 |
+
}
|
| 90 |
+
if (i === 1 && startsLive && !liveAvailable) return 'Live recording is not available here — choose External Recorder.';
|
| 91 |
+
return null;
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
const next = () => {
|
| 95 |
+
const problem = stepValid(step);
|
| 96 |
+
if (problem) { setError(problem); return; }
|
| 97 |
+
setError(null);
|
| 98 |
+
setStep((s) => Math.min(STEPS.length - 1, s + 1));
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
const finish = async () => {
|
| 102 |
+
for (let i = 0; i < STEPS.length; i += 1) {
|
| 103 |
+
const problem = stepValid(i);
|
| 104 |
+
if (problem) { setError(problem); setStep(i); return; }
|
| 105 |
+
}
|
| 106 |
+
if (startsLive && !consent) { setError('Confirm that you may record this meeting.'); return; }
|
| 107 |
+
setBusy(true);
|
| 108 |
+
setError(null);
|
| 109 |
+
const style = summaryStyleFor(outputs);
|
| 110 |
+
try {
|
| 111 |
+
if (startsLive && controls?.startWith) {
|
| 112 |
+
const result = await controls.startWith(
|
| 113 |
+
{ ...capture, summaryStyle: style, summaryLength: length },
|
| 114 |
+
{ title: title.trim(), projectId: showInProject && projectId ? projectId : undefined },
|
| 115 |
+
true,
|
| 116 |
+
);
|
| 117 |
+
if (!result.ok) throw new Error(result.error || 'The meeting could not start.');
|
| 118 |
+
onClose();
|
| 119 |
+
return;
|
| 120 |
+
}
|
| 121 |
+
await libraryApi.createScheduled({
|
| 122 |
+
title: title.trim(),
|
| 123 |
+
project_id: showInProject && projectId ? projectId : '',
|
| 124 |
+
scheduled_for: Math.round(scheduledTs / 1000),
|
| 125 |
+
recording_plan: plan === 'live' ? 'later' : plan,
|
| 126 |
+
category: projectId && showInProject ? 'Project Meeting' : 'Meeting',
|
| 127 |
+
description: capture.context.trim() || undefined,
|
| 128 |
+
outputs: {
|
| 129 |
+
selected: outputs, style, length, summary: outputs.some((o) => o !== 'transcript'),
|
| 130 |
+
actions: outputs.includes('actions'), assistant: capture.mode || 'note-taker',
|
| 131 |
+
},
|
| 132 |
+
});
|
| 133 |
+
onCreated(when === 'later' ? `Scheduled "${title.trim()}". Its recording can be added by upload or folder sync.`
|
| 134 |
+
: `Created "${title.trim()}". It is waiting for the external recording.`);
|
| 135 |
+
} catch (e) {
|
| 136 |
+
setError(e instanceof LibraryError || e instanceof Error ? e.message : 'The meeting could not be created.');
|
| 137 |
+
} finally {
|
| 138 |
+
setBusy(false);
|
| 139 |
+
}
|
| 140 |
+
};
|
| 141 |
+
|
| 142 |
+
const last = step === STEPS.length - 1;
|
| 143 |
+
const finishLabel = startsLive ? 'Start meeting' : (when === 'later' ? 'Schedule meeting' : 'Create meeting');
|
| 144 |
+
|
| 145 |
+
return (
|
| 146 |
+
<ModalSheet
|
| 147 |
+
labelledBy="hp-wizard-title"
|
| 148 |
+
onRequestClose={() => { if (!busy) onClose(); }}
|
| 149 |
+
className="hp-sheet--xl"
|
| 150 |
+
header={(
|
| 151 |
+
<div className="border-b border-white/[0.06]">
|
| 152 |
+
<div className="flex items-start gap-4 px-5 pt-5 sm:px-7">
|
| 153 |
+
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl border border-violet-400/30 bg-violet-500/15 text-violet-200 sm:h-14 sm:w-14"><Mic size={24} /></div>
|
| 154 |
+
<div className="min-w-0 flex-1">
|
| 155 |
+
<h2 id="hp-wizard-title" className="text-xl font-semibold sm:text-2xl">Start a meeting</h2>
|
| 156 |
+
<p className="mt-0.5 text-sm text-white/60">Set up your meeting and let HomePilot capture, sync and understand it.</p>
|
| 157 |
+
</div>
|
| 158 |
+
{local ? (
|
| 159 |
+
<span className="hidden items-center gap-1.5 rounded-full border border-emerald-400/40 bg-emerald-500/10 px-3 py-1 text-[11px] font-semibold tracking-wide text-emerald-300 sm:inline-flex">
|
| 160 |
+
<HardDrive size={12} /> PROCESSED LOCALLY
|
| 161 |
+
</span>
|
| 162 |
+
) : null}
|
| 163 |
+
<button type="button" onClick={onClose} aria-label="Close" className="grid h-10 w-10 shrink-0 place-items-center rounded-full border border-white/10 text-white/70 hover:bg-white/10"><X size={18} /></button>
|
| 164 |
+
</div>
|
| 165 |
+
<ol className="flex gap-2 overflow-x-auto px-5 py-4 scrollbar-hide sm:px-7" aria-label="Steps">
|
| 166 |
+
{STEPS.map((s, i) => (
|
| 167 |
+
<li key={s.title} className="flex min-w-0 flex-1 items-center gap-2">
|
| 168 |
+
<button type="button" onClick={() => (i < step ? setStep(i) : undefined)} disabled={i > step}
|
| 169 |
+
aria-current={i === step ? 'step' : undefined}
|
| 170 |
+
className={`grid h-9 w-9 shrink-0 place-items-center rounded-full border text-sm font-semibold ${
|
| 171 |
+
i === step ? 'border-violet-400 bg-violet-600 text-white shadow-[0_0_0_4px_rgba(139,92,246,0.2)]'
|
| 172 |
+
: i < step ? 'border-violet-400/50 bg-violet-500/20 text-violet-100' : 'border-white/15 text-white/60'}`}>
|
| 173 |
+
{i < step ? <Check size={15} /> : i + 1}
|
| 174 |
+
</button>
|
| 175 |
+
<span className="hidden min-w-0 sm:block">
|
| 176 |
+
<span className={`block truncate text-sm font-semibold ${i === step ? 'text-white' : 'text-white/80'}`}>{s.title}</span>
|
| 177 |
+
<span className={`block truncate text-xs ${i === step ? 'text-violet-300' : 'text-white/45'}`}>{s.note}</span>
|
| 178 |
+
</span>
|
| 179 |
+
{i < STEPS.length - 1 ? <span className="hidden h-px flex-1 bg-white/10 lg:block" aria-hidden /> : null}
|
| 180 |
+
</li>
|
| 181 |
+
))}
|
| 182 |
+
</ol>
|
| 183 |
+
</div>
|
| 184 |
+
)}
|
| 185 |
+
footer={(
|
| 186 |
+
<div className="flex items-center gap-3 border-t border-white/[0.06] px-5 py-3 sm:px-7">
|
| 187 |
+
<button type="button" onClick={onClose} className="h-11 rounded-xl border border-white/15 px-5 text-sm font-semibold hover:bg-white/10">Cancel</button>
|
| 188 |
+
<span className="flex-1 truncate text-sm text-rose-200" role="alert">{error}</span>
|
| 189 |
+
{step > 0 ? (
|
| 190 |
+
<button type="button" onClick={() => { setError(null); setStep((s) => s - 1); }}
|
| 191 |
+
className="inline-flex h-11 items-center gap-2 rounded-xl border border-white/15 px-4 text-sm font-semibold hover:bg-white/10"><ArrowLeft size={15} /> Back</button>
|
| 192 |
+
) : null}
|
| 193 |
+
{last ? (
|
| 194 |
+
<button type="button" onClick={() => void finish()} disabled={busy}
|
| 195 |
+
className="inline-flex h-11 items-center gap-2 rounded-xl bg-violet-600 px-5 text-sm font-semibold shadow-lg shadow-violet-900/40 hover:bg-violet-500 disabled:opacity-50">
|
| 196 |
+
{startsLive ? <Radio size={15} /> : <CalendarClock size={15} />} {busy ? 'Working…' : finishLabel}
|
| 197 |
+
</button>
|
| 198 |
+
) : (
|
| 199 |
+
<button type="button" onClick={next}
|
| 200 |
+
className="inline-flex h-11 items-center gap-2 rounded-xl bg-violet-600 px-5 text-sm font-semibold shadow-lg shadow-violet-900/40 hover:bg-violet-500">
|
| 201 |
+
Next: {step === 0 ? 'Recording source' : step === 1 ? 'Assistant' : 'Summary'} <ArrowRight size={15} />
|
| 202 |
+
</button>
|
| 203 |
+
)}
|
| 204 |
+
</div>
|
| 205 |
+
)}
|
| 206 |
+
>
|
| 207 |
+
<div className="grid gap-4 px-5 py-5 sm:px-7 lg:grid-cols-[minmax(0,1fr)_340px]">
|
| 208 |
+
<div className="min-w-0 rounded-2xl border border-white/10 bg-white/[0.02] p-5">
|
| 209 |
+
{step === 0 ? (
|
| 210 |
+
<section aria-labelledby="hp-wz-s1">
|
| 211 |
+
<SectionTitle id="hp-wz-s1" icon={<FileText size={18} />} title="Meeting details" note="Give your meeting a title and optionally link it to a project." />
|
| 212 |
+
<label className="mt-4 block text-sm text-white/75">Meeting title
|
| 213 |
+
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Project planning meeting" autoFocus
|
| 214 |
+
className="mt-1.5 h-12 w-full rounded-xl border border-white/10 bg-white/[0.03] px-4 text-base text-white placeholder-white/30 focus:border-violet-400/70 focus:outline-none" />
|
| 215 |
+
</label>
|
| 216 |
+
<div className="mt-4 grid gap-3 sm:grid-cols-[180px_minmax(0,1fr)] sm:items-start">
|
| 217 |
+
<span className="pt-2.5 text-sm text-white/75">Link to project (optional)</span>
|
| 218 |
+
<div>
|
| 219 |
+
<select value={projectId} onChange={(e) => setProjectId(e.target.value)} aria-label="Project"
|
| 220 |
+
className="h-12 w-full rounded-xl border border-white/10 bg-white/[0.03] px-3 text-sm text-white focus:border-violet-400/70 focus:outline-none">
|
| 221 |
+
<option value="">No project</option>
|
| 222 |
+
{projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
| 223 |
+
</select>
|
| 224 |
+
{projectId ? (
|
| 225 |
+
<label className="mt-3 flex cursor-pointer items-start gap-3">
|
| 226 |
+
<input type="checkbox" role="switch" checked={showInProject} onChange={(e) => setShowInProject(e.target.checked)}
|
| 227 |
+
className="peer sr-only" />
|
| 228 |
+
<span className="relative mt-0.5 h-6 w-11 shrink-0 rounded-full bg-white/15 transition-colors peer-checked:bg-violet-600 peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-violet-300 after:absolute after:left-0.5 after:top-0.5 after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-transform peer-checked:after:translate-x-5" aria-hidden />
|
| 229 |
+
<span className="text-sm text-white/85">Also show this meeting in the project
|
| 230 |
+
<span className="block text-xs text-white/45">The meeting will appear in both the project and your Meetings library.</span></span>
|
| 231 |
+
</label>
|
| 232 |
+
) : null}
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
<SectionTitle icon={<CalendarDays size={18} />} title="When is the meeting?" note="This helps HomePilot match recordings and organize your meetings." className="mt-6" />
|
| 236 |
+
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
| 237 |
+
<ChoiceCard selected={when === 'now'} onClick={() => setWhen('now')} icon={<CalendarDays size={18} />} title="Now" note="Start recording now" />
|
| 238 |
+
<ChoiceCard selected={when === 'later'} onClick={() => setWhen('later')} icon={<CalendarClock size={18} />} title="Schedule for later" note="Create the meeting and add audio later" />
|
| 239 |
+
</div>
|
| 240 |
+
{when === 'later' ? (
|
| 241 |
+
<label className="mt-3 block text-sm text-white/75">Date and time
|
| 242 |
+
<input type="datetime-local" value={at} onChange={(e) => setAt(e.target.value)}
|
| 243 |
+
className="mt-1.5 h-11 w-full rounded-xl border border-white/10 bg-white/[0.03] px-3 text-sm text-white [color-scheme:dark] focus:border-violet-400/70 focus:outline-none" />
|
| 244 |
+
</label>
|
| 245 |
+
) : null}
|
| 246 |
+
<Tip>You can always add or change the project, title and other details later.</Tip>
|
| 247 |
+
</section>
|
| 248 |
+
) : null}
|
| 249 |
+
|
| 250 |
+
{step === 1 ? (
|
| 251 |
+
<section aria-labelledby="hp-wz-s2">
|
| 252 |
+
<SectionTitle id="hp-wz-s2" icon={<AudioLines size={18} />} title="Recording source" note="How the audio of this meeting reaches HomePilot." />
|
| 253 |
+
<div className="mt-4 grid gap-3">
|
| 254 |
+
<ChoiceCard selected={plan === 'live'} onClick={() => setPlan('live')} disabled={!liveAvailable && when === 'now'}
|
| 255 |
+
icon={<Mic2 size={18} />} title="HomePilot Live"
|
| 256 |
+
note={liveAvailable || when === 'later' ? 'Use HomePilot’s microphone, meeting audio and optional screen capture.' : 'Not available: MeetingSense is off here, or a meeting is already recording.'} />
|
| 257 |
+
<ChoiceCard selected={plan === 'external'} onClick={() => setPlan('external')} icon={<HardDrive size={18} />} title="External Recorder"
|
| 258 |
+
note="Insta360 or another recorder. HomePilot waits for the audio from Folder Sync or a later upload, and matches it by time." />
|
| 259 |
+
<ChoiceCard selected={plan === 'hybrid'} onClick={() => setPlan('hybrid')} disabled={!liveAvailable && when === 'now'} icon={<Radio size={18} />} title="Hybrid Recording"
|
| 260 |
+
note="Record in HomePilot now and also bring in an external device’s file later (imported as its own recording)." />
|
| 261 |
+
</div>
|
| 262 |
+
{plan !== 'external' ? (
|
| 263 |
+
<fieldset className="mt-5">
|
| 264 |
+
<legend className="text-sm font-semibold text-white/85">Capture</legend>
|
| 265 |
+
<div className="mt-2 grid gap-2 sm:grid-cols-3">
|
| 266 |
+
{([['audio', 'Meeting audio', AudioLines], ['mic', 'My microphone', Mic2], ['slides', 'Screen and slides', MonitorUp]] as const).map(([key, label, Icon]) => (
|
| 267 |
+
<label key={key} className={`flex cursor-pointer items-center gap-2 rounded-xl border px-3 py-2.5 text-sm ${capture[key] ? 'border-violet-400/50 bg-violet-500/10' : 'border-white/10 bg-white/[0.02]'}`}>
|
| 268 |
+
<input type="checkbox" checked={capture[key]} onChange={(e) => setCapture({ ...capture, [key]: e.target.checked })} className="accent-violet-500" />
|
| 269 |
+
<Icon size={15} className="text-white/60" /> {label}
|
| 270 |
+
</label>
|
| 271 |
+
))}
|
| 272 |
+
</div>
|
| 273 |
+
</fieldset>
|
| 274 |
+
) : (
|
| 275 |
+
<Tip>External recordings have no live transcript. When the audio arrives, HomePilot transcribes it on this computer and writes the outputs you choose.</Tip>
|
| 276 |
+
)}
|
| 277 |
+
{startsLive ? (
|
| 278 |
+
<div className="mt-5 rounded-xl border border-white/10 bg-black/30 p-3">
|
| 279 |
+
<p className="flex items-center gap-2 text-sm font-semibold"><ShieldCheck size={15} className="text-emerald-300" /> Before you record</p>
|
| 280 |
+
<ul className="mt-2 space-y-1 text-xs text-white/60">{privacy.map((line) => <li key={line}>• {line}</li>)}</ul>
|
| 281 |
+
<label className="mt-3 flex items-start gap-2 text-sm">
|
| 282 |
+
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} className="mt-0.5 accent-violet-500" />
|
| 283 |
+
<span>I have permission to record this meeting, and the people in it know.</span>
|
| 284 |
+
</label>
|
| 285 |
+
</div>
|
| 286 |
+
) : null}
|
| 287 |
+
</section>
|
| 288 |
+
) : null}
|
| 289 |
+
|
| 290 |
+
{step === 2 ? (
|
| 291 |
+
<section aria-labelledby="hp-wz-s3">
|
| 292 |
+
<SectionTitle id="hp-wz-s3" icon={<Sparkles size={18} />} title="Choose an assistant" note="What HomePilot does during the meeting." />
|
| 293 |
+
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
| 294 |
+
{MODES.map((m) => {
|
| 295 |
+
const liveOnly = m.id !== null;
|
| 296 |
+
const disabled = liveOnly && !startsLive;
|
| 297 |
+
return (
|
| 298 |
+
<ChoiceCard key={m.label} selected={capture.mode === m.id} disabled={disabled}
|
| 299 |
+
onClick={() => setCapture({ ...capture, mode: m.id })} title={m.label}
|
| 300 |
+
note={disabled ? 'Needs live recording — available when the meeting is recorded in HomePilot.' : m.note} />
|
| 301 |
+
);
|
| 302 |
+
})}
|
| 303 |
+
</div>
|
| 304 |
+
{capture.mode === 'participant' || capture.mode === 'presenter' || capture.mode === 'coach' ? (
|
| 305 |
+
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
| 306 |
+
<label className="text-sm text-white/75">What people call you
|
| 307 |
+
<input value={capture.myNames} onChange={(e) => setCapture({ ...capture, myNames: e.target.value })} placeholder="Ruslan, Rus"
|
| 308 |
+
className="mt-1.5 h-10 w-full rounded-xl border border-white/10 bg-white/[0.03] px-3 text-sm focus:border-violet-400/70 focus:outline-none" />
|
| 309 |
+
</label>
|
| 310 |
+
<label className="text-sm text-white/75">Assistant name
|
| 311 |
+
<input value={capture.assistantName} onChange={(e) => setCapture({ ...capture, assistantName: e.target.value })} placeholder="HomePilot"
|
| 312 |
+
className="mt-1.5 h-10 w-full rounded-xl border border-white/10 bg-white/[0.03] px-3 text-sm focus:border-violet-400/70 focus:outline-none" />
|
| 313 |
+
</label>
|
| 314 |
+
</div>
|
| 315 |
+
) : null}
|
| 316 |
+
<label className="mt-4 block text-sm text-white/75">Meeting context <span className="text-white/40">(optional)</span>
|
| 317 |
+
<textarea value={capture.context} onChange={(e) => setCapture({ ...capture, context: e.target.value })} rows={4}
|
| 318 |
+
placeholder="Agenda, goals, last week's minutes…"
|
| 319 |
+
className="mt-1.5 w-full rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-sm focus:border-violet-400/70 focus:outline-none" />
|
| 320 |
+
</label>
|
| 321 |
+
</section>
|
| 322 |
+
) : null}
|
| 323 |
+
|
| 324 |
+
{step === 3 ? (
|
| 325 |
+
<section aria-labelledby="hp-wz-s4">
|
| 326 |
+
<SectionTitle id="hp-wz-s4" icon={<FileText size={18} />} title="Generate after the meeting" note={plan === 'external' ? 'Written once the recording has been transcribed.' : 'Written when the meeting ends.'} />
|
| 327 |
+
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
| 328 |
+
{OUTPUTS.map((o) => (
|
| 329 |
+
<label key={o.id} className={`flex cursor-pointer items-center gap-2 rounded-xl border px-3 py-2.5 text-sm ${outputs.includes(o.id) ? 'border-violet-400/50 bg-violet-500/10' : 'border-white/10 bg-white/[0.02]'}`}>
|
| 330 |
+
<input type="checkbox" checked={outputs.includes(o.id)} disabled={o.id === 'transcript'}
|
| 331 |
+
onChange={(e) => setOutputs((cur) => e.target.checked ? [...cur, o.id] : cur.filter((x) => x !== o.id))}
|
| 332 |
+
className="accent-violet-500" />
|
| 333 |
+
{o.label}
|
| 334 |
+
</label>
|
| 335 |
+
))}
|
| 336 |
+
</div>
|
| 337 |
+
<p className="mt-5 text-sm font-semibold text-white/85">Summary length</p>
|
| 338 |
+
<div className="mt-2 inline-flex rounded-xl border border-white/10 bg-white/[0.02] p-1" role="radiogroup" aria-label="Summary length">
|
| 339 |
+
{SUMMARY_LENGTHS.map((l) => (
|
| 340 |
+
<button key={l.id} type="button" role="radio" aria-checked={length === l.id} onClick={() => setLength(l.id)}
|
| 341 |
+
className={`rounded-lg px-4 py-1.5 text-sm ${length === l.id ? 'bg-violet-600 font-semibold text-white' : 'text-white/70 hover:text-white'}`}>{l.label}</button>
|
| 342 |
+
))}
|
| 343 |
+
</div>
|
| 344 |
+
<Tip>Summaries are written by your local model when it is available; the transcript is always kept.</Tip>
|
| 345 |
+
</section>
|
| 346 |
+
) : null}
|
| 347 |
+
</div>
|
| 348 |
+
|
| 349 |
+
<aside className="rounded-2xl border border-white/10 bg-white/[0.02] p-5" aria-label="Meeting preview">
|
| 350 |
+
<h3 className="flex items-center gap-2 text-sm font-semibold"><CalendarDays size={16} className="text-violet-300" /> Meeting preview</h3>
|
| 351 |
+
<div className="mt-3 rounded-xl border border-white/10 bg-black/30 p-4">
|
| 352 |
+
<p className="text-lg font-semibold">{title.trim() || 'Untitled meeting'}</p>
|
| 353 |
+
<dl className="mt-3 space-y-2.5 text-sm text-white/75">
|
| 354 |
+
<PreviewRow icon={<Folder size={15} />} label="Project" value={projectName && showInProject ? projectName : 'None'} />
|
| 355 |
+
<PreviewRow icon={<CalendarDays size={15} />} label="Date" value={when === 'now' ? `Today, ${new Date().toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}`
|
| 356 |
+
: new Date(scheduledTs).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })} />
|
| 357 |
+
<PreviewRow icon={<Clock3 size={15} />} label="Time" value={when === 'now' ? 'Now' : new Date(scheduledTs).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} />
|
| 358 |
+
<PreviewRow icon={<Users size={15} />} label="Participants" value="Add later" />
|
| 359 |
+
</dl>
|
| 360 |
+
<div className="mt-4 rounded-xl border border-white/10 p-3 text-sm text-white/65">
|
| 361 |
+
{step === 0 ? 'You’ll choose the recording source in the next step.'
|
| 362 |
+
: plan === 'live' ? 'Recorded live in HomePilot.' : plan === 'external' ? 'Recording arrives from an external recorder.' : 'Recorded live, plus an external file later.'}
|
| 363 |
+
{step >= 2 ? <span className="mt-1 block text-xs text-white/45">Assistant: {MODES.find((m) => m.id === capture.mode)?.label || 'Note taker'}</span> : null}
|
| 364 |
+
</div>
|
| 365 |
+
</div>
|
| 366 |
+
{last ? (
|
| 367 |
+
<p className="mt-4 rounded-xl border border-violet-400/30 bg-violet-500/10 p-3 text-sm text-violet-100">
|
| 368 |
+
Ready to {startsLive ? 'start' : 'create'} the meeting.{' '}
|
| 369 |
+
{startsLive ? 'Nothing is recorded until you press Start meeting and allow capture.' : 'It appears in your Meetings library right away.'}
|
| 370 |
+
</p>
|
| 371 |
+
) : null}
|
| 372 |
+
</aside>
|
| 373 |
+
</div>
|
| 374 |
+
</ModalSheet>
|
| 375 |
+
);
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
function SectionTitle({ id, icon, title, note, className = '' }: { id?: string; icon: React.ReactNode; title: string; note: string; className?: string }) {
|
| 379 |
+
return (
|
| 380 |
+
<div className={className}>
|
| 381 |
+
<h3 id={id} className="flex items-center gap-2 text-lg font-semibold"><span className="text-violet-300">{icon}</span> {title}</h3>
|
| 382 |
+
<p className="mt-0.5 pl-7 text-sm text-white/55">{note}</p>
|
| 383 |
+
</div>
|
| 384 |
+
);
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
function ChoiceCard({ selected, onClick, icon, title, note, disabled }: {
|
| 388 |
+
selected: boolean; onClick: () => void; icon?: React.ReactNode; title: string; note: string; disabled?: boolean;
|
| 389 |
+
}) {
|
| 390 |
+
return (
|
| 391 |
+
<button type="button" onClick={onClick} disabled={disabled} aria-pressed={selected}
|
| 392 |
+
className={`flex items-center gap-3 rounded-xl border p-3.5 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${
|
| 393 |
+
selected ? 'border-violet-400 bg-violet-500/10 shadow-[0_0_0_1px_rgba(167,139,250,0.4)]' : 'border-white/10 bg-white/[0.02] hover:border-white/20'}`}>
|
| 394 |
+
{icon ? <span className={`grid h-10 w-10 shrink-0 place-items-center rounded-xl border ${selected ? 'border-violet-400/40 bg-violet-500/20 text-violet-100' : 'border-white/10 bg-white/[0.03] text-white/60'}`}>{icon}</span> : null}
|
| 395 |
+
<span className="min-w-0">
|
| 396 |
+
<span className="block font-semibold">{title}</span>
|
| 397 |
+
<span className="block text-xs text-white/55">{note}</span>
|
| 398 |
+
</span>
|
| 399 |
+
</button>
|
| 400 |
+
);
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
function PreviewRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
| 404 |
+
return (
|
| 405 |
+
<div className="flex items-center gap-3">
|
| 406 |
+
<span className="text-violet-300">{icon}</span>
|
| 407 |
+
<dt className="sr-only">{label}</dt>
|
| 408 |
+
<dd>{label}: <span className="text-white">{value}</span></dd>
|
| 409 |
+
</div>
|
| 410 |
+
);
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
function Tip({ children }: { children: React.ReactNode }) {
|
| 414 |
+
return (
|
| 415 |
+
<p className="mt-5 flex gap-2 rounded-xl border border-white/10 bg-white/[0.02] px-3 py-2.5 text-sm text-white/60">
|
| 416 |
+
<Lightbulb size={16} className="mt-0.5 shrink-0 text-sky-300" /> {children}
|
| 417 |
+
</p>
|
| 418 |
+
);
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
export default StartMeetingWizard;
|