HomePilot Deploy Bot commited on
Commit
de5e4f8
·
1 Parent(s): a55105f

chore(hf): sync HomePilot to HF Space

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +4 -4
  2. backend/app/main.py +39 -11
  3. backend/app/meetingsense/__init__.py +10 -1
  4. backend/app/meetingsense/export.py +20 -1
  5. backend/app/meetingsense/intelligence/__init__.py +20 -0
  6. backend/app/meetingsense/intelligence/analyze.py +576 -0
  7. backend/app/meetingsense/intelligence/brief.py +401 -0
  8. backend/app/meetingsense/intelligence/layout.py +78 -0
  9. backend/app/meetingsense/intelligence/questions.py +89 -0
  10. backend/app/meetingsense/intelligence/schema.py +233 -0
  11. backend/app/meetingsense/intelligence/service.py +69 -0
  12. backend/app/meetingsense/intelligence/store.py +232 -0
  13. backend/app/meetingsense/intelligence/validate.py +142 -0
  14. backend/app/meetingsense/library/__init__.py +28 -0
  15. backend/app/meetingsense/library/adapters.py +332 -0
  16. backend/app/meetingsense/library/config.py +122 -0
  17. backend/app/meetingsense/library/db.py +1062 -0
  18. backend/app/meetingsense/library/importer.py +373 -0
  19. backend/app/meetingsense/library/jobs.py +441 -0
  20. backend/app/meetingsense/library/media.py +172 -0
  21. backend/app/meetingsense/library/parsers.py +292 -0
  22. backend/app/meetingsense/library/projection.py +314 -0
  23. backend/app/meetingsense/library/routes.py +977 -0
  24. backend/app/meetingsense/library/sync.py +266 -0
  25. backend/app/meetingsense/notes_engine.py +31 -10
  26. backend/app/personas/avatar_crop.py +64 -0
  27. frontend/.cache-bust +1 -1
  28. frontend/package-lock.json +7 -0
  29. frontend/package.json +2 -0
  30. frontend/scripts/ensure-deps.mjs +78 -0
  31. frontend/src/test/__snapshots__/meetingsLandingGate.test.tsx.snap +3 -0
  32. frontend/src/test/conversationHub.test.tsx +21 -0
  33. frontend/src/test/ensureDeps.test.js +74 -0
  34. frontend/src/test/meetingIntelligence.test.tsx +192 -0
  35. frontend/src/test/meetingsLandingGate.test.tsx +57 -0
  36. frontend/src/test/meetingsLibrary.test.tsx +240 -0
  37. frontend/src/test/visualSummaryTab.test.tsx +220 -0
  38. frontend/src/ui/App.tsx +197 -147
  39. frontend/src/ui/components/PersonaSettingsPanel.tsx +3 -1
  40. frontend/src/ui/components/ProjectAvatar.tsx +29 -2
  41. frontend/src/ui/meetingsense/MeetingSenseProvider.tsx +39 -6
  42. frontend/src/ui/meetingsense/library/AssistantDrawer.tsx +154 -0
  43. frontend/src/ui/meetingsense/library/FolderSyncPanel.tsx +181 -0
  44. frontend/src/ui/meetingsense/library/ImportDialog.tsx +279 -0
  45. frontend/src/ui/meetingsense/library/InsightsPanel.tsx +183 -0
  46. frontend/src/ui/meetingsense/library/MeetingDetailPage.tsx +929 -0
  47. frontend/src/ui/meetingsense/library/MeetingsHome.tsx +427 -0
  48. frontend/src/ui/meetingsense/library/PresentMode.tsx +66 -0
  49. frontend/src/ui/meetingsense/library/ProcessingPanel.tsx +101 -0
  50. 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 auto_mode:
 
 
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
- try:
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
- appearance["selected_filename"] = result.selected_filename
6220
- appearance["selected_thumb_filename"] = result.thumb_filename
 
 
 
 
 
 
 
 
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 .routes import router # noqa: F401
 
 
 
 
 
 
 
 
 
 
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
- response = await route_chat(
515
- messages,
516
- provider=target["provider"],
517
- base_url=target["base_url"],
518
- temperature=temperature,
519
- max_tokens=600,
520
- model=target["model"],
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
- 1791656982
 
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
- const meetingsView = useMeetingCatalog({ enabled: meetingsNavEnabled && mode === 'meetings' })
 
 
 
 
 
 
 
 
 
 
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
- setMeetingsNavEnabled(msCatalogEnabled(body))
 
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={async (projectId) => {
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
- const url = fileUrl(backendUrl, thumb || full)
 
 
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(capture, Boolean(status?.stt?.remote)),
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
- const actuallyStart = useCallback(async () => {
 
 
 
 
 
 
 
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, capture, conversationId, meeting, onOpenConversation, persistStartMarker]);
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&apos;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;