diff --git a/.gitattributes b/.gitattributes index bf133d6477636cc0ea883e39c697a0bbd6b17f18..f8059effe7a5e7c5ac4a21148a0a93032e0df79e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,9 +1,9 @@ -*.png filter=lfs diff=lfs merge=lfs -text -*.jpg filter=lfs diff=lfs merge=lfs -text -*.webp filter=lfs diff=lfs merge=lfs -text -*.ico filter=lfs diff=lfs merge=lfs -text *.hpersona filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text *.jpeg filter=lfs diff=lfs merge=lfs -text *.svg filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.webp filter=lfs diff=lfs merge=lfs -text *.woff filter=lfs diff=lfs merge=lfs -text *.woff2 filter=lfs diff=lfs merge=lfs -text +*.ico filter=lfs diff=lfs merge=lfs -text diff --git a/backend/app/main.py b/backend/app/main.py index 22eebf980a552b1e27be92890848177775bb4e4f..5833bbe11994dec2c60e1f267d4419a9e9ec1fa9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3861,6 +3861,19 @@ async def update_project(project_id: str, request: Request) -> JSONResponse: """Update project details.""" try: data = await request.json() + + # A picture framed in Edit Profile Picture stays until the user picks + # another look: settings panels echo the appearance they loaded, which + # may predate the crop (see personas/avatar_crop.keep_manual_picture). + from .personas.avatar_crop import keep_manual_picture, keeps_manual_picture + + before = projects.get_project_by_id(project_id) or {} + before_pap = dict(before.get("persona_appearance") or {}) + if isinstance(data, dict) and isinstance(data.get("persona_appearance"), dict): + merged_pap = {**before_pap, **data["persona_appearance"]} + data = {**data, "persona_appearance": keep_manual_picture( + before_pap, merged_pap, _resolve_selected_image_url(merged_pap))} + result = projects.update_project(project_id, data) if not result: @@ -3911,7 +3924,11 @@ async def update_project(project_id: str, request: Request) -> JSONResponse: # selected_filename was missing → changing Active Look left # the project mini-thumbnail stale. # ----------------------------------------------------------- + # ...except while the user's own picture from Edit Profile Picture + # is in use and they have not picked another look. selected_url = _resolve_selected_image_url(appearance) + if keeps_manual_picture(before_pap, appearance, selected_url): + selected_url = None if selected_url: # Extract the rel_path for already-committed /files/ URLs sel_rel = None @@ -6158,9 +6175,19 @@ async def persona_commit_avatar(project_id: str, body: dict) -> JSONResponse: source_url = body.get("source_url") auto_mode = body.get("auto", False) + # A picture framed in Edit Profile Picture is the persona's picture until + # the user picks another look — and the project save applies a new look + # (dropping the framing) before this repair runs. Re-committing the look + # here would replace the framing with a fresh automatic thumbnail. + from .personas.avatar_crop import active_crop as _active_crop + + keep_framed = bool(auto_mode) and _active_crop(p.get("persona_appearance") or {}) is not None + # ── Resolve the source file ────────────────────────────────────────── try: - if auto_mode: + if keep_framed: + source_filename = None + elif auto_mode: # Auto-resolve: walk persona_appearance.sets to find selected URL appearance_data = p.get("persona_appearance") or {} resolved_url = _resolve_selected_image_url(appearance_data) @@ -6207,17 +6234,18 @@ async def persona_commit_avatar(project_id: str, body: dict) -> JSONResponse: raise HTTPException(status_code=502, detail=f"Failed to download image: {e}") # ── Commit into project-owned storage ──────────────────────────────── - try: - project_root = UPLOAD_PATH / "projects" / project_id - result = commit_persona_avatar(UPLOAD_PATH, project_root, source_filename) - except FileNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - + project_root = UPLOAD_PATH / "projects" / project_id appearance = dict(p.get("persona_appearance") or {}) - appearance["selected_filename"] = result.selected_filename - appearance["selected_thumb_filename"] = result.thumb_filename + if not keep_framed: + try: + result = commit_persona_avatar(UPLOAD_PATH, project_root, source_filename) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + appearance["selected_filename"] = result.selected_filename + appearance["selected_thumb_filename"] = result.thumb_filename # ----- Commit ALL remaining /comfy/view/ images in sets[] + outfits[] ----- # Same logic as project creation (POST /projects) — download ephemeral diff --git a/backend/app/meetingsense/__init__.py b/backend/app/meetingsense/__init__.py index 862551dd4faa576875d1686f7a38c2b2f651077d..103514ca5dc76ee065932788c18ba81ded302d52 100644 --- a/backend/app/meetingsense/__init__.py +++ b/backend/app/meetingsense/__init__.py @@ -17,4 +17,13 @@ needs WebSockets arrives in MS3 and is imported lazily then, so an install that MeetingSense on never loads it. """ -from .routes import router # noqa: F401 +from fastapi import APIRouter + +from .library import router as _library_router +from .routes import router as _core_router + +# The library's routes first: MeetingSense's ``GET /v1/meetingsense/{meeting_id}`` would +# otherwise read ``library`` or ``imports`` as a meeting id. Every core route is unchanged. +router = APIRouter() +router.include_router(_library_router) +router.include_router(_core_router) diff --git a/backend/app/meetingsense/export.py b/backend/app/meetingsense/export.py index a4dde837bc1419dcf5509170d5b98a1f47f475dc..a3c13b1eaf632cbc767276cd8016c00a1e963335 100644 --- a/backend/app/meetingsense/export.py +++ b/backend/app/meetingsense/export.py @@ -131,6 +131,25 @@ def notes_body(notes: Any) -> Optional[Dict[str, Any]]: return None +def note_line(item: Any) -> str: + """One note item as a Markdown bullet. The notes engine stores items as dicts + (``{"text", "owner", "t0", "done"}``); rendering those with ``str()`` printed Python + dict reprs into exports and into project knowledge. Strings pass through unchanged.""" + if not isinstance(item, dict): + return str(item) + text = str(item.get("text") or item.get("title") or item.get("item") or "").strip() + extras = [] + owner = item.get("owner") or item.get("who") + if owner: + extras.append(str(owner)) + stamp = item.get("t0_ms") if item.get("t0_ms") is not None else item.get("t0") + if isinstance(stamp, (int, float)) and not isinstance(stamp, bool): + extras.append(clock(int(stamp))) + if item.get("done"): + extras.append("done") + return f"{text} ({', '.join(extras)})" if extras and text else text or str(item) + + def to_markdown( meeting: Dict[str, Any], segments: Sequence[Dict[str, Any]], @@ -157,7 +176,7 @@ def to_markdown( if isinstance(value, str): out.append(f"\n{value}\n") else: - out.extend(f"\n- {item}" for item in value) + out.extend(f"\n- {note_line(item)}" for item in value) out.append("\n") if keyframes: diff --git a/backend/app/meetingsense/intelligence/__init__.py b/backend/app/meetingsense/intelligence/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4ff33bb494574518c7d2b38ce595ee43884b7a2e --- /dev/null +++ b/backend/app/meetingsense/intelligence/__init__.py @@ -0,0 +1,20 @@ +"""Meeting intelligence — the typed layer on top of a finished transcript. + +One ``MeetingAnalysis`` per transcript version: topics, decisions (confirmed / proposed / +rejected), risks, action items, open questions and conclusions, every one of them carrying a +verbatim quote and the transcript line it came from. A validator keeps an item only when its +quote really is in that line, so what the visual page shows is what the meeting said. + +From the analysis the module derives a ``VisualSpec`` — the one-page summary as a list of +typed blocks whose cells reference analysis items by id — and a handful of suggested +questions for the assistant. + +Additive and flag-gated (``MEETINGS_INTELLIGENCE``, default off). Nothing here touches +MeetingSense's own tables, routes or the live recorder, and nothing here calls a model when +none is configured: without one, the analysis is assembled from the meeting's own words and +says so (``degraded == "extractive"``). +""" + +from .schema import SCHEMA_VERSION, MeetingAnalysis, SuggestedQuestion, VisualSpec + +__all__ = ["SCHEMA_VERSION", "MeetingAnalysis", "SuggestedQuestion", "VisualSpec"] diff --git a/backend/app/meetingsense/intelligence/analyze.py b/backend/app/meetingsense/intelligence/analyze.py new file mode 100644 index 0000000000000000000000000000000000000000..cb685b8035fa6680746d1394787bcf629338856f --- /dev/null +++ b/backend/app/meetingsense/intelligence/analyze.py @@ -0,0 +1,576 @@ +"""Transcript → ``MeetingAnalysis``: map each chunk to cited candidates, reduce once. + +The shape follows ``minutes.py`` on purpose — the same chunker, the same bounded prompts, +the same "one outage, one log line" rule — but the output is data, not prose: + +* **map**: every chunk yields candidate topics, decisions, risks, actions and questions, + each with a verbatim quote and the stamp of the line it came from; +* **reduce**: one call merges the candidates into the final lists and *cites candidate + ids* instead of re-quoting, so evidence survives the merge untouched; +* **validate** (``validate.py``): anything whose quote is not in the transcript is dropped. + +Without a model the analysis is **extractive**: cue words pick the lines that sound like +decisions, actions, risks and questions, each citing itself, and ``degraded`` says so. That is +the honest floor — the meeting's own words, never a guess dressed as a summary. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence, Tuple + +from .. import export, minutes +from .schema import ( + ActionItem, Conclusion, Decision, Evidence, MeetingAnalysis, OpenQuestion, Risk, SpeakerStat, Topic, + ValidatorReport, +) +from .validate import normalise, validate + +log = logging.getLogger(__name__) + +ModelCall = Callable[..., Awaitable[str]] + +#: Parallel map calls per analysis. Four keeps a 60-minute meeting to a handful of rounds +#: without starving the chat model that shares the GPU. +MAP_CONCURRENCY = 4 +MAP_MAX_TOKENS = 900 +REDUCE_MAX_TOKENS = 1400 +#: Candidate caps per kind before the reduce — a bound on the prompt, not on the meeting. +CANDIDATE_CAP = {"topics": 48, "decisions": 40, "risks": 30, "actions": 40, "questions": 30} + +MAP_SYSTEM = """\ +You read one consecutive slice of a meeting transcript and list what it establishes. +Return ONLY a JSON object with the keys topics, decisions, risks, actions, questions. + +Rules: +- Quote verbatim. Every decision, risk, action and question carries "quote": the exact words \ +of ONE transcript line (copy them, never paraphrase) and "t0": that line's [hh:mm:ss] stamp. +- A decision is "confirmed" only when the speakers agree ("we agreed", "let's go with", \ +"the decision is"); "proposed" for a suggestion ("we could", "maybe", "I think we should"); \ +"rejected" when it was turned down. +- Never invent owners or dates: leave them empty unless said. Owners use speaker names as \ +written. +- Keep every text under 30 words. Any list may be empty. No commentary outside the JSON. + +Shape: +{"topics":[{"label":"...","summary":"..."}], + "decisions":[{"statement":"...","status":"confirmed|proposed|rejected","quote":"...","t0":"hh:mm:ss"}], + "risks":[{"title":"...","severity":"high|medium|low","detail":"...","root_cause":"...",\ +"solutions":["..."],"quote":"...","t0":"hh:mm:ss"}], + "actions":[{"text":"...","owner":"","due_text":"","quote":"...","t0":"hh:mm:ss"}], + "questions":[{"text":"...","quote":"...","t0":"hh:mm:ss"}]}""" + +REDUCE_SYSTEM = """\ +You merge candidate findings from consecutive parts of ONE meeting into its final analysis. +Return ONLY a JSON object: +{"title":"...","one_line":"...", + "topics":[{"label":"...","summary":"...","from":["t3","t7"]}], + "decisions":[{"statement":"...","status":"confirmed|proposed|rejected","from":["d2","d9"]}], + "risks":[{"title":"...","severity":"high|medium|low","detail":"...","root_cause":"...",\ +"solutions":["..."],"from":["r1"]}], + "actions":[{"text":"...","owner":"","due_text":"","from":["a4"]}], + "questions":[{"text":"...","from":["q2"]}], + "conclusions":[{"text":"...","from":["d2","r1"]}]} + +Rules: +- "from" lists the candidate ids you merged, copied exactly. An item with no candidate id is \ +dropped, so always cite at least one. +- Merge duplicates. When a proposal was later agreed, the result is ONE confirmed decision \ +citing both candidates. Keep "rejected" items; they matter. +- The title is specific to this meeting and under 12 words. one_line is the one sentence a \ +colleague who missed the meeting needs. +- 2 to 4 conclusions, each tied to the candidates that support it. No commentary outside the JSON.""" + +_FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.S) +_STAMP = re.compile(r"(\d{1,2}):(\d{2}):(\d{2})") + + +def json_object(text: str) -> Optional[Dict[str, Any]]: + """The first JSON object in a model reply — fenced, bare, or buried in prose.""" + if not isinstance(text, str) or not text.strip(): + return None + body = text.strip() + fenced = _FENCE.search(body) + if fenced: + body = fenced.group(1).strip() + start = body.find("{") + if start < 0: + return None + depth = 0 + in_str = False + esc = False + for i in range(start, len(body)): + ch = body[i] + if in_str: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + in_str = False + continue + if ch == '"': + in_str = True + elif ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + try: + value = json.loads(body[start:i + 1]) + except ValueError: + return None + return value if isinstance(value, dict) else None + return None + + +def stamp_ms(value: Any) -> Optional[int]: + """``"00:12:34"`` → 754000. Accepts an int of milliseconds as well.""" + if isinstance(value, (int, float)): + return int(value) + m = _STAMP.search(str(value or "")) + if not m: + return None + h, mi, s = (int(x) for x in m.groups()) + return (h * 3600 + mi * 60 + s) * 1000 + + +def _text(value: Any, limit: int = 240) -> str: + out = " ".join(str(value or "").split()) + return out[:limit] + + +def _list(value: Any) -> List[Any]: + return [v for v in value if isinstance(v, dict)] if isinstance(value, list) else [] + + +def _strings(value: Any, limit: int = 6) -> List[str]: + if not isinstance(value, list): + return [] + return [_text(v, 160) for v in value if _text(v, 160)][:limit] + + +def _status(value: Any) -> str: + v = str(value or "").strip().lower() + return v if v in ("confirmed", "proposed", "rejected") else "proposed" + + +def _severity(value: Any) -> str: + v = str(value or "").strip().lower() + return v if v in ("high", "medium", "low") else "medium" + + +# ── candidates ────────────────────────────────────────────────────────────── + + +class Candidates: + """Everything the map step produced, each with a stable id for the reduce to cite.""" + + def __init__(self) -> None: + self.items: Dict[str, Dict[str, Any]] = {} + self.counts = {"t": 0, "d": 0, "r": 0, "a": 0, "q": 0} + + def add(self, kind: str, payload: Dict[str, Any]) -> str: + self.counts[kind] += 1 + cid = f"{kind}{self.counts[kind]}" + self.items[cid] = {"id": cid, **payload} + return cid + + def of(self, kind: str) -> List[Dict[str, Any]]: + return [v for k, v in self.items.items() if k.startswith(kind)] + + def evidence(self, ids: Sequence[str]) -> List[Evidence]: + out: List[Evidence] = [] + seen = set() + for cid in ids: + item = self.items.get(str(cid)) + if not item: + continue + ev = item.get("evidence") + if isinstance(ev, Evidence) and (ev.t0_ms, ev.quote) not in seen: + seen.add((ev.t0_ms, ev.quote)) + out.append(ev) + return out + + +def _candidate(kind: str, raw: Dict[str, Any], chunk: Dict[str, Any]) -> Dict[str, Any]: + t0 = stamp_ms(raw.get("t0")) + if t0 is None: + t0 = int(chunk.get("t0_ms") or 0) + ev = Evidence(t0_ms=t0, quote=_text(raw.get("quote"), 300)) + base = {"chunk": chunk.get("index", 0), "t0_ms": t0, "evidence": ev} + if kind == "d": + return {**base, "statement": _text(raw.get("statement")), "status": _status(raw.get("status"))} + if kind == "r": + return {**base, "title": _text(raw.get("title"), 120), "severity": _severity(raw.get("severity")), + "detail": _text(raw.get("detail")), "root_cause": _text(raw.get("root_cause")), + "solutions": _strings(raw.get("solutions"))} + if kind == "a": + return {**base, "text": _text(raw.get("text")), "owner": _text(raw.get("owner"), 80) or None, + "due_text": _text(raw.get("due_text"), 80) or None} + return {**base, "text": _text(raw.get("text"))} + + +def _collect(cands: Candidates, parsed: Dict[str, Any], chunk: Dict[str, Any]) -> None: + for raw in _list(parsed.get("topics")): + label = _text(raw.get("label"), 80) + if label: + cands.add("t", {"label": label, "summary": _text(raw.get("summary")), "chunk": chunk.get("index", 0), + "t0_ms": int(chunk.get("t0_ms") or 0), "t1_ms": int(chunk.get("t1_ms") or 0)}) + for kind, key, text_key in (("d", "decisions", "statement"), ("r", "risks", "title"), + ("a", "actions", "text"), ("q", "questions", "text")): + for raw in _list(parsed.get(key)): + if not _text(raw.get(text_key)) or not _text(raw.get("quote")): + continue + cands.add(kind, _candidate(kind, raw, chunk)) + + +def render_candidates(cands: Candidates) -> str: + """The reduce prompt's body: one line per candidate, ids first so the model can cite.""" + lines: List[str] = [] + for item in cands.of("t")[:CANDIDATE_CAP["topics"]]: + lines.append(f"{item['id']} topic: {item['label']} — {item.get('summary', '')}".rstrip(" —")) + for item in cands.of("d")[:CANDIDATE_CAP["decisions"]]: + lines.append(f"{item['id']} decision [{item['status']}] ({export.clock(item['t0_ms'])}): " + f"{item['statement']} — \"{item['evidence'].quote}\"") + for item in cands.of("r")[:CANDIDATE_CAP["risks"]]: + lines.append(f"{item['id']} risk [{item['severity']}] ({export.clock(item['t0_ms'])}): {item['title']}" + f"{' — ' + item['detail'] if item.get('detail') else ''}") + for item in cands.of("a")[:CANDIDATE_CAP["actions"]]: + owner = f" owner={item['owner']}" if item.get("owner") else "" + due = f" due={item['due_text']}" if item.get("due_text") else "" + lines.append(f"{item['id']} action ({export.clock(item['t0_ms'])}):{owner}{due} {item['text']}") + for item in cands.of("q")[:CANDIDATE_CAP["questions"]]: + lines.append(f"{item['id']} question ({export.clock(item['t0_ms'])}): {item['text']}") + return "\n".join(lines) + + +# ── model steps ───────────────────────────────────────────────────────────── + + +async def _ask(call: Optional[ModelCall], messages: List[Dict[str, str]], *, max_tokens: int, + outage: Optional[minutes._Outage], what: str) -> Optional[Dict[str, Any]]: + if call is None: + return None + try: + raw = await call(messages, temperature=0.1, max_tokens=max_tokens, response_format="json") + except Exception as exc: # noqa: BLE001 — a failed model is a state, not a crash + if outage is not None: + outage.record(exc, what) + return None + return json_object(raw) if isinstance(raw, str) else None + + +async def map_candidates(chunks: Sequence[Dict[str, Any]], *, call: Optional[ModelCall], + outage: Optional[minutes._Outage] = None) -> Tuple[Candidates, int]: + """Candidates from every chunk, ``MAP_CONCURRENCY`` at a time. Returns the candidates and + how many chunks a model actually answered for.""" + cands = Candidates() + if call is None or not chunks: + return cands, 0 + sem = asyncio.Semaphore(MAP_CONCURRENCY) + + async def one(chunk: Dict[str, Any]) -> Optional[Dict[str, Any]]: + async with sem: + messages = [ + {"role": "system", "content": MAP_SYSTEM}, + {"role": "user", "content": f"Transcript lines {minutes._range_label(chunk)}:\n\n" + f"{minutes.render_chunk(chunk['segments'])}"}, + ] + return await _ask(call, messages, max_tokens=MAP_MAX_TOKENS, outage=outage, what="analysis map") + + results = await asyncio.gather(*(one(c) for c in chunks)) + answered = 0 + for chunk, parsed in zip(chunks, results): + if parsed is None: + continue + answered += 1 + _collect(cands, parsed, chunk) + return cands, answered + + +async def reduce_candidates(cands: Candidates, *, meeting_title: str, call: Optional[ModelCall], + outage: Optional[minutes._Outage] = None) -> Optional[Dict[str, Any]]: + body = render_candidates(cands) + if not body.strip(): + return None + messages = [ + {"role": "system", "content": REDUCE_SYSTEM}, + {"role": "user", "content": f"Meeting title as recorded: {meeting_title or 'untitled'}\n\nCandidates:\n{body}"}, + ] + return await _ask(call, messages, max_tokens=REDUCE_MAX_TOKENS, outage=outage, what="analysis reduce") + + +# ── assembling the analysis ───────────────────────────────────────────────── + + +def speaker_stats(segments: Sequence[Dict[str, Any]]) -> List[SpeakerStat]: + talk: Dict[str, int] = {} + lines: Dict[str, int] = {} + for seg in segments: + label = export.speaker_label(seg.get("speaker")) if seg.get("speaker") in ("me", "them") \ + else (seg.get("speaker") or "") + if not label: + continue + t0 = int(seg.get("t0_ms") or 0) + t1 = seg.get("t1_ms") + dur = max(0, int(t1) - t0) if t1 is not None else 4000 + talk[label] = talk.get(label, 0) + dur + lines[label] = lines.get(label, 0) + 1 + total = sum(talk.values()) or 1 + out = [SpeakerStat(label=k, talk_ms=v, share=round(v / total, 3), lines=lines[k]) for k, v in talk.items()] + out.sort(key=lambda s: -s.talk_ms) + return out + + +def _topics_from_candidates(cands: Candidates, reduced: Optional[Dict[str, Any]]) -> List[Topic]: + out: List[Topic] = [] + rows = _list((reduced or {}).get("topics")) + if rows: + for i, raw in enumerate(rows[:12], start=1): + label = _text(raw.get("label"), 80) + if not label: + continue + refs = [cands.items[c] for c in (raw.get("from") or []) if str(c) in cands.items] + t0 = min((r["t0_ms"] for r in refs), default=0) + t1 = max((r.get("t1_ms", r["t0_ms"]) for r in refs), default=t0) + out.append(Topic(id=f"t{i}", label=label, t0_ms=int(t0), t1_ms=int(t1), summary=_text(raw.get("summary")))) + if out: + return out + # No reduce: one topic per candidate label, de-duplicated, in time order. + seen = set() + for item in sorted(cands.of("t"), key=lambda c: c["t0_ms"]): + key = normalise(item["label"]) + if key in seen: + continue + seen.add(key) + out.append(Topic(id=f"t{len(out) + 1}", label=item["label"], t0_ms=item["t0_ms"], + t1_ms=item.get("t1_ms", item["t0_ms"]), summary=item.get("summary", ""))) + return out[:12] + + +def assemble(cands: Candidates, reduced: Optional[Dict[str, Any]], *, meeting_id: str, meeting_title: str, + segments: Sequence[Dict[str, Any]], model: Optional[str]) -> MeetingAnalysis: + """Turn reduce output (or, failing that, the raw candidates) into the typed analysis.""" + analysis = MeetingAnalysis(meeting_id=meeting_id, title=meeting_title or "Meeting", model=model) + analysis.speakers = speaker_stats(segments) + analysis.topics = _topics_from_candidates(cands, reduced) + + def merged(kind: str, key: str) -> List[Tuple[Dict[str, Any], List[Evidence]]]: + rows = _list((reduced or {}).get(key)) + out: List[Tuple[Dict[str, Any], List[Evidence]]] = [] + if rows: + for raw in rows: + ids = [str(c) for c in (raw.get("from") or [])] + ev = cands.evidence(ids) + if ev: + out.append((raw, ev)) + return out + seen = set() + text_key = {"d": "statement", "r": "title", "a": "text", "q": "text"}[kind] + for item in cands.of(kind): + k = normalise(item[text_key]) + if not k or k in seen: + continue + seen.add(k) + out.append((item, [item["evidence"]])) + return out + + analysis.title = _text((reduced or {}).get("title"), 120) or analysis.title + analysis.one_line = _text((reduced or {}).get("one_line"), 300) + + for i, (raw, ev) in enumerate(merged("d", "decisions"), start=1): + analysis.decisions.append(Decision(id=f"d{i}", statement=_text(raw.get("statement")), status=_status(raw.get("status")), + evidence=ev, confidence=0.8 if reduced else 0.6)) + for i, (raw, ev) in enumerate(merged("r", "risks"), start=1): + analysis.risks.append(Risk(id=f"r{i}", title=_text(raw.get("title"), 120), severity=_severity(raw.get("severity")), + detail=_text(raw.get("detail")), root_cause=_text(raw.get("root_cause")), + solutions=_strings(raw.get("solutions")), evidence=ev)) + for i, (raw, ev) in enumerate(merged("a", "actions"), start=1): + analysis.action_items.append(ActionItem(id=f"a{i}", text=_text(raw.get("text")), owner=_text(raw.get("owner"), 80) or None, + due_text=_text(raw.get("due_text"), 80) or None, evidence=ev, + confidence=0.8 if reduced else 0.6)) + for i, (raw, ev) in enumerate(merged("q", "questions"), start=1): + analysis.open_questions.append(OpenQuestion(id=f"q{i}", text=_text(raw.get("text")), evidence=ev)) + for i, raw in enumerate(_list((reduced or {}).get("conclusions"))[:4], start=1): + ev = cands.evidence([str(c) for c in (raw.get("from") or [])]) + text = _text(raw.get("text"), 300) + if text and ev: + analysis.conclusions.append(Conclusion(id=f"c{i}", text=text, evidence=ev)) + return analysis + + +# ── the extractive floor ──────────────────────────────────────────────────── + +_DECIDED = re.compile(r"\b(we agreed|agreed to|we decided|decided to|let'?s go with|the decision is|we will go with|" + r"go ahead with|approved|sign(?:ed)? off)\b", re.I) +_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|" + r"propose that|proposing|i suggest|we might|one option is|an option would be)\b", re.I) +_REJECTED = re.compile(r"\b(we won'?t|we will not|not going to|rejected|drop that|decided against|ruled out|" + r"let'?s not)\b", re.I) +_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|" + r"send (?:you|the)|schedule|set up|prepare|complete|launch|inject|review)\b", re.I) +_DUE = re.compile(r"\b(today|tomorrow|tonight|this week|next week|end of (?:the )?(?:week|month|day)|by (?:monday|tuesday|" + r"wednesday|thursday|friday|saturday|sunday|tomorrow|eod|next week)|on (?:monday|tuesday|wednesday|thursday|" + r"friday)|next (?:monday|tuesday|wednesday|thursday|friday))\b", re.I) +_RISK = re.compile(r"\b(risk|problem|issue|blocked|blocker|fails?|failure|error|bug|concern|broken|missing|unavailable|" + r"cannot|can'?t|doesn'?t work|not working|outage)\b", re.I) +_HIGH = re.compile(r"\b(critical|urgent|high priority|blocked|blocker|outage|data loss|security)\b", re.I) + + +def fallback_analysis(segments: Sequence[Dict[str, Any]], *, meeting_id: str, meeting_title: str) -> MeetingAnalysis: + """The no-model analysis: lines that sound like decisions, actions, risks and questions, + each citing itself. Topics are the transcript in order, one per chunk, named by their + most frequent words. ``degraded`` is set so every surface can say what this is.""" + rows = [s for s in segments if (s.get("text") or "").strip()] + analysis = MeetingAnalysis(meeting_id=meeting_id, title=meeting_title or "Meeting", degraded="extractive") + analysis.speakers = speaker_stats(rows) + + def ev(seg: Dict[str, Any]) -> List[Evidence]: + return [Evidence(segment_id=str(seg.get("id")) if seg.get("id") else None, t0_ms=int(seg.get("t0_ms") or 0), + t1_ms=seg.get("t1_ms"), quote=_text(seg.get("text"), 300))] + + def who(seg: Dict[str, Any]) -> Optional[str]: + sp = seg.get("speaker") + if not sp: + return None + return export.speaker_label(sp) if sp in ("me", "them") else str(sp) + + def decision(seg: Dict[str, Any], text: str, status: str) -> None: + analysis.decisions.append(Decision(id=f"d{len(analysis.decisions) + 1}", statement=text, status=status, # type: ignore[arg-type] + evidence=ev(seg), confidence=0.4)) + + # Strong cues first (an explicit agreement or refusal), then problems, then commitments, + # then mere suggestions: a line that says "that is a real problem" is a risk even when it + # also contains a softer phrase. + for seg in rows: + text = _text(seg.get("text"), 300) + if len(analysis.decisions) < 12 and _DECIDED.search(text): + decision(seg, _cue_sentence(text, _DECIDED), "confirmed") + continue + if len(analysis.decisions) < 12 and _REJECTED.search(text): + decision(seg, _cue_sentence(text, _REJECTED), "rejected") + continue + if len(analysis.risks) < 8 and _RISK.search(text): + analysis.risks.append(Risk(id=f"r{len(analysis.risks) + 1}", title=_headline(_cue_sentence(text, _RISK)), + severity="high" if _HIGH.search(text) else "medium", detail=_clean(text), evidence=ev(seg))) + continue + if len(analysis.action_items) < 12 and _ACTION.search(text): + due = _DUE.search(text) + analysis.action_items.append(ActionItem(id=f"a{len(analysis.action_items) + 1}", text=_cue_sentence(text, _ACTION), + owner=who(seg), due_text=due.group(0) if due else None, evidence=ev(seg), + confidence=0.4)) + continue + if len(analysis.decisions) < 12 and _PROPOSED.search(text): + decision(seg, _cue_sentence(text, _PROPOSED), "proposed") + continue + if len(analysis.open_questions) < 8 and text.rstrip().endswith("?") and len(text.split()) >= 4: + analysis.open_questions.append(OpenQuestion(id=f"q{len(analysis.open_questions) + 1}", text=_clean(text), owner=who(seg), + evidence=ev(seg))) + + chunks = minutes.chunk(rows, max_words=400, overlap_words=0, max_chunks=12) + for i, chunk in enumerate(chunks, start=1): + digest = minutes.extractive_digest(chunk["segments"], limit=1) + analysis.topics.append(Topic(id=f"t{i}", label=_topic_label(chunk["segments"]), t0_ms=chunk["t0_ms"], + t1_ms=chunk["t1_ms"], summary=_strip_stamps(digest))) + if chunks: + first = minutes.extractive_digest(rows[: min(len(rows), 40)], limit=1) + analysis.one_line = _strip_stamps(first) + analysis.validator = ValidatorReport(kept=len(analysis.decisions) + len(analysis.action_items) + len(analysis.risks) + + len(analysis.open_questions), + notes=["No language model answered; items are the meeting's own lines, chosen by cue words."]) + return analysis + + +def _strip_stamps(text: str) -> str: + return re.sub(r"\[\d{2}:\d{2}:\d{2}\]\s*", "", text or "").strip() + + +_LABEL = re.compile(r"^(?:(?:another|a|the|one|first|second|big|main|core|key)\s+)?" + r"(?:risk|problem|issue|concern|blocker|summary|note|update|open question|question|action item|action|" + r"decision|proposal|suggestion|recap|takeaway)\s*:\s*", re.I) +_OPENER = re.compile(r"^(?:(?:the|a|one|another)\s+(?:core|main|big|real|key|other|first)?\s*(?:problem|issue|risk|concern|thing|point)" + 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+|" + r"i\s+think\s+(?:that\s+)?|so\s+|also,?\s+|and\s+|but\s+|well,?\s+|ok(?:ay)?,?\s+|fine\.?\s+)", re.I) +_SENTENCE = re.compile(r"(?<=[.!?])\s+") + + +def _trim_lead(text: str) -> str: + """Drop a leading label ("Another risk:") or opener ("The core problem is that") so a + title or statement starts on the substance. Words only; the evidence quote stays whole.""" + out = (text or "").strip() + for _ in range(2): + out = _OPENER.sub("", _LABEL.sub("", out)) + return out.strip() + + +def _clean(text: str) -> str: + out = _trim_lead(text) or (text or "").strip() + return out[:1].upper() + out[1:] + + +def _cue_sentence(text: str, pattern: "re.Pattern[str]") -> str: + """The sentence of a line that carries the cue. A cue sentence too short to stand alone + ("Agreed.") gives way to the next sentence when that one carries the cue too, and is + joined with it otherwise.""" + pieces = [p for p in _SENTENCE.split((text or "").strip()) if p] + for i, piece in enumerate(pieces): + if not pattern.search(piece): + continue + out = piece + if len(piece.split()) < 5 and i + 1 < len(pieces): + out = pieces[i + 1] if pattern.search(pieces[i + 1]) else f"{piece} {pieces[i + 1]}" + return _clean(out) + return _clean(text) + + +def _headline(text: str, limit: int = 90) -> str: + """A card title from a transcript line: its first clause, cut at a word, never mid-word.""" + lead = _trim_lead(text) or (text or "").strip() + first = re.split(r"(?<=[.!?])\s+|;\s+|,\s+(?=(?:and|but|so|which|because|while|although)\b)|\s+so that\b", + lead, maxsplit=1)[0].strip() + first = first[:1].upper() + first[1:] + if len(first) <= limit: + return first.rstrip(".,;:") + cut = first[:limit].rsplit(" ", 1)[0].rstrip(".,;:") + return f"{cut}…" + + +def _topic_label(segments: Sequence[Dict[str, Any]]) -> str: + counts: Dict[str, int] = {} + for seg in segments: + for m in minutes._WORD.finditer((seg.get("text") or "").lower()): + w = m.group(0) + if w in minutes._STOP or len(w) < 4: + continue + counts[w] = counts.get(w, 0) + 1 + top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:3] + return ", ".join(w for w, _ in top).capitalize() or "Discussion" + + +# ── entry point ───────────────────────────────────────────────────────────── + + +async def analyse(segments: Sequence[Dict[str, Any]], *, meeting_id: str, meeting_title: str, + call: Optional[ModelCall], model: Optional[str] = None) -> MeetingAnalysis: + """The whole pipeline for one transcript. Never raises on a model failure.""" + rows = [s for s in segments if (s.get("text") or "").strip()] + if not rows: + return MeetingAnalysis(meeting_id=meeting_id, title=meeting_title or "Meeting", degraded="extractive", + validator=ValidatorReport(notes=["There is no transcript to analyse."])) + outage = minutes._Outage(meeting_id) + chunks = minutes.chunk(rows) + cands, answered = await map_candidates(chunks, call=call, outage=outage) + if answered == 0: + return validate(fallback_analysis(rows, meeting_id=meeting_id, meeting_title=meeting_title), rows) + reduced = await reduce_candidates(cands, meeting_title=meeting_title, call=call, outage=outage) + analysis = assemble(cands, reduced, meeting_id=meeting_id, meeting_title=meeting_title, segments=rows, model=model) + analysis = validate(analysis, rows) + if reduced is None and analysis.validator is not None: + analysis.validator.notes.append("The merge step did not answer; items were de-duplicated by text instead.") + if answered < len(chunks) and analysis.validator is not None: + analysis.validator.notes.append(f"{len(chunks) - answered} of {len(chunks)} parts went unanswered by the model.") + return analysis diff --git a/backend/app/meetingsense/intelligence/brief.py b/backend/app/meetingsense/intelligence/brief.py new file mode 100644 index 0000000000000000000000000000000000000000..79a1417f8224e80324bd933400c295c43a3d0cd7 --- /dev/null +++ b/backend/app/meetingsense/intelligence/brief.py @@ -0,0 +1,401 @@ +"""The Executive Brief composer: polished wording for the one-page report, strictly grounded. + +The visual page people expect reads like an executive brief — "Missing DNS verification +tools", not a transcript line. A model writes that wording here, but under rules the +validator can check: + +* every card, row, step and the takeaway names the analysis item it rests on; an unknown + or wrong-kind id is dropped; +* **facts are never the model's to set**: severity, decision status, due bucket and owner + come from the verified analysis, and a proposal can never be worded as a decision; +* numbers and technical terms in the polished text must already appear in the source item + (its text, quote and detail); anything else falls back to the item's own words; +* with no model, or a model that fails, the brief is composed deterministically from the + analysis, so the page always exists and is always true. +""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Set, Tuple + +from .analyze import _headline, json_object +from .validate import normalise +from .schema import ( + ActionBucket, ActionItem, BriefCard, BriefRow, BriefStep, Decision, ExecutiveBrief, MeetingAnalysis, Risk, +) + +log = logging.getLogger(__name__) + +ModelCall = Callable[..., Awaitable[str]] + +STATUS_LABEL = {"confirmed": "Selected", "proposed": "Proposed", "rejected": "Rejected"} +MAX_RISKS = 2 +MAX_BLOCKERS = 2 +MAX_DECISIONS = 6 +MAX_ACTIONS = 3 +MAX_TOKENS = 1200 + +SYSTEM = """\ +You write the one-page executive brief of a meeting from its verified analysis. +Return ONLY a JSON object: +{"eyebrow":" · Executive brief","title":"...","intro":"...", + "risks":[{"item":"r1","title":"...","summary":"...","note":"..."}], + "blockers":[{"item":"r2","title":"...","summary":"..."}], + "decisions":[{"item":"d1","option":"...","impact":"..."}], + "actions":[{"item":"a1","text":"..."}], + "takeaway":{"text":"...","items":["d1","r1"]}} + +Rules: +- Use ONLY the items given, by their ids. Never add a risk, decision or action that is not listed. +- Titles: at most 6 words. Summaries: at most 2 sentences. "note": one short consequence or cause. \ +"impact": at most 18 words on what the option costs or gains. Action "text": at most 14 words. +- Keep every number, name, product and technical term exactly as the source says it. Do not add facts. +- A decision's status is given; if it is "proposed", write it as an option under consideration, never \ +as chosen, agreed or selected. If it is "rejected", say it was set aside. +- "blockers" are risks that depend on something missing or external (tools, access, another team); \ +other risks stay in "risks". A risk appears in one list only. +- The takeaway is at most 40 words, cites the ids it rests on, and does not announce decisions that \ +were only proposed. +- No commentary outside the JSON.""" + +_NUM_WORDS = ("zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|" + "sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|" + "hundred|thousand|million|billion|dozen|half|quarter|percent") +# Digits and spelled-out quantities alike: "50", "3.5", "12%", "fifty", "a dozen". +_NUM = re.compile(rf"\d+(?:[.,]\d+)?%?|\b(?:{_NUM_WORDS})\b", re.I) +_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") +_CHOSEN = re.compile(r"\b(selected|chosen|approved|agreed|decided|decision is|will adopt|adopted|go with|green-?lit)\b", re.I) +_BLOCKER = re.compile(r"\b(block\w*|missing|unavailable|depend\w*|waiting|access|not available|need\w* (?:the )?tool|" + r"environment|another team|external)\b", re.I) +_IMMEDIATE = re.compile(r"\b(today|now|immediately|asap|right away|tonight|tomorrow|this morning|this afternoon)\b", re.I) +_THIS_WEEK = re.compile(r"\b(this week|end of (?:the )?week|by friday|friday|thursday|wednesday|eow|in the next (?:few )?days|" + r"by (?:monday|tuesday|wednesday|thursday|friday)(?! next))\b", re.I) + + +def _words(text: str, limit: int) -> str: + parts = (text or "").split() + if len(parts) <= limit: + return " ".join(parts) + return " ".join(parts[:limit]).rstrip(",;:") + "…" + + +def _sentences(text: str, limit: int) -> str: + pieces = re.split(r"(?<=[.!?])\s+", (text or "").strip()) + return " ".join(p for p in pieces[:limit] if p) + + +def _source_text(item: Any) -> str: + parts = [getattr(item, "statement", ""), getattr(item, "title", ""), getattr(item, "text", ""), + getattr(item, "detail", ""), getattr(item, "root_cause", "")] + parts += list(getattr(item, "solutions", []) or []) + parts += [e.quote for e in (getattr(item, "evidence", []) or [])] + return " ".join(p for p in parts if p) + + +def _proper_nouns(text: str) -> List[str]: + """Capitalised words that are not the first word of their sentence — names and products.""" + out: List[str] = [] + for sentence in re.split(r"(?<=[.!?])\s+", text or ""): + for token in sentence.split()[1:]: + core = token.strip("\"'()[],.;:!?“”’") + if re.fullmatch(r"[A-Z][a-z]{2,}", core): + out.append(core) + return out + + +def terms_preserved(polished: str, source: str, *, sentence_case: bool = True) -> bool: + """Every number and every term that looks technical (CamelCase, ALLCAPS, mixedCase) in the + polished text must already be in the source; in sentence-case fields, so must every + capitalised word after the first (a name or a product). Ordinary words may change; facts + may not. Title-case fields (card titles, option labels) skip the proper-noun rule, since + there every word is capitalised.""" + src = (source or "").lower() + for number in _NUM.findall(polished or ""): + if number.lower() not in src: + return False + src_terms = {t.lower() for t in _TERM.findall(source or "")} + for term in _TERM.findall(polished or ""): + if term.lower() not in src_terms and term.lower() not in src: + return False + if sentence_case: + for noun in _proper_nouns(polished): + if noun.lower() not in src: + return False + return True + + +def bucket_for(action: ActionItem) -> Tuple[ActionBucket, str]: + """The timeline column an action belongs to, from its own due words — never from a model.""" + due = f"{action.due_text or ''} {action.due or ''}" + if _IMMEDIATE.search(due): + return "Immediate", action.due_text or "Immediate" + if _THIS_WEEK.search(due): + return "This week", action.due_text or "This week" + return "Next steps", action.due_text or action.due or "Next steps" + + +def is_blocker(risk: Risk) -> bool: + return bool(_BLOCKER.search(_source_text(risk))) + + +def source_count(analysis: MeetingAnalysis, used: Iterable[str]) -> int: + seen: Set[Any] = set() + for item_id in used: + item = analysis.item(item_id) + for ev in getattr(item, "evidence", []) or []: + seen.add(ev.segment_id or ev.t0_ms) + return len(seen) + + +# ── deterministic composition (the floor) ────────────────────────────────── + + +def compose_fallback(analysis: MeetingAnalysis) -> ExecutiveBrief: + """The brief built from the analysis alone: honest wording, same structure.""" + brief = ExecutiveBrief(title=analysis.title or "Meeting", intro=analysis.one_line or "") + risks = sorted(analysis.risks, key=lambda r: ({"high": 0, "medium": 1, "low": 2}[r.severity], + r.evidence[0].t0_ms if r.evidence else 0)) + for r in risks: + title = _headline(r.title, 60) + card = BriefCard(item=r.id, title=title, summary=_rest(r.detail, title), + note=r.root_cause or (r.solutions[0] if r.solutions else ""), severity=r.severity) + target = brief.blockers if is_blocker(r) and len(brief.blockers) < MAX_BLOCKERS else brief.risks + if len(target) < (MAX_RISKS if target is brief.risks else MAX_BLOCKERS): + target.append(card) + ordered = sorted(analysis.decisions, key=lambda d: ({"confirmed": 0, "rejected": 1, "proposed": 2}[d.status], + d.evidence[0].t0_ms if d.evidence else 0)) + for d in ordered[:MAX_DECISIONS]: + brief.decisions.append(BriefRow(item=d.id, option=_headline(d.statement, 96), status=d.status, + status_label=STATUS_LABEL[d.status])) + for a in analysis.action_items: + bucket, label = bucket_for(a) + brief.actions.append(BriefStep(item=a.id, bucket=bucket, label=label, text=_words(a.text, 16), owner=a.owner)) + brief.actions = _spread(brief.actions)[:MAX_ACTIONS] + brief.open_questions = [q.id for q in analysis.open_questions[:4]] + # The takeaway always rests on a verified item: a conclusion, else a confirmed decision, + # else the gravest risk — and it is never the intro said twice. + candidates: List[Tuple[str, str]] = [(c.id, c.text) for c in analysis.conclusions] + candidates += [(d.id, d.statement) for d in analysis.decisions if d.status == "confirmed"] + candidates += [(r.id, r.detail or r.title) for r in risks] + for item_id, text in candidates: + if text and normalise(text) != normalise(brief.intro): + brief.takeaway, brief.takeaway_items = text, [item_id] + break + intro_n = normalise(brief.intro) + if intro_n and any(normalise(c.summary) in intro_n or intro_n in normalise(c.summary) + for c in brief.risks + brief.blockers if c.summary): + brief.intro = "" # already said on a card + used = [c.item for c in brief.risks + brief.blockers] + [r.item for r in brief.decisions] \ + + [s.item for s in brief.actions] + brief.open_questions + brief.takeaway_items + brief.source_count = source_count(analysis, used) + brief.eyebrow = _eyebrow(analysis) + return brief + + +def _rest(detail: str, title: str) -> str: + """What a card's sentence adds beyond its title: the remainder when the sentence opens with + the title's words, the whole sentence otherwise, nothing when nothing is left.""" + d, t = (detail or "").strip(), (title or "").rstrip("…").strip() + if not d or not t or d.lower() == t.lower(): + return "" if d.lower() == t.lower() else d + if d.lower().startswith(t.lower()): + rest = re.sub(r"^(?:so|and|but|which|because|while|then)\s+", "", d[len(t):].lstrip(" ,;:—-"), flags=re.I) + if len(rest.split()) < 3: + return "" + return rest[:1].upper() + rest[1:] + return d + + +def _spread(steps: List[BriefStep]) -> List[BriefStep]: + """Prefer one step per bucket (Immediate, This week, Next steps) before filling a bucket twice.""" + order: Dict[str, int] = {"Immediate": 0, "This week": 1, "Next steps": 2} + by_bucket: Dict[str, List[BriefStep]] = {} + for s in steps: + by_bucket.setdefault(s.bucket, []).append(s) + out: List[BriefStep] = [] + for bucket in sorted(by_bucket, key=lambda b: order[b]): + out.append(by_bucket[bucket][0]) + for bucket in sorted(by_bucket, key=lambda b: order[b]): + out.extend(by_bucket[bucket][1:]) + return out + + +def _eyebrow(analysis: MeetingAnalysis) -> str: + text = " ".join(t.label for t in analysis.topics).lower() + if re.search(r"\b(deploy|pipeline|agent|api|dns|server|bug|latency|model|prompt|code|release)\b", text): + return "Technical meeting · Executive brief" + if re.search(r"\b(budget|revenue|cost|pricing|forecast|sales)\b", text): + return "Business review · Executive brief" + return "Meeting · Executive brief" + + +# ── the polished composition ─────────────────────────────────────────────── + + +def _catalog(analysis: MeetingAnalysis) -> str: + lines: List[str] = [f"Title as recorded: {analysis.title}", f"One line: {analysis.one_line}"] + for r in analysis.risks: + kind = "blocker-candidate" if is_blocker(r) else "risk" + lines.append(f"{r.id} {kind} [{r.severity}]: {r.title} | detail: {r.detail} | cause: {r.root_cause} | " + f"solutions: {'; '.join(r.solutions)} | quote: \"{r.evidence[0].quote if r.evidence else ''}\"") + for d in analysis.decisions: + lines.append(f"{d.id} decision [{d.status}]: {d.statement} | quote: \"{d.evidence[0].quote if d.evidence else ''}\"") + for a in analysis.action_items: + lines.append(f"{a.id} action: {a.text} | owner: {a.owner or 'not stated'} | due: {a.due_text or 'not stated'}") + for q in analysis.open_questions: + lines.append(f"{q.id} open question: {q.text}") + for c in analysis.conclusions: + lines.append(f"{c.id} conclusion: {c.text}") + return "\n".join(lines) + + +def _card(raw: Dict[str, Any], risk: Risk, *, notes: List[str], fallback: BriefCard) -> BriefCard: + source = _source_text(risk) + title = _words(str(raw.get("title") or ""), 6) + summary = _sentences(str(raw.get("summary") or ""), 2) + note = _words(str(raw.get("note") or ""), 20) + ok = bool(title) and terms_preserved(title, source, sentence_case=False) \ + and all(terms_preserved(t, source) for t in (summary, note) if t) + if not ok: + notes.append(f"{risk.id}: polished wording dropped (unsupported term or number); the meeting's words are shown.") + return fallback + return BriefCard(item=risk.id, title=title, summary=summary, note=note, severity=risk.severity, polished=True) + + +def _row(raw: Dict[str, Any], decision: Decision, *, notes: List[str], fallback: BriefRow) -> BriefRow: + source = _source_text(decision) + option = _words(str(raw.get("option") or ""), 8) + impact = _words(str(raw.get("impact") or ""), 18) + if not option or not terms_preserved(option, source, sentence_case=False) or (impact and not terms_preserved(impact, source)): + notes.append(f"{decision.id}: polished wording dropped (unsupported term or number).") + return fallback + if decision.status != "confirmed" and (_CHOSEN.search(option) or _CHOSEN.search(impact)): + notes.append(f"{decision.id}: wording presented a {decision.status} item as decided; the verified wording is shown.") + return fallback + return BriefRow(item=decision.id, option=option, status=decision.status, status_label=STATUS_LABEL[decision.status], + impact=impact, polished=True) + + +def _step(raw: Dict[str, Any], action: ActionItem, *, notes: List[str], fallback: BriefStep) -> BriefStep: + text = _words(str(raw.get("text") or ""), 14) + if not text or not terms_preserved(text, _source_text(action)): + notes.append(f"{action.id}: polished wording dropped (unsupported term or number).") + return fallback + return BriefStep(item=action.id, bucket=fallback.bucket, label=fallback.label, text=text, owner=action.owner, polished=True) + + +def merge(parsed: Dict[str, Any], analysis: MeetingAnalysis) -> ExecutiveBrief: + """Fold the model's wording onto the deterministic brief, field by field, keeping each + polished field only when the validator accepts it.""" + base = compose_fallback(analysis) + notes: List[str] = [] + out = ExecutiveBrief(**base.model_dump()) + used_any = False + + eyebrow = _words(str(parsed.get("eyebrow") or ""), 6) + if eyebrow and "brief" in eyebrow.lower(): + out.eyebrow = eyebrow + title = _words(str(parsed.get("title") or ""), 12) + if title and terms_preserved(title, _catalog(analysis), sentence_case=False): + out.title, used_any = title, True + intro = _sentences(str(parsed.get("intro") or ""), 1) + if intro and terms_preserved(intro, _catalog(analysis)) and not (_CHOSEN.search(intro) and not any( + d.status == "confirmed" for d in analysis.decisions)): + out.intro, used_any = intro, True + + risks_by_id = {r.id: r for r in analysis.risks} + base_cards = {c.item: c for c in base.risks + base.blockers} + seen: Set[str] = set() + new_risks: List[BriefCard] = [] + new_blockers: List[BriefCard] = [] + for key, target, cap in (("risks", new_risks, MAX_RISKS), ("blockers", new_blockers, MAX_BLOCKERS)): + for raw in parsed.get(key) or []: + if not isinstance(raw, dict): + continue + rid = str(raw.get("item") or "") + risk = risks_by_id.get(rid) + if risk is None or rid in seen or len(target) >= cap: + continue + seen.add(rid) + fallback = base_cards.get(rid) or BriefCard(item=rid, title=_headline(risk.title, 60), summary=risk.detail, + note=risk.root_cause, severity=risk.severity) + card = _card(raw, risk, notes=notes, fallback=fallback) + used_any = used_any or card.polished + target.append(card) + if new_risks or new_blockers: + out.risks, out.blockers = new_risks, new_blockers + + decisions_by_id = {d.id: d for d in analysis.decisions} + base_rows = {r.item: r for r in base.decisions} + new_rows: List[BriefRow] = [] + for raw in parsed.get("decisions") or []: + if not isinstance(raw, dict): + continue + did = str(raw.get("item") or "") + decision = decisions_by_id.get(did) + if decision is None or any(r.item == did for r in new_rows) or len(new_rows) >= MAX_DECISIONS: + continue + fallback = base_rows.get(did) or BriefRow(item=did, option=_headline(decision.statement, 70), status=decision.status, + status_label=STATUS_LABEL[decision.status]) + row = _row(raw, decision, notes=notes, fallback=fallback) + used_any = used_any or row.polished + new_rows.append(row) + if new_rows: + out.decisions = new_rows + + actions_by_id = {a.id: a for a in analysis.action_items} + base_steps = {s.item: s for s in base.actions} + new_steps: List[BriefStep] = [] + for raw in parsed.get("actions") or []: + if not isinstance(raw, dict): + continue + aid = str(raw.get("item") or "") + action = actions_by_id.get(aid) + if action is None or any(s.item == aid for s in new_steps): + continue + bucket, label = bucket_for(action) + fallback = base_steps.get(aid) or BriefStep(item=aid, bucket=bucket, label=label, text=_words(action.text, 16), owner=action.owner) + step = _step(raw, action, notes=notes, fallback=fallback) + used_any = used_any or step.polished + new_steps.append(step) + if new_steps: + out.actions = _spread(new_steps)[:MAX_ACTIONS] + + take = parsed.get("takeaway") if isinstance(parsed.get("takeaway"), dict) else {} + text = _words(str(take.get("text") or ""), 40) + ids = [str(i) for i in (take.get("items") or []) if analysis.item(str(i)) is not None] + if text and ids: + cited = " ".join(_source_text(analysis.item(i)) for i in ids) + confirmed_cited = any(getattr(analysis.item(i), "status", "") == "confirmed" for i in ids) + if terms_preserved(text, cited) and (confirmed_cited or not _CHOSEN.search(text)): + out.takeaway, out.takeaway_items, used_any = text, ids, True + else: + notes.append("takeaway: polished wording dropped (unsupported term or an unconfirmed decision stated as made).") + + used = [c.item for c in out.risks + out.blockers] + [r.item for r in out.decisions] + [s.item for s in out.actions] \ + + out.open_questions + out.takeaway_items + out.source_count = source_count(analysis, used) + out.polished = used_any + out.notes = notes + return out + + +async def compose(analysis: MeetingAnalysis, *, call: Optional[ModelCall]) -> ExecutiveBrief: + """The Executive Brief for an analysis: polished when a model answers and the validator + agrees, the meeting's own words otherwise. Never raises.""" + if call is None or analysis.degraded is not None or not (analysis.risks or analysis.decisions or analysis.action_items): + return compose_fallback(analysis) + try: + raw = await call([{"role": "system", "content": SYSTEM}, {"role": "user", "content": _catalog(analysis)}], + temperature=0.2, max_tokens=MAX_TOKENS, response_format="json") + parsed = json_object(raw) if isinstance(raw, str) else None + except Exception as exc: # noqa: BLE001 — a failed model is a state, not a crash + log.warning("meetingsense: brief composer failed for %s (%s); using the meeting's own words", analysis.meeting_id, exc) + parsed = None + if not parsed: + brief = compose_fallback(analysis) + brief.notes.append("The model did not answer; wording is taken from the transcript.") + return brief + return merge(parsed, analysis) diff --git a/backend/app/meetingsense/intelligence/layout.py b/backend/app/meetingsense/intelligence/layout.py new file mode 100644 index 0000000000000000000000000000000000000000..d90f40d136693a7d831d46372826731514fbe8c6 --- /dev/null +++ b/backend/app/meetingsense/intelligence/layout.py @@ -0,0 +1,78 @@ +"""From a ``MeetingAnalysis`` to the one-page ``VisualSpec`` — deterministically. + +The page InSight renders is a small vocabulary of blocks (risk cards, a status table, a +step timeline, a highlighted conclusion) filled from the analysis. Choosing those blocks is +a job rules do reliably and a model does inconsistently, so the layout is built here, by +rules, from the analysis alone: block order and tints follow what the meeting contains, and +every cell is either an item id or text copied verbatim from an analysis item. + +A later batch may let a model *propose* a different arrangement; its output would be +validated against this schema and this builder stays the fallback. +""" + +from __future__ import annotations + +from typing import List + +from .schema import MeetingAnalysis, VisualBlock, VisualSpec + +SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2} +STATUS_LABEL = {"confirmed": "Confirmed", "proposed": "Proposed", "rejected": "Rejected"} +STATUS_TONE = {"confirmed": "green", "proposed": "amber", "rejected": "rose"} + + +def _clock(ms: int) -> str: + total = max(0, int(ms or 0)) // 1000 + h, rest = divmod(total, 3600) + m, s = divmod(rest, 60) + return f"{h:02d}:{m:02d}:{s:02d}" + + +def build_spec(analysis: MeetingAnalysis, *, analysis_id: str = "", template: str = "autopilot") -> VisualSpec: + blocks: List[VisualBlock] = [] + counts = analysis.counts() + + blocks.append(VisualBlock(type="header", title=analysis.title or "Meeting", text=analysis.one_line or None, + meta={"degraded": analysis.degraded, "counts": counts})) + + kpis = [ + {"label": "Decisions", "value": counts["decisions"], "key": "decisions"}, + {"label": "Action items", "value": counts["actions"], "key": "actions"}, + {"label": "Risks", "value": counts["risks"], "key": "risks"}, + {"label": "Open questions", "value": counts["questions"], "key": "questions"}, + ] + if analysis.speakers: + lead = analysis.speakers[0] + kpis.append({"label": "Most talk time", "value": f"{round(lead.share * 100)}%", "key": "speaker", "sub": lead.label}) + blocks.append(VisualBlock(type="kpi_row", meta={"kpis": kpis})) + + if analysis.risks: + ordered = sorted(analysis.risks, key=lambda r: (SEVERITY_ORDER.get(r.severity, 1), r.evidence[0].t0_ms if r.evidence else 0)) + blocks.append(VisualBlock(type="risk_cards", title="Risks and problems", items=[r.id for r in ordered[:4]])) + + if analysis.decisions: + ordered = sorted(analysis.decisions, key=lambda d: ({"confirmed": 0, "rejected": 1, "proposed": 2}[d.status], + d.evidence[0].t0_ms if d.evidence else 0)) + blocks.append(VisualBlock(type="decision_list", title="Decisions", items=[d.id for d in ordered[:8]])) + if len(ordered) >= 2 and len({d.status for d in ordered}) >= 2: + rows = [[d.statement, STATUS_LABEL[d.status], _clock(d.evidence[0].t0_ms) if d.evidence else ""] for d in ordered[:6]] + blocks.append(VisualBlock(type="comparison_table", title="Decision status at a glance", + columns=["Decision", "Status", "Said at"], rows=rows, + meta={"status_column": 1, "time_column": 2, "items": [d.id for d in ordered[:6]]})) + + if analysis.action_items: + ordered = sorted(analysis.action_items, key=lambda a: (a.due or "9999", a.evidence[0].t0_ms if a.evidence else 0)) + blocks.append(VisualBlock(type="timeline", title="Action items", items=[a.id for a in ordered[:6]])) + + if analysis.open_questions: + blocks.append(VisualBlock(type="section", title="Still open", items=[q.id for q in analysis.open_questions[:6]], tone="amber")) + + if analysis.topics and len(analysis.topics) > 1: + blocks.append(VisualBlock(type="section", title="The meeting in order", items=[t.id for t in analysis.topics[:8]], tone="slate")) + + conclusion = analysis.conclusions[0].text if analysis.conclusions else analysis.one_line + if conclusion: + blocks.append(VisualBlock(type="callout", title="Key conclusion", text=conclusion, + items=[analysis.conclusions[0].id] if analysis.conclusions else [], tone="violet")) + + return VisualSpec(analysis_id=analysis_id, template=template, blocks=blocks) diff --git a/backend/app/meetingsense/intelligence/questions.py b/backend/app/meetingsense/intelligence/questions.py new file mode 100644 index 0000000000000000000000000000000000000000..c63d7e16d849b9ced3a0fa6ea897c02d1abb1c93 --- /dev/null +++ b/backend/app/meetingsense/intelligence/questions.py @@ -0,0 +1,89 @@ +"""Suggested questions: three to five things a colleague who missed the meeting would ask. + +Generated from the *analysis* (never the raw transcript) so they point at what the page +already knows how to answer, and so the call is small. Without a model, templates over the +analysis's own items produce questions that are still specific to this meeting. +""" + +from __future__ import annotations + +from typing import Any, Awaitable, Callable, List, Optional + +from .analyze import json_object +from .schema import MeetingAnalysis, SuggestedQuestion + +ModelCall = Callable[..., Awaitable[str]] + +SYSTEM = """\ +Write the questions a colleague who missed this meeting would ask first. Each must be answerable \ +from the meeting's transcript. Return ONLY JSON: {"questions":[{"text":"...","kind":"decisions|actions|risks|why|open|general"}]}. \ +Four questions, each under 18 words, specific to this meeting (name the things discussed), no yes/no questions.""" + +KIND_VALUES = {"decisions", "actions", "risks", "why", "open", "general"} + + +def _brief(analysis: MeetingAnalysis) -> str: + parts = [f"Title: {analysis.title}", f"Summary: {analysis.one_line}"] + if analysis.decisions: + parts.append("Decisions: " + "; ".join(f"{d.statement} [{d.status}]" for d in analysis.decisions[:6])) + if analysis.risks: + parts.append("Risks: " + "; ".join(r.title for r in analysis.risks[:5])) + if analysis.action_items: + parts.append("Actions: " + "; ".join(f"{a.text}{' (' + a.owner + ')' if a.owner else ''}" for a in analysis.action_items[:6])) + if analysis.open_questions: + parts.append("Open: " + "; ".join(q.text for q in analysis.open_questions[:4])) + return "\n".join(parts) + + +def fallback_questions(analysis: MeetingAnalysis) -> List[SuggestedQuestion]: + out: List[SuggestedQuestion] = [] + + def add(text: str, kind: str) -> None: + if len(out) < 5 and text and all(q.text != text for q in out): + out.append(SuggestedQuestion(id=f"sq{len(out) + 1}", text=text, kind=kind)) # type: ignore[arg-type] + + if analysis.decisions: + d = analysis.decisions[0] + add(f"What was decided about “{_short(d.statement)}”, and was it confirmed?", "decisions") + if analysis.action_items: + a = analysis.action_items[0] + add(f"Who is responsible for “{_short(a.text)}” and by when?", "actions") + if analysis.risks: + r = analysis.risks[0] + add(f"What is the plan for the problem “{_short(r.title)}”?", "risks") + if analysis.topics: + add(f"Why did the discussion focus on {analysis.topics[0].label.lower()}?", "why") + if analysis.open_questions: + add("Which questions were left unresolved at the end?", "open") + add("What are the next steps after this meeting?", "general") + return out[:4] + + +def _short(text: str, limit: int = 60) -> str: + t = " ".join((text or "").split()) + return t if len(t) <= limit else t[:limit].rsplit(" ", 1)[0] + "…" + + +async def suggest(analysis: MeetingAnalysis, *, call: Optional[ModelCall]) -> List[SuggestedQuestion]: + if call is not None and analysis.degraded is None: + try: + raw = await call([{"role": "system", "content": SYSTEM}, {"role": "user", "content": _brief(analysis)}], + temperature=0.3, max_tokens=400, response_format="json") + parsed = json_object(raw) if isinstance(raw, str) else None + except Exception: # noqa: BLE001 + parsed = None + rows: List[Any] = (parsed or {}).get("questions") or [] + out: List[SuggestedQuestion] = [] + for raw_q in rows: + if not isinstance(raw_q, dict): + continue + text = " ".join(str(raw_q.get("text") or "").split())[:200] + kind = str(raw_q.get("kind") or "general").strip().lower() + if text: + out.append(SuggestedQuestion(id=f"sq{len(out) + 1}", text=text, + kind=kind if kind in KIND_VALUES else "general")) # type: ignore[arg-type] + if len(out) == 5: + break + if len(out) >= 3: + return out + return fallback_questions(analysis) diff --git a/backend/app/meetingsense/intelligence/schema.py b/backend/app/meetingsense/intelligence/schema.py new file mode 100644 index 0000000000000000000000000000000000000000..b7dda2e760ceaf3feabb3498f8a38a27278ca1a1 --- /dev/null +++ b/backend/app/meetingsense/intelligence/schema.py @@ -0,0 +1,233 @@ +"""The typed meeting analysis (schema v1) and the visual-summary spec built from it. + +Pydantic models, because this is the contract three consumers share: the extraction +pipeline writes it, the visual renderer reads it, and a future DayPilot pulls it over HTTP. +``json_schema()`` publishes the same shape for them. + +Every item that makes a claim about the meeting carries ``evidence``: the transcript line +(``segment_id`` / ``t0_ms``) and the verbatim ``quote`` the claim rests on. Topics and +speaker statistics are descriptive and carry none. +""" + +from __future__ import annotations + +import time +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, Field + +SCHEMA_VERSION = 1 + +DecisionStatus = Literal["confirmed", "proposed", "rejected"] +Severity = Literal["high", "medium", "low"] +RiskStatus = Literal["open", "mitigated", "resolved"] +ActionStatus = Literal["open", "done", "dropped"] + + +class Evidence(BaseModel): + segment_id: Optional[str] = None + t0_ms: int = 0 + t1_ms: Optional[int] = None + quote: str = "" + + +class Topic(BaseModel): + id: str + label: str + t0_ms: int = 0 + t1_ms: int = 0 + summary: str = "" + + +class Decision(BaseModel): + id: str + statement: str + status: DecisionStatus = "proposed" + topic_id: Optional[str] = None + evidence: List[Evidence] = Field(default_factory=list) + confidence: float = 0.5 + + +class Risk(BaseModel): + id: str + title: str + severity: Severity = "medium" + detail: str = "" + root_cause: str = "" + solutions: List[str] = Field(default_factory=list) + status: RiskStatus = "open" + evidence: List[Evidence] = Field(default_factory=list) + + +class ActionItem(BaseModel): + id: str + text: str + owner: Optional[str] = None + due: Optional[str] = None + due_text: Optional[str] = None + status: ActionStatus = "open" + evidence: List[Evidence] = Field(default_factory=list) + confidence: float = 0.5 + + +class OpenQuestion(BaseModel): + id: str + text: str + owner: Optional[str] = None + evidence: List[Evidence] = Field(default_factory=list) + + +class Conclusion(BaseModel): + id: str + text: str + evidence: List[Evidence] = Field(default_factory=list) + + +class SpeakerStat(BaseModel): + label: str + talk_ms: int = 0 + share: float = 0.0 + lines: int = 0 + + +class ValidatorReport(BaseModel): + kept: int = 0 + dropped: int = 0 + repaired: int = 0 + notes: List[str] = Field(default_factory=list) + + +class MeetingAnalysis(BaseModel): + schema_version: int = SCHEMA_VERSION + meeting_id: str + transcript_version_id: Optional[str] = None + language: str = "en" + title: str = "" + one_line: str = "" + topics: List[Topic] = Field(default_factory=list) + decisions: List[Decision] = Field(default_factory=list) + risks: List[Risk] = Field(default_factory=list) + action_items: List[ActionItem] = Field(default_factory=list) + open_questions: List[OpenQuestion] = Field(default_factory=list) + conclusions: List[Conclusion] = Field(default_factory=list) + speakers: List[SpeakerStat] = Field(default_factory=list) + #: ``None`` when a model wrote it; ``"extractive"`` when it is the meeting's own words. + degraded: Optional[str] = None + model: Optional[str] = None + created_at: float = Field(default_factory=time.time) + validator: Optional[ValidatorReport] = None + + def item(self, item_id: str) -> Optional[BaseModel]: + """Look an item up by id across every list (``d3`` → the third decision, …).""" + for group in (self.decisions, self.risks, self.action_items, self.open_questions, + self.conclusions, self.topics): + for entry in group: + if entry.id == item_id: + return entry + return None + + def counts(self) -> Dict[str, int]: + return { + "topics": len(self.topics), "decisions": len(self.decisions), "risks": len(self.risks), + "actions": len(self.action_items), "questions": len(self.open_questions), + } + + +class SuggestedQuestion(BaseModel): + id: str + text: str + kind: Literal["decisions", "actions", "risks", "why", "open", "general"] = "general" + + +BlockType = Literal[ + "header", "kpi_row", "section", "risk_cards", "decision_list", "comparison_table", + "timeline", "callout", "quote", +] + + +class VisualBlock(BaseModel): + type: BlockType + title: Optional[str] = None + #: Analysis item ids this block renders (cards, lists, timeline steps). + items: List[str] = Field(default_factory=list) + #: Literal text for blocks that are not item lists (callout, quote, header lead). Always + #: copied from the analysis by the layout step — never free text from a model. + text: Optional[str] = None + columns: List[str] = Field(default_factory=list) + rows: List[List[str]] = Field(default_factory=list) + tone: Optional[str] = None + meta: Dict[str, Any] = Field(default_factory=dict) + + +ActionBucket = Literal["Immediate", "This week", "Next steps"] + + +class BriefCard(BaseModel): + """A risk or blocker card. ``severity`` always comes from the analysis item; the words + may be the model's (``polished``) or the item's own.""" + item: str + title: str + summary: str = "" + note: str = "" + severity: Severity = "medium" + polished: bool = False + + +class BriefRow(BaseModel): + """One row of the decision comparison. ``status`` is the verified decision status and + ``status_label`` is its fixed display word — never a model's choice.""" + item: str + option: str + status: DecisionStatus = "proposed" + status_label: str = "Proposed" + impact: str = "" + polished: bool = False + + +class BriefStep(BaseModel): + item: str + bucket: ActionBucket = "Next steps" + label: str = "" + text: str + owner: Optional[str] = None + polished: bool = False + + +class ExecutiveBrief(BaseModel): + """The one-page Executive Brief: what the paper report renders. Every entry carries the + analysis item id it rests on, so the renderer can show the transcript evidence and the + validator can refuse wording that drifted from it.""" + eyebrow: str = "Meeting · Executive brief" + title: str = "" + intro: str = "" + risks: List[BriefCard] = Field(default_factory=list) + blockers: List[BriefCard] = Field(default_factory=list) + decisions: List[BriefRow] = Field(default_factory=list) + actions: List[BriefStep] = Field(default_factory=list) + open_questions: List[str] = Field(default_factory=list) + takeaway: str = "" + takeaway_items: List[str] = Field(default_factory=list) + #: Distinct transcript moments cited by everything on the page. + source_count: int = 0 + #: True when at least one field carries model-written wording. + polished: bool = False + notes: List[str] = Field(default_factory=list) + + +class VisualSpec(BaseModel): + schema_version: int = SCHEMA_VERSION + analysis_id: str = "" + template: str = "executive_brief" + blocks: List[VisualBlock] = Field(default_factory=list) + brief: Optional[ExecutiveBrief] = None + + +def json_schema() -> Dict[str, Any]: + """The published contract: the analysis, the spec and the questions, one document.""" + return { + "schema_version": SCHEMA_VERSION, + "analysis": MeetingAnalysis.model_json_schema(), + "visual": VisualSpec.model_json_schema(), + "brief": ExecutiveBrief.model_json_schema(), + "question": SuggestedQuestion.model_json_schema(), + } diff --git a/backend/app/meetingsense/intelligence/service.py b/backend/app/meetingsense/intelligence/service.py new file mode 100644 index 0000000000000000000000000000000000000000..cfe74a855a99ff309242b704a0bc10e780215828 --- /dev/null +++ b/backend/app/meetingsense/intelligence/service.py @@ -0,0 +1,69 @@ +"""The one entry point the job and the routes call: analyse a meeting and store the result.""" + +from __future__ import annotations + +import logging +from typing import Any, Awaitable, Callable, Dict, Optional + +from .. import store as ms_store +from . import analyze, brief as brief_mod, layout, questions as questions_mod, store +from .schema import MeetingAnalysis, VisualSpec + +log = logging.getLogger(__name__) + +ModelCall = Callable[..., Awaitable[str]] + + +async def compose_spec(analysis: MeetingAnalysis, *, call: Optional[ModelCall], analysis_id: str = "") -> VisualSpec: + """The visual spec for an analysis: the block list (reusable by any renderer) plus the + Executive Brief composed on top of it.""" + spec = layout.build_spec(analysis, analysis_id=analysis_id) + spec.template = "executive_brief" + spec.brief = await brief_mod.compose(analysis, call=call) + return spec + + +async def run_analysis(meeting_id: str, *, call: Optional[ModelCall], model: Optional[str] = None, + transcript_version_id: Optional[str] = None, + progress: Optional[Callable[[str, float], None]] = None) -> Dict[str, Any]: + """Analyse the meeting's current transcript, build the page and the questions, store all + three. Returns what :func:`store.latest` returns. Raises ``ValueError`` when there is no + transcript — the caller turns that into a 409 or a failed job, never into a fake page.""" + meeting = ms_store.get_meeting(meeting_id) or {} + segments = ms_store.get_segments(meeting_id) + if not segments: + raise ValueError("no_transcript") + + def tick(stage: str, fraction: float) -> None: + if progress is not None: + try: + progress(stage, fraction) + except Exception: # noqa: BLE001 + pass + + tick("analysing", 0.1) + analysis = await analyze.analyse(segments, meeting_id=meeting_id, meeting_title=meeting.get("title") or "", + call=call, model=model) + tick("writing the brief", 0.7) + spec = await compose_spec(analysis, call=call) + tick("questions", 0.85) + suggested = await questions_mod.suggest(analysis, call=call) + tick("saving", 0.95) + store.save(meeting_id, analysis, spec, suggested, transcript_version_id=transcript_version_id) + return store.latest(meeting_id) or {} + + +async def recompose(meeting_id: str, *, call: Optional[ModelCall]) -> Dict[str, Any]: + """Write the brief again for the latest analysis without re-extracting. Raises + ``ValueError("no_analysis")`` when there is nothing to lay out.""" + current_row = store.latest(meeting_id) + if not current_row: + raise ValueError("no_analysis") + analysis = MeetingAnalysis.model_validate(current_row["analysis"]) + spec = await compose_spec(analysis, call=call, analysis_id=current_row["id"]) + store.save_spec(meeting_id, current_row["id"], spec) + return store.latest(meeting_id) or {} + + +def current(meeting_id: str) -> Optional[Dict[str, Any]]: + return store.latest(meeting_id) diff --git a/backend/app/meetingsense/intelligence/store.py b/backend/app/meetingsense/intelligence/store.py new file mode 100644 index 0000000000000000000000000000000000000000..f9d62dcfac11d8f8a445f5fc3e310d6a7c0f5210 --- /dev/null +++ b/backend/app/meetingsense/intelligence/store.py @@ -0,0 +1,232 @@ +"""Persistence for analyses, visual specs and first-class action items. + +Additive tables beside the library's, on the same connection (``store._connect``), created +only when the library migrates with ``MEETINGS_INTELLIGENCE`` on. Analyses are versioned +rows, never overwritten: regenerating keeps the previous page until the new one is ready, +and the history stays available. +""" + +from __future__ import annotations + +import json +import time +import uuid +from typing import Any, Dict, List, Optional, Sequence + +from .. import store as ms_store +from .schema import MeetingAnalysis, SuggestedQuestion, VisualSpec + +_TABLES = ( + """ + CREATE TABLE IF NOT EXISTS ms_analyses( + id TEXT PRIMARY KEY, + meeting_id TEXT NOT NULL, + transcript_version_id TEXT, + schema_version INTEGER NOT NULL, + model TEXT, + degraded TEXT, + status TEXT NOT NULL DEFAULT 'ready', + json TEXT NOT NULL, + report_json TEXT, + questions_json TEXT, + created_at REAL NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS ms_visual_specs( + id TEXT PRIMARY KEY, + analysis_id TEXT NOT NULL, + meeting_id TEXT NOT NULL, + template TEXT NOT NULL, + spec_json TEXT NOT NULL, + created_at REAL NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS ms_action_items( + id TEXT PRIMARY KEY, + meeting_id TEXT NOT NULL, + analysis_id TEXT NOT NULL, + item_id TEXT NOT NULL, + text TEXT NOT NULL, + owner TEXT, + due TEXT, + due_text TEXT, + status TEXT NOT NULL DEFAULT 'open', + evidence_json TEXT, + updated_at REAL NOT NULL, + UNIQUE(meeting_id, item_id) + ) + """, +) +_INDEXES = ( + "CREATE INDEX IF NOT EXISTS ix_ms_analyses_meeting ON ms_analyses(meeting_id, created_at)", + "CREATE INDEX IF NOT EXISTS ix_ms_visual_specs_meeting ON ms_visual_specs(meeting_id, created_at)", + "CREATE INDEX IF NOT EXISTS ix_ms_action_items_meeting ON ms_action_items(meeting_id)", +) + + +def _connect(): + con = ms_store._connect() + try: + # WAL and a busy timeout: the worker, the live socket and request handlers all write + # to this file. Best effort — a test's in-memory connection may refuse either. + con.execute("PRAGMA busy_timeout = 5000") + except Exception: # noqa: BLE001 + pass + return con + + +def migrate() -> None: + con = _connect() + try: + cur = con.cursor() + for statement in _TABLES: + cur.execute(statement) + for statement in _INDEXES: + cur.execute(statement) + con.commit() + finally: + con.close() + + +def _now() -> float: + return time.time() + + +def save(meeting_id: str, analysis: MeetingAnalysis, spec: VisualSpec, questions: Sequence[SuggestedQuestion], *, + transcript_version_id: Optional[str] = None) -> str: + """One new analysis row, its spec, and the action items (statuses of items that already + existed with the same text are carried over, so a tick survives regeneration).""" + aid = uuid.uuid4().hex + spec.analysis_id = aid + now = _now() + con = _connect() + try: + previous = {r["text"]: r for r in con.execute( + "SELECT text, owner, due, status FROM ms_action_items WHERE meeting_id = ?", (meeting_id,)).fetchall()} + con.execute( + "INSERT INTO ms_analyses(id, meeting_id, transcript_version_id, schema_version, model, degraded, status," + " json, report_json, questions_json, created_at) VALUES (?, ?, ?, ?, ?, ?, 'ready', ?, ?, ?, ?)", + (aid, meeting_id, transcript_version_id, analysis.schema_version, analysis.model, analysis.degraded, + analysis.model_dump_json(), analysis.validator.model_dump_json() if analysis.validator else None, + json.dumps([q.model_dump() for q in questions]), now), + ) + con.execute( + "INSERT INTO ms_visual_specs(id, analysis_id, meeting_id, template, spec_json, created_at) VALUES (?, ?, ?, ?, ?, ?)", + (uuid.uuid4().hex, aid, meeting_id, spec.template, spec.model_dump_json(), now), + ) + con.execute("DELETE FROM ms_action_items WHERE meeting_id = ?", (meeting_id,)) + for item in analysis.action_items: + prior = previous.get(item.text) + con.execute( + "INSERT INTO ms_action_items(id, meeting_id, analysis_id, item_id, text, owner, due, due_text, status," + " evidence_json, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (uuid.uuid4().hex, meeting_id, aid, item.id, item.text, + (prior["owner"] if prior and prior["owner"] else item.owner), + (prior["due"] if prior and prior["due"] else item.due), item.due_text, + (prior["status"] if prior else item.status), + json.dumps([e.model_dump() for e in item.evidence]), now), + ) + con.commit() + finally: + con.close() + return aid + + +def save_spec(meeting_id: str, analysis_id: str, spec: VisualSpec) -> str: + """A new visual spec for an existing analysis (re-layout). The previous spec stays.""" + spec.analysis_id = analysis_id + sid = uuid.uuid4().hex + con = _connect() + try: + con.execute( + "INSERT INTO ms_visual_specs(id, analysis_id, meeting_id, template, spec_json, created_at) VALUES (?, ?, ?, ?, ?, ?)", + (sid, analysis_id, meeting_id, spec.template, spec.model_dump_json(), _now()), + ) + con.commit() + finally: + con.close() + return sid + + +def latest(meeting_id: str) -> Optional[Dict[str, Any]]: + """The newest analysis with its spec and questions, as plain dicts, or ``None``.""" + con = _connect() + try: + row = con.execute("SELECT * FROM ms_analyses WHERE meeting_id = ? AND status = 'ready' ORDER BY created_at DESC LIMIT 1", + (meeting_id,)).fetchone() + if row is None: + return None + spec = con.execute("SELECT spec_json FROM ms_visual_specs WHERE analysis_id = ? ORDER BY created_at DESC LIMIT 1", + (row["id"],)).fetchone() + count = con.execute("SELECT COUNT(*) AS n FROM ms_analyses WHERE meeting_id = ?", (meeting_id,)).fetchone() + return { + "id": row["id"], "meeting_id": meeting_id, "created_at": row["created_at"], "model": row["model"], + "degraded": row["degraded"], "schema_version": row["schema_version"], + "transcript_version_id": row["transcript_version_id"], + "analysis": json.loads(row["json"]), + "report": json.loads(row["report_json"]) if row["report_json"] else None, + "questions": json.loads(row["questions_json"]) if row["questions_json"] else [], + "visual": json.loads(spec["spec_json"]) if spec else None, + "versions": int(count["n"]) if count else 1, + } + finally: + con.close() + + +def _action_row(row) -> Dict[str, Any]: + out = dict(row) + raw = out.pop("evidence_json", None) + try: + out["evidence"] = json.loads(raw) if raw else [] + except ValueError: + out["evidence"] = [] + return out + + +def actions(meeting_id: str) -> List[Dict[str, Any]]: + con = _connect() + try: + return [_action_row(r) for r in con.execute( + "SELECT * FROM ms_action_items WHERE meeting_id = ? ORDER BY updated_at ASC", (meeting_id,)).fetchall()] + finally: + con.close() + + +UPDATABLE = ("status", "owner", "due", "text") + + +def update_action(meeting_id: str, item_id: str, **fields: Any) -> Optional[Dict[str, Any]]: + sets, args = [], [] + for key in UPDATABLE: + if key in fields and fields[key] is not None: + value = str(fields[key]).strip() + if key == "status" and value not in ("open", "done", "dropped"): + continue + sets.append(f"{key} = ?") + args.append(value or None) + con = _connect() + try: + if sets: + sets.append("updated_at = ?") + args.append(_now()) + con.execute(f"UPDATE ms_action_items SET {', '.join(sets)} WHERE meeting_id = ? AND item_id = ?", + args + [meeting_id, item_id]) + con.commit() + row = con.execute("SELECT * FROM ms_action_items WHERE meeting_id = ? AND item_id = ?", (meeting_id, item_id)).fetchone() + return _action_row(row) if row else None + finally: + con.close() + + +def delete_for(meeting_id: str) -> Dict[str, int]: + con = _connect() + try: + out = {} + for table in ("ms_action_items", "ms_visual_specs", "ms_analyses"): + out[table] = con.execute(f"DELETE FROM {table} WHERE meeting_id = ?", (meeting_id,)).rowcount + con.commit() + return out + finally: + con.close() diff --git a/backend/app/meetingsense/intelligence/validate.py b/backend/app/meetingsense/intelligence/validate.py new file mode 100644 index 0000000000000000000000000000000000000000..d2458066b95178f5ead1b176524f77f3e45a7cdd --- /dev/null +++ b/backend/app/meetingsense/intelligence/validate.py @@ -0,0 +1,142 @@ +"""Evidence validation: an item stays only if its quote really is in the line it cites. + +The model is asked for verbatim quotes and the timestamp of the line they come from. Models +paraphrase, shift a stamp by a line, or invent a tidy sentence nobody said. This module is +the difference between a page that *looks* grounded and one that is: it resolves each +citation to a real segment (by id, else by nearest start time), compares the quote with the +segment text (substring after normalisation, else a fuzzy ratio against the best window), +tries the neighbouring lines, and drops the item when nothing matches. + +Dropping is reported, not hidden: the ``ValidatorReport`` travels with the analysis and the +page shows the count. +""" + +from __future__ import annotations + +import difflib +import re +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from .schema import Evidence, MeetingAnalysis, ValidatorReport + +#: Fuzzy threshold for "the quote is this line, give or take a word". +MATCH_RATIO = 0.85 +#: A quote shorter than this (in normalised characters) matches too easily to count. +MIN_QUOTE_CHARS = 8 +#: How far (ms) a cited start may sit from a real segment start before it is "not that line". +STAMP_TOLERANCE_MS = 4000 + +_WS = re.compile(r"\s+") +_PUNCT = re.compile(r"[^\w\s]", re.UNICODE) + + +def normalise(text: str) -> str: + return _WS.sub(" ", _PUNCT.sub(" ", (text or "").lower())).strip() + + +class SegmentIndex: + """Segments by id and by start time, with neighbours.""" + + def __init__(self, segments: Sequence[Dict[str, Any]]): + self.rows: List[Dict[str, Any]] = [s for s in segments if (s.get("text") or "").strip()] + self.rows.sort(key=lambda s: int(s.get("t0_ms") or 0)) + self.by_id = {str(s.get("id")): i for i, s in enumerate(self.rows) if s.get("id")} + + def locate(self, ev: Evidence) -> Optional[int]: + if ev.segment_id and ev.segment_id in self.by_id: + return self.by_id[ev.segment_id] + if not self.rows: + return None + best, best_gap = None, None + for i, s in enumerate(self.rows): + gap = abs(int(s.get("t0_ms") or 0) - int(ev.t0_ms or 0)) + if best_gap is None or gap < best_gap: + best, best_gap = i, gap + if best is not None and best_gap is not None and best_gap <= STAMP_TOLERANCE_MS: + return best + return best if ev.quote else None + + def neighbours(self, i: int, span: int = 1) -> List[int]: + return [j for j in range(i - span, i + span + 1) if 0 <= j < len(self.rows)] + + +def quote_matches(quote: str, text: str) -> Tuple[bool, float]: + """Does ``quote`` appear in ``text``? Exact (normalised) substring first, then the best + fuzzy window of the same word length.""" + q = normalise(quote) + t = normalise(text) + if len(q) < MIN_QUOTE_CHARS: + return (bool(q) and q in t, 1.0 if q and q in t else 0.0) + if q in t: + return True, 1.0 + qw = q.split() + tw = t.split() + if not qw or not tw: + return False, 0.0 + best = 0.0 + width = len(qw) + for start in range(0, max(1, len(tw) - width + 1)): + window = " ".join(tw[start:start + width]) + ratio = difflib.SequenceMatcher(None, q, window).ratio() + if ratio > best: + best = ratio + if best >= 0.98: + break + return best >= MATCH_RATIO, best + + +def verify(ev: Evidence, index: SegmentIndex) -> Optional[Evidence]: + """The evidence re-anchored to the segment that really carries the quote, or ``None``.""" + i = index.locate(ev) + if i is None: + return None + candidates = [i] + [j for j in index.neighbours(i) if j != i] + for j in candidates: + seg = index.rows[j] + ok, _ = quote_matches(ev.quote, seg.get("text") or "") + if ok: + return Evidence(segment_id=str(seg.get("id")) if seg.get("id") else None, + t0_ms=int(seg.get("t0_ms") or 0), t1_ms=seg.get("t1_ms"), quote=ev.quote) + return None + + +def validate(analysis: MeetingAnalysis, segments: Sequence[Dict[str, Any]]) -> MeetingAnalysis: + """Return the analysis with unverifiable items removed and a report attached. + + Items with no evidence at all are dropped too, except when the analysis is extractive + (every extractive item is its own line, so this is a consistency check there). + """ + index = SegmentIndex(segments) + report = ValidatorReport() + + def keep(items: List[Any], name: str) -> List[Any]: + kept: List[Any] = [] + for item in items: + verified: List[Evidence] = [] + repaired = False + for ev in item.evidence: + good = verify(ev, index) + if good is None: + continue + if good.segment_id != ev.segment_id or good.t0_ms != ev.t0_ms: + repaired = True + verified.append(good) + if verified: + item.evidence = verified + kept.append(item) + report.kept += 1 + if repaired: + report.repaired += 1 + else: + report.dropped += 1 + label = getattr(item, "statement", None) or getattr(item, "title", None) or getattr(item, "text", "") + report.notes.append(f"{name}: dropped “{str(label)[:80]}” — its quote is not in the transcript.") + return kept + + analysis.decisions = keep(list(analysis.decisions), "decision") + analysis.risks = keep(list(analysis.risks), "risk") + analysis.action_items = keep(list(analysis.action_items), "action") + analysis.open_questions = keep(list(analysis.open_questions), "question") + analysis.conclusions = keep(list(analysis.conclusions), "conclusion") + analysis.validator = report + return analysis diff --git a/backend/app/meetingsense/library/__init__.py b/backend/app/meetingsense/library/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..bb25b69160cbcba850e9772b8e25e2248c49222e --- /dev/null +++ b/backend/app/meetingsense/library/__init__.py @@ -0,0 +1,28 @@ +"""Meetings Library — the additive upgrade to the Meetings view (``MEETINGS_LIBRARY_UPGRADE``). + +One library over every meeting (recorded live, imported, synced from a folder, scheduled, and +read-only Teams rooms), imports of Insta360 / AIRecord archives, audio and transcript files, +opt-in folder sync, and offline transcription through HomePilot's own speech provider. + +See ``docs/meetings-library.md``. +""" + +from . import jobs as _jobs +from . import sync as _sync +from .routes import router + + +async def _startup() -> None: + _jobs.start_worker() + _sync.start_sync_loop() + + +async def _shutdown() -> None: + await _jobs.stop_worker() + await _sync.stop_sync_loop() + + +router.add_event_handler("startup", _startup) +router.add_event_handler("shutdown", _shutdown) + +__all__ = ["router"] diff --git a/backend/app/meetingsense/library/adapters.py b/backend/app/meetingsense/library/adapters.py new file mode 100644 index 0000000000000000000000000000000000000000..815a829051ec0868fc796bb7e75ac336fbd7b422 --- /dev/null +++ b/backend/app/meetingsense/library/adapters.py @@ -0,0 +1,332 @@ +"""Finding meetings in a pile of files — an upload, an archive, or a synced folder. + +One discovery routine for all three, so an Insta360 folder looks the same whether it was +zipped and uploaded or picked up from ``D:\\AIRecord``: + +* **AIRecord** — a folder holding ``.airecord_data/record_info.json``. The recording beside + it is the meeting's audio; ``audio_asr_total.json`` is its transcript (often empty); the + ASR slices are the device's own working copies and are not imported twice. +* **Audio** — any other recording, paired with a transcript that shares its name + (``call.mp3`` + ``call.srt``). +* **Transcript only** — a text, subtitle or JSON transcript with no audio. + +Discovery only describes; it never copies or changes a file. Names are kept exactly — the +Insta360 ``∶`` in ``12∶31∶51`` is a real character and stays one. +""" + +from __future__ import annotations + +import os +import re +import stat +import time +import unicodedata +import zipfile +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path, PurePosixPath +from typing import Any, Dict, List, Optional, Tuple + +from .media import AUDIO_EXTENSIONS +from .parsers import TRANSCRIPT_EXTENSIONS + +_IGNORED_NAMES = {"dot.txt", "desktop.ini", "thumbs.db", ".ds_store"} +_TEMP_SUFFIXES = (".tmp", ".part", ".crdownload", ".partial", ".download", ".lock") + +#: Limits for an uploaded archive. Generous for real meetings, tight enough that an archive +#: built to expand without end is refused before it fills the disk. +ZIP_MAX_ENTRIES = 5000 +ZIP_MAX_RATIO = 200 +ZIP_MAX_DEPTH = 12 + + +@dataclass +class FileRef: + rel: str # path relative to the scan root, POSIX separators + path: Path # absolute path on disk + size: int + mtime: float + role: str # audio | transcript | metadata | slice + + def as_dict(self) -> Dict[str, Any]: + return {"rel": self.rel, "name": PurePosixPath(self.rel).name, "size": self.size, + "mtime": self.mtime, "role": self.role} + + +@dataclass +class Candidate: + key: str + kind: str # airecord | audio | transcript + title: str + started_at: Optional[float] + ended_at: Optional[float] = None + claimed_duration_ms: Optional[int] = None + audio: Optional[FileRef] = None + transcript: Optional[FileRef] = None + metadata_files: List[FileRef] = field(default_factory=list) + ignored: List[FileRef] = field(default_factory=list) + origin: Optional[Tuple[str, str]] = None # ("airecord", localMeetingId) + raw_metadata: Dict[str, Any] = field(default_factory=dict) + time_source: str = "metadata" # metadata | filename | file_date + warnings: List[str] = field(default_factory=list) + + @property + def files(self) -> List[FileRef]: + out = [f for f in (self.audio, self.transcript) if f is not None] + return out + list(self.metadata_files) + + +def _skip(name: str) -> bool: + lower = name.lower() + return ( + lower in _IGNORED_NAMES + or lower.endswith(_TEMP_SUFFIXES) + or name.startswith("~$") + or name.startswith("._") + ) + + +def _ref(root: Path, path: Path, role: str) -> FileRef: + st = path.stat() + return FileRef(rel=path.relative_to(root).as_posix(), path=path, size=st.st_size, + mtime=st.st_mtime, role=role) + + +# 2026_10_10 12∶31∶51 · 2026-10-10 12-31-51 · 20261010_123151 · 2026-10-10T12:31:51 +_NAME_TIME = re.compile( + r"(20\d{2})[-_.]?(\d{2})[-_.]?(\d{2})[ T_-]+(\d{2})[:∶꞉\-_.]?(\d{2})[:∶꞉\-_.]?(\d{2})" +) + + +def time_from_name(name: str) -> Optional[datetime]: + m = _NAME_TIME.search(unicodedata.normalize("NFKC", name)) + if not m: + return None + try: + return datetime(*(int(g) for g in m.groups())) + except ValueError: + return None + + +def display_title(name: str, *, fallback_prefix: str = "Imported meeting") -> str: + """A person-readable title. A name that is only a timestamp becomes "… · Oct 10, 12:31"; + "Design Review 2026-02-10 13-00-00" becomes "Design Review" (the time is kept as the + meeting's start, so it still shows on the card).""" + stem = name.strip() + when = time_from_name(stem) + if not when: + return stem + normalized = unicodedata.normalize("NFKC", stem) + leftover = re.sub(r"\s{2,}", " ", _NAME_TIME.sub(" ", normalized)).strip(" -_.·()[]") + if not leftover: + return f"{fallback_prefix} · {when.strftime('%b')} {when.day}, {when.strftime('%H:%M')}" + return leftover + + +def _airecord_candidate(root: Path, folder: Path) -> Candidate: + import json # noqa: PLC0415 + + data_dir = folder / ".airecord_data" + info_path = data_dir / "record_info.json" + info: Dict[str, Any] = {} + warnings: List[str] = [] + try: + info = json.loads(info_path.read_text(encoding="utf-8-sig")) + except (OSError, ValueError): + warnings.append("record_info.json could not be read; times come from the folder name.") + name = str(info.get("meetingname") or folder.name) + started = info.get("startTime") + ended = info.get("endTime") + started_at = float(started) if isinstance(started, (int, float)) and started > 0 else None + ended_at = float(ended) if isinstance(ended, (int, float)) and ended > 0 else None + time_source = "metadata" + if started_at is None: + when = time_from_name(name) or time_from_name(folder.name) + if when: + started_at, time_source = when.timestamp(), "filename" + claimed = info.get("duration") + claimed_ms = int(claimed) if isinstance(claimed, (int, float)) and claimed > 0 else None + + audio_files = [p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS + and not _skip(p.name)] + # The recording that carries the meeting's own name, else the largest. + audio_files.sort(key=lambda p: (p.stem != name and p.stem != folder.name, -p.stat().st_size)) + cand = Candidate( + key=folder.relative_to(root).as_posix() or ".", + kind="airecord", + title=display_title(name), + started_at=started_at, + ended_at=ended_at, + claimed_duration_ms=claimed_ms, + audio=_ref(root, audio_files[0], "audio") if audio_files else None, + raw_metadata={"record_info": info}, + time_source=time_source, + warnings=warnings, + ) + local_id = str(info.get("localMeetingId") or "").strip() + if local_id and local_id.lower() != "none": + cand.origin = ("airecord", local_id) + for extra in audio_files[1:]: + cand.ignored.append(_ref(root, extra, "audio")) + if info_path.is_file(): + cand.metadata_files.append(_ref(root, info_path, "metadata")) + asr = data_dir / "audio_asr_total.json" + if asr.is_file(): + cand.transcript = _ref(root, asr, "transcript") + slices = data_dir / "asr_audio_slice" + if slices.is_dir(): + for p in sorted(slices.iterdir()): + if p.is_file(): + cand.ignored.append(_ref(root, p, "slice")) + if not cand.audio: + cand.warnings.append("No recording was found in this AIRecord folder.") + description = str(info.get("description") or "").strip() + if description and description != "xxxx会议描述": # the device's placeholder text + cand.raw_metadata["description"] = description + return cand + + +def _file_time(path: Path, name: str) -> Tuple[Optional[float], str]: + when = time_from_name(name) + if when: + return when.timestamp(), "filename" + try: + return path.stat().st_mtime, "file_date" + except OSError: + return None, "file_date" + + +def discover(root: Path, *, max_files: int = 20000, max_depth: int = 6) -> List[Candidate]: + """Every meeting found below ``root``, oldest first. Never follows symlinks.""" + root = root.resolve() + candidates: List[Candidate] = [] + airecord_dirs: List[Path] = [] + loose_audio: Dict[str, Path] = {} + loose_text: Dict[str, Path] = {} + seen = 0 + + for dirpath, dirnames, filenames in os.walk(root, followlinks=False): + current = Path(dirpath) + depth = len(current.relative_to(root).parts) + if depth > max_depth: + dirnames[:] = [] + continue + if (current / ".airecord_data" / "record_info.json").is_file(): + airecord_dirs.append(current) + dirnames[:] = [] # its contents belong to the recording + continue + dirnames[:] = sorted(d for d in dirnames if not d.startswith(".") and d != "__MACOSX") + for fname in sorted(filenames): + seen += 1 + if seen > max_files: + break + if fname.startswith(".") or _skip(fname): + continue + path = current / fname + try: + if path.is_symlink() or not path.is_file(): + continue + except OSError: + continue + ext = path.suffix.lower() + key = (current / path.stem).relative_to(root).as_posix() + if ext in AUDIO_EXTENSIONS: + loose_audio[key] = path + elif ext in TRANSCRIPT_EXTENSIONS: + loose_text.setdefault(key, path) + + for folder in airecord_dirs: + candidates.append(_airecord_candidate(root, folder)) + + for key, path in loose_audio.items(): + started_at, source = _file_time(path, path.name) + cand = Candidate(key=key, kind="audio", title=display_title(path.stem, fallback_prefix="Recording"), + started_at=started_at, audio=_ref(root, path, "audio"), time_source=source) + if source == "file_date": + cand.warnings.append("The start time comes from the file's date; check it.") + text = loose_text.pop(key, None) + if text is not None: + cand.transcript = _ref(root, text, "transcript") + candidates.append(cand) + + for key, path in loose_text.items(): + started_at, source = _file_time(path, path.name) + candidates.append(Candidate(key=key, kind="transcript", + title=display_title(path.stem, fallback_prefix="Transcript"), + started_at=started_at, transcript=_ref(root, path, "transcript"), + time_source=source)) + + candidates.sort(key=lambda c: (c.started_at or 0, c.key)) + return candidates + + +# ── archives ──────────────────────────────────────────────────────────────── + + +class UnsafeArchive(ValueError): + pass + + +def _safe_member(name: str) -> Optional[str]: + """The member's relative path, normalised — or None for anything that escapes.""" + name = unicodedata.normalize("NFC", name.replace("\\", "/")) + if not name or name.startswith("/") or re.match(r"^[A-Za-z]:", name): + return None + parts = [p for p in name.split("/") if p not in ("", ".")] + if any(p == ".." for p in parts) or len(parts) > ZIP_MAX_DEPTH: + return None + return "/".join(parts) + + +def extract_zip(archive: Path, dest: Path, *, max_total_bytes: int) -> List[str]: + """Extract an upload safely: no path escapes, no symlinks, no zip bombs. + + Returns the relative paths written. Raises :class:`UnsafeArchive` with a reason a person + can act on; nothing is left half-extracted to be mistaken for an import. + """ + written: List[str] = [] + try: + zf = zipfile.ZipFile(archive) + except zipfile.BadZipFile as exc: + raise UnsafeArchive("This is not a ZIP archive HomePilot can open.") from exc + with zf: + infos = zf.infolist() + if len(infos) > ZIP_MAX_ENTRIES: + raise UnsafeArchive(f"The archive has more than {ZIP_MAX_ENTRIES} files.") + total = sum(i.file_size for i in infos) + if total > max_total_bytes: + raise UnsafeArchive("The archive expands to more than the import size limit.") + dest.mkdir(parents=True, exist_ok=True) + for info in infos: + rel = _safe_member(info.filename) + if rel is None: + raise UnsafeArchive(f"Refused an unsafe path in the archive: “{info.filename}”") + mode = info.external_attr >> 16 + if stat.S_ISLNK(mode): + continue # a link inside an upload points somewhere the user did not upload + if info.is_dir(): + (dest / rel).mkdir(parents=True, exist_ok=True) + continue + if info.compress_size and info.file_size > 10 * 1024 * 1024 and \ + info.file_size / max(1, info.compress_size) > ZIP_MAX_RATIO: + raise UnsafeArchive("The archive is compressed suspiciously well (a possible zip bomb).") + target = dest / rel + target.parent.mkdir(parents=True, exist_ok=True) + written_bytes = 0 + with zf.open(info) as src, open(target, "wb") as out: + while True: + chunk = src.read(1024 * 1024) + if not chunk: + break + written_bytes += len(chunk) + if written_bytes > info.file_size + 1024: + raise UnsafeArchive("An archive member is larger than it claims.") + out.write(chunk) + # Keep the recorder's own timestamp — it is what "newest" sorts by. + try: + ts = time.mktime(info.date_time + (0, 0, -1)) + os.utime(target, (ts, ts)) + except (OverflowError, ValueError, OSError): + pass + written.append(rel) + return written diff --git a/backend/app/meetingsense/library/config.py b/backend/app/meetingsense/library/config.py new file mode 100644 index 0000000000000000000000000000000000000000..015b47dbe3d4f8c8c9a0c9d487f9dffa4d2e1413 --- /dev/null +++ b/backend/app/meetingsense/library/config.py @@ -0,0 +1,122 @@ +"""Meetings Library configuration — its own rollout gate, separate from MeetingSense's. + +``MEETINGS_LIBRARY_UPGRADE`` turns on the unified library, imports, folder sync and offline +transcription. It defaults **off**: with it off the Meetings view, its routes and the live +recorder are exactly what they were, and turning it off again leaves every table and file the +upgrade wrote where it is, ready for the next time it is turned on. + +Kept out of ``meetingsense.config`` on purpose: that module's key set is frozen by a test, +and a rollout gate that has to be removable must not be woven into the config it sits beside. + +Folder sync reads the host's disk, so it is allowed only below roots the *operator* lists in +``MEETINGS_SYNC_ALLOWED_ROOTS``. A path typed into the browser grants nothing by itself. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import List, Tuple + + +def _flag(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _int(name: str, default: int) -> int: + raw = os.getenv(name, "").strip() + try: + return int(raw) if raw else default + except ValueError: + return default + + +def _roots(raw: str) -> Tuple[str, ...]: + """Allowed sync roots: separated by ``;`` or newlines (``:`` would split ``D:\\``).""" + out: List[str] = [] + for part in raw.replace("\n", ";").split(";"): + part = part.strip() + if part: + out.append(part) + return tuple(out) + + +#: Where a Windows install keeps Insta360 / AIRecord recordings. A suggestion for the +#: Folder Sync form, never a path the server reads on its own. +DEFAULT_SYNC_SUGGESTION = "D:\\AIRecord" + + +@dataclass(frozen=True) +class LibraryConfig: + enabled: bool = False + #: Offline transcription and summaries for imported recordings. On with the library; + #: separate so an operator can keep imports but stop all background inference. + processing: bool = True + #: Meeting intelligence: the typed analysis, the visual summary page, suggested questions + #: and first-class action items (``MEETINGS_INTELLIGENCE``). Off by default; needs the + #: library. Turning it off hides the routes and the page; nothing written is removed. + intelligence: bool = False + #: After a model has analysed a meeting, a title that names nothing — a timestamp, a + #: recorder default, the library's own "Imported meeting · Oct 10, 12:31" — gives way to + #: the analysis title (``MEETINGS_AUTO_TITLE``, on by default). A title a person typed is + #: never touched, and an extractive analysis never renames anything. + auto_title: bool = True + sync_roots: Tuple[str, ...] = () + #: Upper bound for one uploaded file (MB). Meetings are long; an hour of 128 kb/s MP3 + #: is ~60 MB and an hour of WAV ~600 MB. + max_upload_mb: int = 2048 + #: Seconds of audio per transcription chunk — bounds memory on long recordings and gives + #: the Processing view progress it can show. + stt_chunk_s: int = 300 + #: How often enabled folder sources are reconciled. + sync_interval_s: int = 60 + #: A file must keep its size and mtime this long before it is imported, so a recording + #: still being written is never indexed half-finished. + stability_s: int = 10 + + def as_dict(self) -> dict: + return { + "enabled": self.enabled, + "processing": self.processing, + "intelligence": bool(self.enabled and self.intelligence), + "sync": bool(self.sync_roots), + "sync_suggestion": DEFAULT_SYNC_SUGGESTION, + "max_upload_mb": self.max_upload_mb, + } + + +def load_library_config() -> LibraryConfig: + """Read per call, like ``meetingsense.config.load_config``, so tests can vary it.""" + return LibraryConfig( + enabled=_flag("MEETINGS_LIBRARY_UPGRADE", False), + processing=_flag("MEETINGS_LIBRARY_PROCESSING", True), + intelligence=_flag("MEETINGS_INTELLIGENCE", False), + auto_title=_flag("MEETINGS_AUTO_TITLE", True), + sync_roots=_roots(os.getenv("MEETINGS_SYNC_ALLOWED_ROOTS", "")), + max_upload_mb=max(1, _int("MEETINGS_IMPORT_MAX_MB", 2048)), + stt_chunk_s=max(30, _int("MEETINGS_STT_CHUNK_S", 300)), + sync_interval_s=max(5, _int("MEETINGS_SYNC_INTERVAL_S", 60)), + stability_s=max(0, _int("MEETINGS_SYNC_STABILITY_S", 10)), + ) + + +def media_root() -> Path: + """HomePilot-owned storage for imported meeting files. + + A sibling of the uploads directory rather than inside it, so meeting audio is never + reachable through the generic ``/files/`` route — it is served only by the library's own + media route, which checks who is asking. + """ + override = os.getenv("MEETINGS_LIBRARY_DIR", "").strip() + if override: + root = Path(override) + else: + from ...files import _upload_root # noqa: PLC0415 — keep import-light + + root = _upload_root().parent / "meetings_library" + root.mkdir(parents=True, exist_ok=True) + return root diff --git a/backend/app/meetingsense/library/db.py b/backend/app/meetingsense/library/db.py new file mode 100644 index 0000000000000000000000000000000000000000..71202ed1bdd341badcaec4c5a9afd4f8a02dd14c --- /dev/null +++ b/backend/app/meetingsense/library/db.py @@ -0,0 +1,1062 @@ +"""Meetings Library persistence — companion tables beside MeetingSense's own. + +Additive only. Nothing here alters an ``ms_`` table MeetingSense created; it adds tables +next to them and keys them by the same meeting id, so a meeting is one row in ``ms_meetings`` +whichever way it arrived — recorded live, imported, synced from a folder or scheduled. + +The connection is MeetingSense's (``store._connect``) so the library writes to the same +database file, and so a test that redirects that one function redirects both. + +Rollback is "turn the flag off": the tables stay, every row stays, and turning the flag on +again finds them as they were. +""" + +from __future__ import annotations + +import json +import time +import uuid +from typing import Any, Dict, Iterable, List, Optional, Sequence + +from .. import store + +LIBRARY_SCHEMA_VERSION = 1 + +_TABLES = ( + # One row per meeting that the library knows more about than ms_meetings can hold: + # where it came from, whether it has audio and a transcript, what it is about. + """ + CREATE TABLE IF NOT EXISTS ms_library_entries( + meeting_id TEXT PRIMARY KEY, + origin_type TEXT NOT NULL, + owner_user_id TEXT NOT NULL DEFAULT '', + category TEXT, + description TEXT, + scheduled_for REAL, + recording_plan TEXT, + recording_state TEXT NOT NULL DEFAULT 'none', + transcript_state TEXT NOT NULL DEFAULT 'none', + summary_state TEXT NOT NULL DEFAULT 'none', + duration_ms INTEGER, + media_present INTEGER NOT NULL DEFAULT 0, + outputs_json TEXT, + raw_metadata_json TEXT, + warnings_json TEXT, + hidden_at REAL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS ms_library_sources( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + display_name TEXT NOT NULL, + path TEXT NOT NULL, + resolved_path TEXT NOT NULL, + owner_user_id TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + project_id TEXT, + last_scan_at REAL, + last_success_at REAL, + last_error TEXT, + counts_json TEXT, + created_at REAL NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS ms_library_assets( + id TEXT PRIMARY KEY, + meeting_id TEXT, + source_id TEXT NOT NULL, + origin_type TEXT NOT NULL, + relative_path TEXT NOT NULL, + original_name TEXT NOT NULL, + kind TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'primary', + sha256 TEXT NOT NULL, + mime TEXT, + size_bytes INTEGER NOT NULL DEFAULT 0, + storage_ref TEXT, + media_duration_ms INTEGER, + source_available INTEGER NOT NULL DEFAULT 1, + metadata_json TEXT, + created_at REAL NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS ms_library_imports( + id TEXT PRIMARY KEY, + owner_user_id TEXT NOT NULL DEFAULT '', + source_id TEXT NOT NULL, + idempotency_key TEXT, + status TEXT NOT NULL, + manifest_json TEXT, + staging_dir TEXT, + error TEXT, + created_at REAL NOT NULL, + committed_at REAL + ) + """, + """ + CREATE TABLE IF NOT EXISTS ms_library_jobs( + id TEXT PRIMARY KEY, + meeting_id TEXT NOT NULL, + type TEXT NOT NULL, + state TEXT NOT NULL, + stage TEXT, + progress REAL NOT NULL DEFAULT 0, + attempt INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 2, + options_json TEXT, + diagnostic_json TEXT, + error TEXT, + lease_until REAL, + created_at REAL NOT NULL, + started_at REAL, + finished_at REAL + ) + """, + """ + CREATE TABLE IF NOT EXISTS ms_meeting_project_links( + meeting_id TEXT NOT NULL, + project_id TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'linked', + created_at REAL NOT NULL, + PRIMARY KEY (meeting_id, project_id) + ) + """, + """ + CREATE TABLE IF NOT EXISTS ms_library_origin_links( + origin_type TEXT NOT NULL, + origin_id TEXT NOT NULL, + meeting_id TEXT NOT NULL, + created_at REAL NOT NULL, + PRIMARY KEY (origin_type, origin_id) + ) + """, + """ + CREATE TABLE IF NOT EXISTS ms_transcript_versions( + id TEXT PRIMARY KEY, + meeting_id TEXT NOT NULL, + origin TEXT NOT NULL, + source_ref TEXT, + provider TEXT, + model TEXT, + language TEXT, + timing TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 0, + segment_count INTEGER NOT NULL DEFAULT 0, + note TEXT, + created_at REAL NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS ms_transcript_version_segments( + version_id TEXT NOT NULL, + seq INTEGER NOT NULL, + t0_ms INTEGER NOT NULL, + t1_ms INTEGER, + speaker TEXT, + text TEXT NOT NULL, + conf REAL, + PRIMARY KEY (version_id, seq) + ) + """, + # What a folder scan has seen, so "finished writing" can be told from "still being + # written": a file is imported only once its size and mtime have held still. + """ + CREATE TABLE IF NOT EXISTS ms_library_sync_seen( + source_id TEXT NOT NULL, + relative_path TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + mtime REAL NOT NULL, + first_seen_at REAL NOT NULL, + PRIMARY KEY (source_id, relative_path) + ) + """, +) + +_INDEXES = ( + "CREATE INDEX IF NOT EXISTS ix_ms_lib_assets_meeting ON ms_library_assets(meeting_id, kind)", + "CREATE INDEX IF NOT EXISTS ix_ms_lib_assets_sha ON ms_library_assets(sha256)", + "CREATE INDEX IF NOT EXISTS ix_ms_lib_assets_source ON ms_library_assets(source_id, relative_path)", + "CREATE INDEX IF NOT EXISTS ix_ms_lib_jobs_state ON ms_library_jobs(state, created_at)", + "CREATE INDEX IF NOT EXISTS ix_ms_lib_jobs_meeting ON ms_library_jobs(meeting_id)", + "CREATE INDEX IF NOT EXISTS ix_ms_lib_links_project ON ms_meeting_project_links(project_id)", + "CREATE INDEX IF NOT EXISTS ix_ms_lib_origin_meeting ON ms_library_origin_links(meeting_id)", + "CREATE INDEX IF NOT EXISTS ix_ms_tv_meeting ON ms_transcript_versions(meeting_id, created_at)", + "CREATE UNIQUE INDEX IF NOT EXISTS ux_ms_lib_imports_key ON ms_library_imports(owner_user_id, idempotency_key)", +) + +#: The states a transcript can be in. Recording and transcript are separate fields: an +#: imported recording keeps its audio whatever happens to its transcription. +TRANSCRIPT_STATES = ( + "none", "imported", "queued", "transcribing", "ready", "ready_warnings", + "failed", "needs_setup", "no_speech", "transcript_only", +) +RECORDING_STATES = ("none", "awaiting", "recording", "present", "unavailable") +JOB_STATES = ("queued", "running", "done", "failed", "cancelled", "needs_setup") + +_migrated_paths: set = set() + + +def _connect(): + return store._connect() + + +def _now() -> float: + return time.time() + + +def _dumps(value: Any) -> Optional[str]: + return None if value is None else json.dumps(value) + + +def _loads(raw: Optional[str], default: Any = None) -> Any: + if not raw: + return default + try: + return json.loads(raw) + except (TypeError, ValueError): + return default + + +def migrate() -> None: + """Create MeetingSense's tables (idempotent) and the library's beside them, then seed + project links from the ``project_id`` meetings already carry. Safe to run every start.""" + store.migrate() + con = _connect() + try: + cur = con.cursor() + for statement in _TABLES: + cur.execute(statement) + for statement in _INDEXES: + cur.execute(statement) + # Every meeting recorded in a project is linked to it — once, idempotently, and the + # ms_meetings.project_id column stays exactly as it was. + cur.execute( + "INSERT OR IGNORE INTO ms_meeting_project_links(meeting_id, project_id, kind, created_at)" + " SELECT id, project_id, 'primary', started_at FROM ms_meetings" + " WHERE project_id IS NOT NULL AND project_id != ''" + ) + con.commit() + finally: + con.close() + # Meeting intelligence keeps its own tables beside these; created only when its flag is + # on, so an install that never turns it on never gains them. + from .config import load_library_config # noqa: PLC0415 + + if load_library_config().intelligence: + from ..intelligence import store as intelligence_store # noqa: PLC0415 + + intelligence_store.migrate() + + +def ensure_migrated() -> None: + """``migrate`` once per database file per process (the routes call this on every request).""" + try: + from ...storage import _get_db_path # noqa: PLC0415 + + key = _get_db_path() + except Exception: # noqa: BLE001 + key = "default" + key = f"{key}:{id(store._connect)}" + if key in _migrated_paths: + return + migrate() + _migrated_paths.add(key) + + +# ── entries ───────────────────────────────────────────────────────────────── + + +def _entry_row(row) -> Dict[str, Any]: + out = dict(row) + out["outputs"] = _loads(out.pop("outputs_json", None), None) + out["raw_metadata"] = _loads(out.pop("raw_metadata_json", None), None) + out["warnings"] = _loads(out.pop("warnings_json", None), []) or [] + return out + + +def create_meeting_entry( + *, + title: str, + origin_type: str, + owner_user_id: str = "", + started_at: Optional[float] = None, + ended_at: Optional[float] = None, + status: str = "ended", + source: Optional[str] = None, + project_id: Optional[str] = None, + category: Optional[str] = None, + description: Optional[str] = None, + scheduled_for: Optional[float] = None, + recording_plan: Optional[str] = None, + recording_state: str = "none", + transcript_state: str = "none", + duration_ms: Optional[int] = None, + outputs: Any = None, + raw_metadata: Any = None, + warnings: Optional[Sequence[str]] = None, + meeting_id: Optional[str] = None, +) -> str: + """Create a finished (or scheduled) meeting **without** a live session. + + ``store.create_meeting`` opens a row as ``live``, which is right for the recorder and + wrong here: an imported recording is already over, and a scheduled meeting has not + started. Both get a meeting row of their own with no conversation behind it. + """ + mid = meeting_id or uuid.uuid4().hex + now = _now() + start = float(started_at if started_at is not None else (scheduled_for or now)) + con = _connect() + try: + con.execute( + "INSERT INTO ms_meetings(id, conversation_id, project_id, title, source, started_at," + " ended_at, audio_mode, retention, status) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 'text', ?)", + (mid, project_id or None, title, source, start, ended_at, "import", status), + ) + con.execute( + "INSERT INTO ms_library_entries(meeting_id, origin_type, owner_user_id, category," + " description, scheduled_for, recording_plan, recording_state, transcript_state," + " summary_state, duration_ms, media_present, outputs_json, raw_metadata_json," + " warnings_json, created_at, updated_at)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'none', ?, ?, ?, ?, ?, ?, ?)", + ( + mid, origin_type, owner_user_id or "", category, description, scheduled_for, + recording_plan, recording_state, transcript_state, duration_ms, + 1 if recording_state == "present" else 0, _dumps(outputs), _dumps(raw_metadata), + _dumps(list(warnings or [])), now, now, + ), + ) + if project_id: + con.execute( + "INSERT OR IGNORE INTO ms_meeting_project_links(meeting_id, project_id, kind, created_at)" + " VALUES (?, ?, 'primary', ?)", + (mid, project_id, now), + ) + con.commit() + finally: + con.close() + return mid + + +def ensure_entry(meeting_id: str, *, origin_type: str = "meetingsense", owner_user_id: str = "") -> None: + """Give a meeting that predates the library an entry row (for edits like a category).""" + now = _now() + con = _connect() + try: + con.execute( + "INSERT OR IGNORE INTO ms_library_entries(meeting_id, origin_type, owner_user_id," + " created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + (meeting_id, origin_type, owner_user_id or "", now, now), + ) + con.commit() + finally: + con.close() + + +_ENTRY_FIELDS = { + "category", "description", "scheduled_for", "recording_plan", "recording_state", + "transcript_state", "summary_state", "duration_ms", "media_present", "hidden_at", +} + + +def update_entry(meeting_id: str, *, outputs: Any = None, raw_metadata: Any = None, + warnings: Optional[Sequence[str]] = None, **fields: Any) -> None: + sets: List[str] = [] + args: List[Any] = [] + for key, value in fields.items(): + if key not in _ENTRY_FIELDS: + raise ValueError(f"not an entry field: {key}") + sets.append(f"{key} = ?") + args.append(value) + if outputs is not None: + sets.append("outputs_json = ?") + args.append(_dumps(outputs)) + if raw_metadata is not None: + sets.append("raw_metadata_json = ?") + args.append(_dumps(raw_metadata)) + if warnings is not None: + sets.append("warnings_json = ?") + args.append(_dumps(list(warnings))) + if not sets: + return + sets.append("updated_at = ?") + args.append(_now()) + con = _connect() + try: + con.execute(f"UPDATE ms_library_entries SET {', '.join(sets)} WHERE meeting_id = ?", args + [meeting_id]) + con.commit() + finally: + con.close() + + +def add_warning(meeting_id: str, warning: str) -> None: + entry = get_entry(meeting_id) + if entry is None: + return + warnings = list(entry.get("warnings") or []) + if warning not in warnings: + warnings.append(warning) + update_entry(meeting_id, warnings=warnings) + + +def get_entry(meeting_id: str) -> Optional[Dict[str, Any]]: + con = _connect() + try: + row = con.execute("SELECT * FROM ms_library_entries WHERE meeting_id = ?", (meeting_id,)).fetchone() + return _entry_row(row) if row else None + finally: + con.close() + + +def entries_by_id(meeting_ids: Iterable[str]) -> Dict[str, Dict[str, Any]]: + ids = list(meeting_ids) + if not ids: + return {} + out: Dict[str, Dict[str, Any]] = {} + con = _connect() + try: + for i in range(0, len(ids), 500): + part = ids[i:i + 500] + rows = con.execute( + f"SELECT * FROM ms_library_entries WHERE meeting_id IN ({','.join('?' * len(part))})", part + ).fetchall() + for row in rows: + out[row["meeting_id"]] = _entry_row(row) + finally: + con.close() + return out + + +# ── project links ─────────────────────────────────────────────────────────── + + +def project_links(meeting_ids: Iterable[str]) -> Dict[str, List[Dict[str, Any]]]: + ids = list(meeting_ids) + out: Dict[str, List[Dict[str, Any]]] = {} + if not ids: + return out + con = _connect() + try: + for i in range(0, len(ids), 500): + part = ids[i:i + 500] + rows = con.execute( + "SELECT * FROM ms_meeting_project_links WHERE meeting_id IN" + f" ({','.join('?' * len(part))}) ORDER BY kind = 'primary' DESC, created_at ASC", + part, + ).fetchall() + for row in rows: + out.setdefault(row["meeting_id"], []).append(dict(row)) + finally: + con.close() + return out + + +def set_project_links(meeting_id: str, project_ids: Sequence[str]) -> List[str]: + """Make a meeting's linked projects exactly ``project_ids``. Never copies the meeting. + + The primary link (the project a meeting was recorded in) is kept as ``primary`` if it + stays; ``ms_meetings.project_id`` is left as it was, which keeps every pre-library reader + of that column answering what it always answered. + """ + wanted = [p for p in dict.fromkeys(str(p).strip() for p in project_ids) if p] + now = _now() + con = _connect() + try: + existing = {r["project_id"] for r in con.execute( + "SELECT project_id FROM ms_meeting_project_links WHERE meeting_id = ?", (meeting_id,) + ).fetchall()} + for pid in existing - set(wanted): + con.execute("DELETE FROM ms_meeting_project_links WHERE meeting_id = ? AND project_id = ?", (meeting_id, pid)) + for pid in wanted: + if pid not in existing: + con.execute( + "INSERT INTO ms_meeting_project_links(meeting_id, project_id, kind, created_at)" + " VALUES (?, ?, 'linked', ?)", + (meeting_id, pid, now), + ) + con.commit() + finally: + con.close() + return wanted + + +# ── origin links (dedup) ──────────────────────────────────────────────────── + + +def origin_meeting(origin_type: str, origin_id: str) -> Optional[str]: + con = _connect() + try: + row = con.execute( + "SELECT meeting_id FROM ms_library_origin_links WHERE origin_type = ? AND origin_id = ?", + (origin_type, origin_id), + ).fetchone() + return row["meeting_id"] if row else None + finally: + con.close() + + +def link_origin(origin_type: str, origin_id: str, meeting_id: str) -> None: + con = _connect() + try: + con.execute( + "INSERT OR IGNORE INTO ms_library_origin_links(origin_type, origin_id, meeting_id, created_at)" + " VALUES (?, ?, ?, ?)", + (origin_type, origin_id, meeting_id, _now()), + ) + con.commit() + finally: + con.close() + + +# ── assets ────────────────────────────────────────────────────────────────── + + +def add_asset(**fields: Any) -> str: + aid = fields.pop("id", None) or uuid.uuid4().hex + metadata = fields.pop("metadata", None) + cols = [ + "meeting_id", "source_id", "origin_type", "relative_path", "original_name", "kind", "role", + "sha256", "mime", "size_bytes", "storage_ref", "media_duration_ms", + ] + values = [fields.get(c) for c in cols] + if values[cols.index("role")] is None: + values[cols.index("role")] = "primary" + if values[cols.index("size_bytes")] is None: + values[cols.index("size_bytes")] = 0 + con = _connect() + try: + con.execute( + f"INSERT INTO ms_library_assets(id, {', '.join(cols)}, metadata_json, created_at)" + f" VALUES (?, {', '.join('?' * len(cols))}, ?, ?)", + [aid] + values + [_dumps(metadata), _now()], + ) + con.commit() + finally: + con.close() + return aid + + +def _asset_row(row) -> Dict[str, Any]: + out = dict(row) + out["metadata"] = _loads(out.pop("metadata_json", None), {}) or {} + return out + + +def assets_for(meeting_id: str) -> List[Dict[str, Any]]: + con = _connect() + try: + rows = con.execute( + "SELECT * FROM ms_library_assets WHERE meeting_id = ? ORDER BY kind, role = 'primary' DESC, created_at", + (meeting_id,), + ).fetchall() + return [_asset_row(r) for r in rows] + finally: + con.close() + + +def get_asset(asset_id: str) -> Optional[Dict[str, Any]]: + con = _connect() + try: + row = con.execute("SELECT * FROM ms_library_assets WHERE id = ?", (asset_id,)).fetchone() + return _asset_row(row) if row else None + finally: + con.close() + + +def update_asset(asset_id: str, *, metadata: Any = None, **fields: Any) -> None: + allowed = {"relative_path", "source_available", "media_duration_ms", "storage_ref", "meeting_id"} + sets, args = [], [] + for key, value in fields.items(): + if key not in allowed: + raise ValueError(f"not an asset field: {key}") + sets.append(f"{key} = ?") + args.append(value) + if metadata is not None: + sets.append("metadata_json = ?") + args.append(_dumps(metadata)) + if not sets: + return + con = _connect() + try: + con.execute(f"UPDATE ms_library_assets SET {', '.join(sets)} WHERE id = ?", args + [asset_id]) + con.commit() + finally: + con.close() + + +def assets_with_sha(sha256: str) -> List[Dict[str, Any]]: + con = _connect() + try: + rows = con.execute( + "SELECT * FROM ms_library_assets WHERE sha256 = ? AND meeting_id IS NOT NULL", (sha256,) + ).fetchall() + return [_asset_row(r) for r in rows] + finally: + con.close() + + +def assets_for_source(source_id: str) -> List[Dict[str, Any]]: + con = _connect() + try: + rows = con.execute("SELECT * FROM ms_library_assets WHERE source_id = ?", (source_id,)).fetchall() + return [_asset_row(r) for r in rows] + finally: + con.close() + + +# ── imports ───────────────────────────────────────────────────────────────── + + +def create_import(*, owner_user_id: str, source_id: str, manifest: Any, staging_dir: str, + idempotency_key: Optional[str] = None, import_id: Optional[str] = None) -> str: + iid = import_id or uuid.uuid4().hex + con = _connect() + try: + con.execute( + "INSERT INTO ms_library_imports(id, owner_user_id, source_id, idempotency_key, status," + " manifest_json, staging_dir, created_at) VALUES (?, ?, ?, ?, 'previewed', ?, ?, ?)", + (iid, owner_user_id or "", source_id, idempotency_key, _dumps(manifest), staging_dir, _now()), + ) + con.commit() + finally: + con.close() + return iid + + +def get_import(import_id: str) -> Optional[Dict[str, Any]]: + con = _connect() + try: + row = con.execute("SELECT * FROM ms_library_imports WHERE id = ?", (import_id,)).fetchone() + if not row: + return None + out = dict(row) + out["manifest"] = _loads(out.pop("manifest_json", None), {}) or {} + return out + finally: + con.close() + + +def import_by_key(owner_user_id: str, key: str) -> Optional[Dict[str, Any]]: + con = _connect() + try: + row = con.execute( + "SELECT id FROM ms_library_imports WHERE owner_user_id = ? AND idempotency_key = ?", + (owner_user_id or "", key), + ).fetchone() + finally: + con.close() + return get_import(row["id"]) if row else None + + +def update_import(import_id: str, *, status: Optional[str] = None, manifest: Any = None, + error: Optional[str] = None, committed: bool = False, + idempotency_key: Optional[str] = None) -> None: + sets, args = [], [] + if status is not None: + sets.append("status = ?") + args.append(status) + if manifest is not None: + sets.append("manifest_json = ?") + args.append(_dumps(manifest)) + if error is not None: + sets.append("error = ?") + args.append(error) + if committed: + sets.append("committed_at = ?") + args.append(_now()) + if idempotency_key is not None: + sets.append("idempotency_key = ?") + args.append(idempotency_key) + if not sets: + return + con = _connect() + try: + con.execute(f"UPDATE ms_library_imports SET {', '.join(sets)} WHERE id = ?", args + [import_id]) + con.commit() + finally: + con.close() + + +# ── jobs ──────────────────────────────────────────────────────────────────── + + +def create_job(meeting_id: str, type_: str, *, options: Any = None, max_attempts: int = 2) -> str: + """Queue a job, unless an identical one is already queued or running (idempotent).""" + con = _connect() + try: + row = con.execute( + "SELECT id FROM ms_library_jobs WHERE meeting_id = ? AND type = ? AND state IN ('queued', 'running')", + (meeting_id, type_), + ).fetchone() + if row: + return row["id"] + jid = uuid.uuid4().hex + con.execute( + "INSERT INTO ms_library_jobs(id, meeting_id, type, state, stage, progress, attempt," + " max_attempts, options_json, created_at) VALUES (?, ?, ?, 'queued', 'queued', 0, 0, ?, ?, ?)", + (jid, meeting_id, type_, max_attempts, _dumps(options), _now()), + ) + con.commit() + return jid + finally: + con.close() + + +def _job_row(row) -> Dict[str, Any]: + out = dict(row) + out["options"] = _loads(out.pop("options_json", None), {}) or {} + out["diagnostic"] = _loads(out.pop("diagnostic_json", None), {}) or {} + return out + + +def get_job(job_id: str) -> Optional[Dict[str, Any]]: + con = _connect() + try: + row = con.execute("SELECT * FROM ms_library_jobs WHERE id = ?", (job_id,)).fetchone() + return _job_row(row) if row else None + finally: + con.close() + + +def list_jobs(*, meeting_id: Optional[str] = None, states: Optional[Sequence[str]] = None, + limit: int = 100) -> List[Dict[str, Any]]: + sql = "SELECT * FROM ms_library_jobs WHERE 1=1" + args: List[Any] = [] + if meeting_id: + sql += " AND meeting_id = ?" + args.append(meeting_id) + if states: + sql += f" AND state IN ({','.join('?' * len(states))})" + args.extend(states) + sql += " ORDER BY created_at DESC LIMIT ?" + args.append(int(limit)) + con = _connect() + try: + return [_job_row(r) for r in con.execute(sql, args).fetchall()] + finally: + con.close() + + +def lease_next_job(*, lease_s: float = 600.0) -> Optional[Dict[str, Any]]: + """Take the oldest queued job (or one whose lease ran out — a crashed worker's).""" + now = _now() + con = _connect() + try: + con.execute("BEGIN IMMEDIATE") + row = con.execute( + "SELECT * FROM ms_library_jobs WHERE state = 'queued'" + " OR (state = 'running' AND lease_until IS NOT NULL AND lease_until < ?)" + " ORDER BY created_at ASC LIMIT 1", + (now,), + ).fetchone() + if not row: + con.commit() + return None + con.execute( + "UPDATE ms_library_jobs SET state = 'running', attempt = attempt + 1, lease_until = ?," + " started_at = COALESCE(started_at, ?), error = NULL WHERE id = ?", + (now + lease_s, now, row["id"]), + ) + con.commit() + finally: + con.close() + return get_job(row["id"]) + + +def update_job(job_id: str, *, state: Optional[str] = None, stage: Optional[str] = None, + progress: Optional[float] = None, error: Optional[str] = None, + diagnostic: Any = None, lease_s: Optional[float] = None, finished: bool = False) -> None: + sets, args = [], [] + if state is not None: + sets.append("state = ?") + args.append(state) + if stage is not None: + sets.append("stage = ?") + args.append(stage) + if progress is not None: + sets.append("progress = ?") + args.append(max(0.0, min(1.0, float(progress)))) + if error is not None: + sets.append("error = ?") + args.append(error) + if diagnostic is not None: + sets.append("diagnostic_json = ?") + args.append(_dumps(diagnostic)) + if lease_s is not None: + sets.append("lease_until = ?") + args.append(_now() + lease_s) + if finished: + sets.append("finished_at = ?") + args.append(_now()) + sets.append("lease_until = NULL") + if not sets: + return + con = _connect() + try: + con.execute(f"UPDATE ms_library_jobs SET {', '.join(sets)} WHERE id = ?", args + [job_id]) + con.commit() + finally: + con.close() + + +def job_state(job_id: str) -> Optional[str]: + con = _connect() + try: + row = con.execute("SELECT state FROM ms_library_jobs WHERE id = ?", (job_id,)).fetchone() + return row["state"] if row else None + finally: + con.close() + + +# ── transcript versions ───────────────────────────────────────────────────── + + +def create_version(meeting_id: str, segments: Sequence[Dict[str, Any]], *, origin: str, timing: str, + source_ref: Optional[str] = None, provider: Optional[str] = None, + model: Optional[str] = None, language: Optional[str] = None, + note: Optional[str] = None) -> str: + """Store an immutable transcript version. Not shown until it is published. + + The first version stored for a meeting that already has a transcript (one recorded live) + is preceded by a ``live`` snapshot of that transcript, so history reads in the order + things happened and the recorded words are never lost to an edit. + """ + if origin != "live": + _snapshot_live(meeting_id) + vid = uuid.uuid4().hex + rows = [] + for seq, seg in enumerate(s for s in segments if (s.get("text") or "").strip()): + rows.append(( + vid, seq, int(seg.get("t0_ms") or 0), + None if seg.get("t1_ms") is None else int(seg["t1_ms"]), + seg.get("speaker") or None, str(seg["text"]).strip(), seg.get("conf"), + )) + con = _connect() + try: + con.execute( + "INSERT INTO ms_transcript_versions(id, meeting_id, origin, source_ref, provider, model," + " language, timing, active, segment_count, note, created_at)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)", + (vid, meeting_id, origin, source_ref, provider, model, language, timing, len(rows), note, _now()), + ) + con.executemany( + "INSERT INTO ms_transcript_version_segments(version_id, seq, t0_ms, t1_ms, speaker, text, conf)" + " VALUES (?, ?, ?, ?, ?, ?, ?)", + rows, + ) + con.commit() + finally: + con.close() + return vid + + +def _snapshot_live(meeting_id: str) -> None: + con = _connect() + try: + has_versions = con.execute( + "SELECT 1 FROM ms_transcript_versions WHERE meeting_id = ? LIMIT 1", (meeting_id,)).fetchone() + finally: + con.close() + if has_versions: + return + current = store.get_segments(meeting_id) + if not current: + return + snapshot = [{"t0_ms": s["t0_ms"], "t1_ms": s["t1_ms"], "speaker": s.get("speaker"), + "text": s["text"], "conf": s.get("conf")} for s in current] + vid = create_version(meeting_id, snapshot, origin="live", timing="measured", + note="The transcript recorded live, kept when another version was added.") + con = _connect() + try: # it is what is shown right now + con.execute("UPDATE ms_transcript_versions SET active = 1 WHERE id = ?", (vid,)) + con.commit() + finally: + con.close() + + +def list_versions(meeting_id: str) -> List[Dict[str, Any]]: + con = _connect() + try: + rows = con.execute( + "SELECT * FROM ms_transcript_versions WHERE meeting_id = ? ORDER BY created_at ASC", (meeting_id,) + ).fetchall() + return [dict(r) for r in rows] + finally: + con.close() + + +def version_segments(version_id: str) -> List[Dict[str, Any]]: + con = _connect() + try: + rows = con.execute( + "SELECT * FROM ms_transcript_version_segments WHERE version_id = ? ORDER BY seq", (version_id,) + ).fetchall() + return [dict(r) for r in rows] + finally: + con.close() + + +def publish_version(meeting_id: str, version_id: str) -> int: + """Make a version the meeting's transcript. Returns the segments now shown. + + ``ms_segments`` is what every existing reader uses — the card, export, summaries, + "ask", search — so publishing writes the version there. Whatever was there before is + first kept as a version of its own (``live`` when the recorder wrote it), so publishing + never loses a line: the previous transcript is one publish away. + """ + versions = {v["id"]: v for v in list_versions(meeting_id)} + if version_id not in versions: + raise KeyError(version_id) + rows = version_segments(version_id) + con = _connect() + try: + con.execute("DELETE FROM ms_segments WHERE meeting_id = ?", (meeting_id,)) + con.executemany( + "INSERT INTO ms_segments(id, meeting_id, t0_ms, t1_ms, speaker, text, conf, seq)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [(uuid.uuid4().hex, meeting_id, r["t0_ms"], r["t1_ms"], r["speaker"], r["text"], r["conf"], r["seq"]) + for r in rows], + ) + con.execute("UPDATE ms_transcript_versions SET active = 0 WHERE meeting_id = ?", (meeting_id,)) + con.execute("UPDATE ms_transcript_versions SET active = 1 WHERE id = ?", (version_id,)) + con.commit() + finally: + con.close() + return len(rows) + + +# ── folder sources ────────────────────────────────────────────────────────── + + +def _source_row(row) -> Dict[str, Any]: + out = dict(row) + out["counts"] = _loads(out.pop("counts_json", None), {}) or {} + out["enabled"] = bool(out.get("enabled")) + return out + + +def create_source(*, kind: str, display_name: str, path: str, resolved_path: str, + owner_user_id: str = "", project_id: Optional[str] = None, enabled: bool = True) -> str: + sid = uuid.uuid4().hex + con = _connect() + try: + con.execute( + "INSERT INTO ms_library_sources(id, kind, display_name, path, resolved_path, owner_user_id," + " enabled, project_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (sid, kind, display_name, path, resolved_path, owner_user_id or "", 1 if enabled else 0, + project_id, _now()), + ) + con.commit() + finally: + con.close() + return sid + + +def list_sources() -> List[Dict[str, Any]]: + con = _connect() + try: + return [_source_row(r) for r in con.execute( + "SELECT * FROM ms_library_sources ORDER BY created_at ASC").fetchall()] + finally: + con.close() + + +def get_source(source_id: str) -> Optional[Dict[str, Any]]: + con = _connect() + try: + row = con.execute("SELECT * FROM ms_library_sources WHERE id = ?", (source_id,)).fetchone() + return _source_row(row) if row else None + finally: + con.close() + + +def update_source(source_id: str, *, counts: Any = None, **fields: Any) -> None: + allowed = {"display_name", "enabled", "project_id", "last_scan_at", "last_success_at", "last_error"} + sets, args = [], [] + for key, value in fields.items(): + if key not in allowed: + raise ValueError(f"not a source field: {key}") + sets.append(f"{key} = ?") + args.append(int(value) if key == "enabled" else value) + if counts is not None: + sets.append("counts_json = ?") + args.append(_dumps(counts)) + if not sets: + return + con = _connect() + try: + con.execute(f"UPDATE ms_library_sources SET {', '.join(sets)} WHERE id = ?", args + [source_id]) + con.commit() + finally: + con.close() + + +def delete_source(source_id: str) -> None: + """Disconnect a folder. Meetings imported from it stay — they are HomePilot's copies.""" + con = _connect() + try: + con.execute("DELETE FROM ms_library_sources WHERE id = ?", (source_id,)) + con.execute("DELETE FROM ms_library_sync_seen WHERE source_id = ?", (source_id,)) + con.commit() + finally: + con.close() + + +def observe(source_id: str, relative_path: str, size_bytes: int, mtime: float) -> Dict[str, Any]: + """Record a file sighting. ``changed`` is True when it differs from the last sighting.""" + now = _now() + con = _connect() + try: + row = con.execute( + "SELECT * FROM ms_library_sync_seen WHERE source_id = ? AND relative_path = ?", + (source_id, relative_path), + ).fetchone() + if row and int(row["size_bytes"]) == int(size_bytes) and float(row["mtime"]) == float(mtime): + return {**dict(row), "changed": False} + con.execute( + "INSERT OR REPLACE INTO ms_library_sync_seen(source_id, relative_path, size_bytes, mtime, first_seen_at)" + " VALUES (?, ?, ?, ?, ?)", + (source_id, relative_path, int(size_bytes), float(mtime), now), + ) + con.commit() + return {"source_id": source_id, "relative_path": relative_path, "size_bytes": size_bytes, + "mtime": mtime, "first_seen_at": now, "changed": row is not None} + finally: + con.close() + + +# ── deletion helpers ──────────────────────────────────────────────────────── + + +def delete_library_rows(meeting_id: str) -> Dict[str, int]: + """Remove the library's rows for a meeting (MeetingSense removes its own).""" + con = _connect() + try: + counts = {} + for table, column in ( + ("ms_library_assets", "meeting_id"), ("ms_library_jobs", "meeting_id"), + ("ms_meeting_project_links", "meeting_id"), ("ms_library_origin_links", "meeting_id"), + ("ms_library_entries", "meeting_id"), + ): + cur = con.execute(f"DELETE FROM {table} WHERE {column} = ?", (meeting_id,)) + counts[table] = int(cur.rowcount or 0) + vids = [r["id"] for r in con.execute( + "SELECT id FROM ms_transcript_versions WHERE meeting_id = ?", (meeting_id,)).fetchall()] + for vid in vids: + con.execute("DELETE FROM ms_transcript_version_segments WHERE version_id = ?", (vid,)) + cur = con.execute("DELETE FROM ms_transcript_versions WHERE meeting_id = ?", (meeting_id,)) + counts["ms_transcript_versions"] = int(cur.rowcount or 0) + con.commit() + finally: + con.close() + try: + from ..intelligence import store as intelligence_store # noqa: PLC0415 + + counts.update(intelligence_store.delete_for(meeting_id)) + except Exception: # noqa: BLE001 — the tables exist only once the flag has been on + pass + return counts diff --git a/backend/app/meetingsense/library/importer.py b/backend/app/meetingsense/library/importer.py new file mode 100644 index 0000000000000000000000000000000000000000..165af9c5877dd2b5d93a9af5438134bd575f0098 --- /dev/null +++ b/backend/app/meetingsense/library/importer.py @@ -0,0 +1,373 @@ +"""Importing recordings and transcripts into the library — preview first, then commit. + +Preview looks at everything and changes nothing: which meetings the files contain, how long +the audio really is, whether a transcript is usable, whether the recording is silent, whether +it is already in the library, and whether it belongs to a meeting scheduled for that time. +Commit does what the person confirmed, once — repeating it with the same idempotency key +returns the first result instead of importing twice. + +Imported files are **copied** into HomePilot's own storage. Sources are read, never written. +""" + +from __future__ import annotations + +import hashlib +import shutil +import time +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence + +from . import db, media, parsers +from .adapters import Candidate, discover +from .config import load_library_config, media_root + +#: How far apart a recording and a scheduled meeting can start and still be proposed as the +#: same meeting. Ambiguous matches are offered, never applied without a confirmation. +MATCH_WINDOW_S = 45 * 60 +#: Loudness is measured during preview only for recordings up to this long; longer ones are +#: checked by the transcription job, so a preview of a three-hour file stays quick. +PREVIEW_LOUDNESS_MAX_MS = 20 * 60 * 1000 + +_SOURCE_LABELS = {"airecord": "Insta360 / AIRecord", "audio": "Audio upload", "transcript": "Transcript"} + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def _iso(ts: Optional[float]) -> Optional[str]: + return datetime.fromtimestamp(ts).isoformat(timespec="seconds") if ts else None + + +def scheduled_matches(started_at: Optional[float], *, exclude: Sequence[str] = ()) -> List[Dict[str, Any]]: + """Scheduled meetings still waiting for a recording that start near ``started_at``.""" + if not started_at: + return [] + con = db._connect() + try: + rows = con.execute( + "SELECT e.meeting_id, e.scheduled_for, m.title FROM ms_library_entries e" + " JOIN ms_meetings m ON m.id = e.meeting_id" + " WHERE e.recording_state = 'awaiting' AND e.scheduled_for IS NOT NULL AND e.hidden_at IS NULL" + " AND ABS(e.scheduled_for - ?) <= ? ORDER BY ABS(e.scheduled_for - ?) ASC", + (started_at, MATCH_WINDOW_S, started_at), + ).fetchall() + finally: + con.close() + return [{"meeting_id": r["meeting_id"], "title": r["title"], "scheduled_for": r["scheduled_for"], + "minutes_apart": round(abs(r["scheduled_for"] - started_at) / 60)} + for r in rows if r["meeting_id"] not in exclude] + + +def analyse(cand: Candidate, *, quick: bool = True) -> Dict[str, Any]: + """Describe one candidate meeting for the preview. Reads files; writes nothing.""" + item: Dict[str, Any] = { + "key": cand.key, + "kind": cand.kind, + "source_label": _SOURCE_LABELS.get(cand.kind, cand.kind), + "title": cand.title, + "started_at": cand.started_at, + "ended_at": cand.ended_at, + "time_source": cand.time_source, + "files": [f.as_dict() for f in cand.files], + "ignored_files": [f.as_dict() for f in cand.ignored], + "warnings": list(cand.warnings), + "origin": list(cand.origin) if cand.origin else None, + "duplicate_of": None, + "scheduled_matches": [], + "audio": None, + "transcript": None, + "outcome": "import", + "transcript_state": "none", + } + if cand.raw_metadata.get("description"): + item["description"] = cand.raw_metadata["description"] + + if cand.audio is not None: + info = media.probe(cand.audio.path) + with cand.audio.path.open("rb") as fh: + sniffed = media.sniff_audio(fh.read(16)) + audio: Dict[str, Any] = {"name": cand.audio.as_dict()["name"], "size": cand.audio.size, + "mime": sniffed, "readable": bool(info.get("ok"))} + if info.get("ok"): + audio.update({k: info.get(k) for k in ("duration_ms", "codec", "sample_rate", "channels")}) + if cand.claimed_duration_ms or (cand.started_at and cand.ended_at): + _note_time_mismatch(item, cand, info.get("duration_ms") or 0) + if not quick or (info.get("duration_ms") or 0) <= PREVIEW_LOUDNESS_MAX_MS: + level = media.loudness(cand.audio.path) + audio["max_db"] = level.get("max_db") + audio["mean_db"] = level.get("mean_db") + audio["silent"] = media.is_silent(level) + else: + audio["error"] = info.get("error") + item["warnings"].append(info.get("error") or "The recording could not be read.") + audio["sha256"] = sha256_file(cand.audio.path) + item["audio"] = audio + + if cand.transcript is not None: + parsed = parsers.parse_transcript(cand.transcript.path.name, cand.transcript.path.read_bytes()) + item["transcript"] = { + "name": cand.transcript.as_dict()["name"], + "format": parsed.format, + "segments": len(parsed.segments), + "usable": parsed.usable, + "timing": parsed.timing, + "speakers": sorted({s["speaker"] for s in parsed.segments if s.get("speaker")})[:12], + "preview": " ".join(s["text"] for s in parsed.segments[:3])[:240], + } + item["warnings"].extend(parsed.warnings) + if not parsed.usable and cand.kind == "airecord": + item["warnings"].append("The recorder's transcript is empty — it has to be transcribed.") + + # Outcome — what will happen to the transcript. + audio = item["audio"] or {} + transcript = item["transcript"] or {} + if transcript.get("usable"): + item["transcript_state"] = "imported" + elif cand.audio is None: + item["transcript_state"] = "transcript_only" if transcript else "none" + if not transcript.get("usable"): + item["outcome"] = "skip" + item["warnings"].append("Nothing usable to import: the transcript is empty.") + elif not audio.get("readable"): + item["transcript_state"] = "failed" + elif audio.get("silent"): + item["transcript_state"] = "no_speech" + item["warnings"].append( + f"No speech detected — the recording is silent (loudest moment {audio.get('max_db')} dB)." + ) + else: + item["transcript_state"] = "needs_transcription" + if cand.audio is not None and transcript.get("usable"): + item["transcript_state"] = "imported" + + # Already in the library? + existing = None + if cand.origin: + existing = db.origin_meeting(*cand.origin) + if not existing and audio.get("sha256"): + hits = db.assets_with_sha(audio["sha256"]) + existing = hits[0]["meeting_id"] if hits else None + if existing: + item["duplicate_of"] = existing + item["outcome"] = "skip" + else: + item["scheduled_matches"] = scheduled_matches(cand.started_at) + if len(item["scheduled_matches"]) == 1 and cand.audio is not None: + item["outcome"] = "attach" + item["attach_to"] = item["scheduled_matches"][0]["meeting_id"] + return item + + +def _note_time_mismatch(item: Dict[str, Any], cand: Candidate, media_ms: int) -> None: + claims = [] + if cand.claimed_duration_ms: + claims.append(("the recorder's duration", cand.claimed_duration_ms)) + if cand.started_at and cand.ended_at and cand.ended_at > cand.started_at: + claims.append(("its start/end times", int((cand.ended_at - cand.started_at) * 1000))) + off = [(label, ms) for label, ms in claims if abs(ms - media_ms) > max(2000, media_ms * 0.1)] + if off: + parts = ", ".join(f"{label} say {ms / 1000:.0f} s" for label, ms in off) + item["warnings"].append( + f"The recording plays for {media_ms / 1000:.1f} s; {parts}. The playable length is used " + "and the original values are kept." + ) + + +# ── preview / commit for uploads ──────────────────────────────────────────── + + +def staging_dir(import_id: str) -> Path: + return media_root() / "staging" / import_id + + +def preview_tree(tree: Path) -> Dict[str, Any]: + candidates = discover(tree) + items = [analyse(c) for c in candidates] + to_transcribe_ms = sum((i.get("audio") or {}).get("duration_ms") or 0 + for i in items if i["transcript_state"] == "needs_transcription" and i["outcome"] != "skip") + return { + "items": items, + "totals": { + "meetings": len(items), + "duplicates": sum(1 for i in items if i["duplicate_of"]), + "needs_transcription": sum(1 for i in items if i["transcript_state"] == "needs_transcription"), + "audio_minutes_to_transcribe": round(to_transcribe_ms / 60000, 1), + }, + } + + +def commit_import(import_row: Dict[str, Any], choices: Sequence[Dict[str, Any]], *, + owner_user_id: str, project_id: Optional[str] = None) -> Dict[str, Any]: + """Create (or attach) the meetings a person confirmed in the preview.""" + tree = Path(import_row["staging_dir"]) / "tree" + by_key = {c.key: c for c in discover(tree)} + manifest = import_row.get("manifest") or {} + analysed = {i["key"]: i for i in manifest.get("items") or []} + wanted = {str(c.get("key")): c for c in choices or [] if c.get("key")} + if not wanted: + # No explicit choices: the preview's own defaults. + wanted = {k: {"key": k, "action": v.get("outcome"), "attach_to": v.get("attach_to")} for k, v in analysed.items()} + results: List[Dict[str, Any]] = [] + for key, choice in wanted.items(): + cand = by_key.get(key) + item = analysed.get(key) or {} + action = str(choice.get("action") or item.get("outcome") or "import") + if cand is None: + results.append({"key": key, "status": "missing"}) + continue + if action == "skip": + results.append({"key": key, "status": "skipped", "meeting_id": item.get("duplicate_of")}) + continue + result = ingest( + cand, source_id="upload", origin_type="import", owner_user_id=owner_user_id, + title=str(choice.get("title") or "").strip() or None, + project_id=str(choice.get("project_id") or project_id or "").strip() or None, + category=str(choice.get("category") or "").strip() or None, + attach_to=str(choice.get("attach_to") or "").strip() or None if action == "attach" else None, + transcribe=bool(choice.get("transcribe", True)), + analysed=item, + ) + results.append({"key": key, **result}) + return {"results": results} + + +# ── ingest (shared with folder sync) ──────────────────────────────────────── + + +def _copy_into(meeting_id: str, src: Path, name: str) -> str: + """Copy a file into the meeting's own folder; return its path relative to the media root.""" + folder = media_root() / meeting_id + folder.mkdir(parents=True, exist_ok=True) + safe = name.replace("/", "_").replace("\\", "_").strip() or "file" + target = folder / safe + if target.exists(): + target = folder / f"{uuid.uuid4().hex[:8]}_{safe}" + shutil.copy2(src, target) + return target.relative_to(media_root()).as_posix() + + +def ingest(cand: Candidate, *, source_id: str, origin_type: str, owner_user_id: str = "", + title: Optional[str] = None, project_id: Optional[str] = None, category: Optional[str] = None, + attach_to: Optional[str] = None, transcribe: bool = True, + analysed: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Make one candidate a library meeting (or add its recording to a scheduled one).""" + item = analysed or analyse(cand) + if item.get("duplicate_of") and not attach_to: + return {"status": "duplicate", "meeting_id": item["duplicate_of"]} + audio = item.get("audio") or {} + duration_ms = audio.get("duration_ms") or cand.claimed_duration_ms + started_at = cand.started_at or time.time() + ended_at = started_at + duration_ms / 1000.0 if duration_ms else cand.ended_at + warnings = list(dict.fromkeys(item.get("warnings") or [])) + source_name = "insta360" if cand.kind == "airecord" else ("transcript" if cand.kind == "transcript" else "upload") + if origin_type == "sync": + source_name = "insta360" if cand.kind == "airecord" else "folder" + + if attach_to and db.get_entry(attach_to): + meeting_id = attach_to + con = db._connect() + try: + con.execute("UPDATE ms_meetings SET started_at = ?, ended_at = ?, status = 'ended'," + " source = COALESCE(source, ?) WHERE id = ?", + (started_at, ended_at, source_name, meeting_id)) + con.commit() + finally: + con.close() + db.update_entry(meeting_id, recording_state="present", duration_ms=duration_ms, media_present=1, + warnings=warnings, raw_metadata=cand.raw_metadata or None) + status = "attached" + else: + meeting_id = db.create_meeting_entry( + title=title or cand.title, origin_type=origin_type, owner_user_id=owner_user_id, + started_at=started_at, ended_at=ended_at, status="ended", source=source_name, + project_id=project_id, category=category or ("Imported" if origin_type == "import" else "Synced"), + description=item.get("description"), + recording_state="present" if cand.audio is not None else "none", + transcript_state="none", duration_ms=duration_ms, raw_metadata=cand.raw_metadata or None, + warnings=warnings, + ) + status = "created" + if cand.origin: + db.link_origin(cand.origin[0], cand.origin[1], meeting_id) + + # Files: the recording, the transcript and the recorder's metadata — copies. + if cand.audio is not None: + ref = _copy_into(meeting_id, cand.audio.path, cand.audio.path.name) + db.add_asset(meeting_id=meeting_id, source_id=source_id, origin_type=origin_type, + relative_path=cand.audio.rel, original_name=cand.audio.path.name, kind="audio", + role="primary", sha256=audio.get("sha256") or sha256_file(cand.audio.path), + mime=audio.get("mime"), size_bytes=cand.audio.size, storage_ref=ref, + media_duration_ms=audio.get("duration_ms"), + metadata={k: audio.get(k) for k in ("codec", "sample_rate", "channels", "max_db", "mean_db") + if audio.get(k) is not None} | {"source_mtime": cand.audio.mtime}) + parsed = None + if cand.transcript is not None: + raw = cand.transcript.path.read_bytes() + parsed = parsers.parse_transcript(cand.transcript.path.name, raw) + ref = _copy_into(meeting_id, cand.transcript.path, cand.transcript.path.name) + db.add_asset(meeting_id=meeting_id, source_id=source_id, origin_type=origin_type, + relative_path=cand.transcript.rel, original_name=cand.transcript.path.name, + kind="transcript", role="primary", sha256=hashlib.sha256(raw).hexdigest(), + mime="text/plain", size_bytes=cand.transcript.size, storage_ref=ref, + metadata={"format": parsed.format, "segments": len(parsed.segments), "usable": parsed.usable}) + for meta in cand.metadata_files: + ref = _copy_into(meeting_id, meta.path, meta.path.name) + db.add_asset(meeting_id=meeting_id, source_id=source_id, origin_type=origin_type, + relative_path=meta.rel, original_name=meta.path.name, kind="metadata", role="secondary", + sha256=sha256_file(meta.path), mime="application/json", size_bytes=meta.size, + storage_ref=ref) + + # Transcript state. + state = "none" + if parsed is not None and parsed.usable: + segments = parsers.monotonic(parsed.segments) + vid = db.create_version(meeting_id, segments, origin="imported", timing=parsed.timing, + source_ref=cand.transcript.rel, note=f"Imported from {cand.transcript.path.name}") + db.publish_version(meeting_id, vid) + state = "transcript_only" if cand.audio is None else "imported" + if cand.audio is None and parsed.timing == "imported": + # No recording to measure: the transcript's own times give the length. + span_ms = max((s.get("t1_ms") or s.get("t0_ms") or 0) for s in segments) + if span_ms > 0: + db.update_entry(meeting_id, duration_ms=int(span_ms)) + con = db._connect() + try: + con.execute("UPDATE ms_meetings SET ended_at = started_at + ? WHERE id = ?", + (span_ms / 1000.0, meeting_id)) + con.commit() + finally: + con.close() + _queue_followups(meeting_id) + elif cand.audio is not None: + if not audio.get("readable", True): + state = "failed" + elif audio.get("silent"): + state = "no_speech" + elif transcribe and load_library_config().processing: + db.create_job(meeting_id, "transcribe") + state = "queued" + db.update_entry(meeting_id, transcript_state=state) + return {"status": status, "meeting_id": meeting_id, "transcript_state": state} + + +def _queue_followups(meeting_id: str) -> None: + """After a transcript lands: make it searchable, then (if a model answers) summarise.""" + if not load_library_config().processing: + return + db.create_job(meeting_id, "index", max_attempts=1) + entry = db.get_entry(meeting_id) or {} + outputs = entry.get("outputs") or {} + if outputs.get("summary", True): + db.create_job(meeting_id, "summarize", max_attempts=1, options={"outputs": outputs}) + db.update_entry(meeting_id, summary_state="pending") + + +def cleanup_staging(import_id: str) -> None: + shutil.rmtree(staging_dir(import_id), ignore_errors=True) diff --git a/backend/app/meetingsense/library/jobs.py b/backend/app/meetingsense/library/jobs.py new file mode 100644 index 0000000000000000000000000000000000000000..afd5c42fb8413e764c5ff35f961136791dcfaf67 --- /dev/null +++ b/backend/app/meetingsense/library/jobs.py @@ -0,0 +1,441 @@ +"""Background processing for library meetings — transcribe, index, summarise. + +A durable queue in SQLite (``ms_library_jobs``) worked by one in-process worker. Durable +because a three-hour recording outlives a request: a job survives a restart (an expired +lease is picked up again) and its progress can be shown while it runs. + +Transcription uses **HomePilot's own speech provider** — ``get_meeting_stt_provider()``, +the same local-first choice live meetings use. Nothing here ships audio anywhere: when no +local model is installed the job stops at *Needs speech-to-text setup* and the recording +waits, stored and playable, for the user to install one. + +One job at a time, and transcription steps aside while a live meeting is recording so an +import never makes a meeting in progress fall behind. +""" + +from __future__ import annotations + +import asyncio +import logging +import math +import os +import re +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + +from . import db, media +from .config import load_library_config, media_root + +log = logging.getLogger(__name__) + +_worker: Optional[asyncio.Task] = None +_wake: Optional[asyncio.Event] = None + +#: Seconds of overlap between transcription chunks — enough that a word cut by the chunk +#: boundary is heard whole in one of them. +CHUNK_OVERLAP_S = 1.0 +#: Words per notes window when an imported transcript is turned into notes. +NOTES_WINDOW_WORDS = 600 + + +class JobCancelled(Exception): + pass + + +class JobFailed(Exception): + """A failure that retrying will not fix (unreadable file, missing asset).""" + + +def wake() -> None: + if _wake is not None: + _wake.set() + + +def start_worker() -> bool: + """Start the worker if the library and processing are on. Safe to call more than once.""" + global _worker, _wake + cfg = load_library_config() + if not (cfg.enabled and cfg.processing) or os.getenv("MEETINGS_LIBRARY_WORKER", "1").strip().lower() in ("0", "false", "off"): + return False + if _worker is not None and not _worker.done(): + return True + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return False + _wake = asyncio.Event() + _worker = loop.create_task(_loop()) + return True + + +async def stop_worker() -> None: + global _worker + if _worker is not None: + _worker.cancel() + try: + await _worker + except (asyncio.CancelledError, Exception): # noqa: BLE001 + pass + _worker = None + + +async def _loop() -> None: + db.ensure_migrated() + while True: + try: + job = await asyncio.to_thread(db.lease_next_job) + except Exception: # noqa: BLE001 — a locked database is a reason to wait, not to die + log.exception("meetings library: could not lease a job") + job = None + if job is None: + assert _wake is not None + _wake.clear() + try: + await asyncio.wait_for(_wake.wait(), timeout=3.0) + except asyncio.TimeoutError: + pass + continue + await run_job(job) + + +async def run_job(job: Dict[str, Any]) -> str: + """Run one leased job to an end state. Returns that state.""" + handlers = {"transcribe": _transcribe, "index": _index, "summarize": _summarize, "analyze": _analyze, + "layout": _layout} + handler = handlers.get(job["type"]) + if handler is None: + db.update_job(job["id"], state="failed", error=f"unknown job type {job['type']}", finished=True) + return "failed" + try: + state = await handler(job) or "done" + db.update_job(job["id"], state=state, stage=state, progress=1.0 if state == "done" else None, finished=True) + return state + except JobCancelled: + db.update_job(job["id"], state="cancelled", stage="cancelled", finished=True) + if job["type"] == "transcribe": + db.update_entry(job["meeting_id"], transcript_state="none") + return "cancelled" + except Exception as exc: # noqa: BLE001 + message = str(exc) or type(exc).__name__ + log.warning("meetings library: job %s (%s) failed: %s", job["id"], job["type"], message) + retry = not isinstance(exc, JobFailed) and int(job.get("attempt") or 1) < int(job.get("max_attempts") or 1) + if retry: + db.update_job(job["id"], state="queued", stage="retrying", error=message) + return "queued" + db.update_job(job["id"], state="failed", stage="failed", error=message, finished=True) + if job["type"] == "transcribe": + db.update_entry(job["meeting_id"], transcript_state="failed") + elif job["type"] == "summarize": + db.update_entry(job["meeting_id"], summary_state="failed") + return "failed" + + +def _check_cancel(job_id: str) -> None: + if db.job_state(job_id) == "cancelled": + raise JobCancelled() + + +def _primary_audio(meeting_id: str) -> Optional[Dict[str, Any]]: + for asset in db.assets_for(meeting_id): + if asset["kind"] == "audio" and asset.get("storage_ref"): + return asset + return None + + +def _live_meeting_running() -> bool: + try: + from .. import session as session_mod # noqa: PLC0415 + + return any(getattr(s, "state", None) == session_mod.MeetingState.LIVE + for s in list(session_mod._SESSIONS.values())) + except Exception: # noqa: BLE001 + return False + + +def stt_setup_hint() -> Dict[str, Any]: + """Why transcription cannot run here, from the same probe the status route uses.""" + try: + from ..routes import stt_capability # noqa: PLC0415 + + cap = stt_capability() + except Exception as exc: # noqa: BLE001 + cap = {"available": False, "hint": f"Speech providers unavailable: {exc}"} + return {"available": bool(cap.get("available")), "provider": cap.get("provider"), + "hint": cap.get("hint"), "offer_remote": bool(cap.get("offer_remote"))} + + +def _get_provider(): + from ...voice.providers import get_meeting_stt_provider # noqa: PLC0415 + + return get_meeting_stt_provider() + + +async def _transcribe(job: Dict[str, Any]) -> str: + meeting_id = job["meeting_id"] + job_id = job["id"] + asset = _primary_audio(meeting_id) + if asset is None: + raise JobFailed("This meeting has no recording to transcribe.") + path = media_root() / asset["storage_ref"] + if not path.is_file(): + raise JobFailed("The meeting's recording is missing from HomePilot's storage.") + + db.update_job(job_id, stage="validating", progress=0.0, lease_s=600) + info = await asyncio.to_thread(media.probe, path) + if not info.get("ok"): + raise JobFailed(info.get("error") or "The recording could not be read.") + duration_s = max(0.1, (info.get("duration_ms") or 0) / 1000.0) + + level = await asyncio.to_thread(media.loudness, path) + if media.is_silent(level): + db.update_entry(meeting_id, transcript_state="no_speech") + db.add_warning(meeting_id, f"No speech detected — the recording is silent (loudest moment {level.get('max_db')} dB).") + db.update_job(job_id, diagnostic={"silent": True, **level}) + return "done" + + provider = _get_provider() + if not getattr(provider, "available", False): + hint = stt_setup_hint() + db.update_entry(meeting_id, transcript_state="needs_setup") + db.update_job(job_id, stage="needs_setup", diagnostic=hint, + error=hint.get("hint") or "No local speech-to-text model is installed.") + return "needs_setup" + + db.update_entry(meeting_id, transcript_state="transcribing") + cfg = load_library_config() + chunk_s = float(cfg.stt_chunk_s) + chunks = max(1, math.ceil(duration_s / chunk_s)) + segments: List[Dict[str, Any]] = [] + last_end_ms = -1 + started = time.time() + for index in range(chunks): + _check_cancel(job_id) + while _live_meeting_running(): # a live meeting has the model first + db.update_job(job_id, stage="waiting for live meeting", lease_s=600) + await asyncio.sleep(5) + _check_cancel(job_id) + start_s = index * chunk_s + length_s = min(chunk_s + CHUNK_OVERLAP_S, duration_s - start_s) + if length_s <= 0.05: + break + db.update_job(job_id, stage="decoding", progress=index / chunks, lease_s=900) + wav = await asyncio.to_thread(media.decode_chunk, path, start_s=start_s, length_s=length_s) + db.update_job(job_id, stage="transcribing", progress=(index + 0.2) / chunks, lease_s=900) + spans = await provider.transcribe_segments(wav, fmt="wav", duration_s=length_s) + for span in spans or []: + text = (span.get("text") or "").strip() + if not text: + continue + t0 = int(round((float(span.get("t0") or 0.0) + start_s) * 1000)) + t1_raw = span.get("t1") + t1 = None if t1_raw is None else int(round((float(t1_raw) + start_s) * 1000)) + # The overlap: a line the previous chunk already heard is not written twice. + if t0 < last_end_ms - 200: + continue + segments.append({"t0_ms": t0, "t1_ms": t1, "speaker": None, "text": text, + "conf": span.get("conf")}) + last_end_ms = max(last_end_ms, t1 if t1 is not None else t0) + db.update_job(job_id, progress=(index + 1) / chunks, + diagnostic={"chunks": chunks, "done": index + 1, "segments": len(segments), + "provider": getattr(provider, "name", None), + "elapsed_s": round(time.time() - started, 1)}) + + _check_cancel(job_id) + db.update_job(job_id, stage="persisting") + if not segments: + db.update_entry(meeting_id, transcript_state="no_speech") + db.add_warning(meeting_id, "No speech was recognised in the recording.") + return "done" + timing = "measured" if getattr(provider, "supports_segments", False) else "approximate" + model_name = os.getenv("WHISPER_MODEL", "").strip() or getattr(provider, "model_name", None) + vid = db.create_version(meeting_id, segments, origin="stt", timing=timing, + provider=getattr(provider, "name", None), model=model_name, + note="Transcribed on this computer.") + db.publish_version(meeting_id, vid) + db.update_entry(meeting_id, transcript_state="ready" if timing == "measured" else "ready_warnings") + from .importer import _queue_followups # noqa: PLC0415 + + _queue_followups(meeting_id) + return "done" + + +async def _index(job: Dict[str, Any]) -> str: + from .. import retrieval # noqa: PLC0415 + + rows = await asyncio.to_thread(retrieval.index_meeting, job["meeting_id"]) + db.update_job(job["id"], diagnostic={"indexed_rows": rows}) + return "done" + + +def _model_call(options: Dict[str, Any]): + from ..notes_engine import call_model # noqa: PLC0415 + + target = options.get("model_target") or {} + + async def call(messages, **kw): + return await call_model(messages, provider=target.get("provider", ""), model=target.get("model", ""), + base_url=target.get("base_url", ""), **kw) + + return call + + +def _only_outline(document: Dict[str, Any]) -> bool: + """True for the document minutes writes when no model answered (its first line says so).""" + text = (document.get("text") or "").lstrip() + return bool(document.get("degraded")) and text.startswith("*No language model was reachable") + + +async def _summarize(job: Dict[str, Any]) -> str: + """Minutes, then decisions and action items — if a model answers. Never fakes either.""" + from .. import minutes as minutes_mod # noqa: PLC0415 + from .. import store # noqa: PLC0415 + from ..notes_engine import NotesEngine # noqa: PLC0415 + + meeting_id = job["meeting_id"] + options = job.get("options") or {} + call = _model_call(options) + outputs = (options.get("outputs") or {}) + db.update_job(job["id"], stage="summarising", progress=0.1, lease_s=1800) + opts = None + style = outputs.get("style") + length = outputs.get("length") + if style or length: + opts = minutes_mod.options_from({"style": style or "minutes", "length": length or "standard"}) + document = await minutes_mod.autogenerate(meeting_id, call=call, options=opts) + if document is None or _only_outline(document): + # An outline assembled from the transcript is kept (the Summary tab shows it as one), + # but it is not a summary, and the meeting must not say it has one. + db.update_entry(meeting_id, summary_state="unavailable") + db.update_job(job["id"], diagnostic={"reason": "No language model answered, or there is no transcript."}) + _queue_analysis(meeting_id, options) + return "done" + + if outputs.get("actions", True) and not store.get_notes(meeting_id): + db.update_job(job["id"], stage="notes", progress=0.6) + engine = NotesEngine(meeting_id, call=call, max_words=NOTES_WINDOW_WORDS) + window: List[Dict[str, Any]] = [] + words = 0 + for seg in store.get_segments(meeting_id): + window.append(seg) + words += len((seg.get("text") or "").split()) + if words >= NOTES_WINDOW_WORDS: + engine.add(window) + await engine.run(force=True) + window, words = [], 0 + if window: + engine.add(window) + await engine.run(force=True) + db.update_entry(meeting_id, summary_state="ready") + _queue_analysis(meeting_id, options) + return "done" + + +def _queue_analysis(meeting_id: str, options: Dict[str, Any]) -> Optional[str]: + """After a summary, the typed analysis — only with ``MEETINGS_INTELLIGENCE`` on. The + model target the summary used is carried along so both talk to the same model.""" + if not load_library_config().intelligence: + return None + jid = db.create_job(meeting_id, "analyze", max_attempts=1, + options={"model_target": (options or {}).get("model_target") or {}}) + wake() + return jid + + +def _active_version_id(meeting_id: str) -> Optional[str]: + for version in db.list_versions(meeting_id): + if version.get("active"): + return version.get("id") + return None + + +async def _analyze(job: Dict[str, Any]) -> str: + """The typed analysis, the visual page and the suggested questions for one meeting. + + Runs on the model lane the summary used; with no model it still finishes, with an + extractive analysis that says so. ``ValueError("no_transcript")`` is a permanent failure + for this transcript, not something a retry fixes. + """ + from ..intelligence import service as intelligence # noqa: PLC0415 + + meeting_id = job["meeting_id"] + options = job.get("options") or {} + cfg = load_library_config() + call = _model_call(options) if cfg.processing else None + db.update_job(job["id"], stage="analysing", progress=0.05, lease_s=1800) + + def progress(stage: str, fraction: float) -> None: + db.update_job(job["id"], stage=stage, progress=fraction, lease_s=1800) + + try: + result = await intelligence.run_analysis( + meeting_id, call=call, model=(options.get("model_target") or {}).get("model"), + transcript_version_id=_active_version_id(meeting_id), progress=progress, + ) + except ValueError as exc: + if str(exc) == "no_transcript": + raise JobFailed("There is no transcript to analyse yet.") from exc + raise + report = (result.get("report") or {}) + retitled = _maybe_retitle(meeting_id, result) if cfg.auto_title else None + db.update_job(job["id"], diagnostic={"degraded": result.get("degraded"), "kept": report.get("kept"), + "dropped": report.get("dropped"), "analysis_id": result.get("id"), + "retitled": retitled}) + return "done" + + +# A recorder default or the library's own fallback: a keyword followed by nothing but dates, +# times and separators — "Imported meeting · Oct 10, 12:31", "Recording 12", "Meeting". +_GENERIC_TITLE = re.compile( + r"^(?:imported meeting|new recording|recording|transcript|meeting|untitled|audio|rec)" + r"(?:[\s·._\-:∶,/]*(?:\d+|[a-z]{3,9}\.? \d{1,2}|\d{1,2}:\d{2}|am|pm))*$", re.IGNORECASE) + + +def generic_title(title: str) -> bool: + """True for a title that names nothing: empty, only a timestamp (a recorder's file name), + or a default such as "Imported meeting · Oct 10, 12:31".""" + from .adapters import display_title, time_from_name # noqa: PLC0415 + + text = " ".join((title or "").split()) + if not text: + return True + if time_from_name(text) and display_title(text, fallback_prefix="~time-only~").startswith("~time-only~"): + return True + return bool(_GENERIC_TITLE.match(text)) + + +def _maybe_retitle(meeting_id: str, result: Dict[str, Any]) -> Optional[str]: + """Give a meeting whose title names nothing the title its analysis found. Only after a + model answered (an extractive analysis keeps the recorded title), only over a generic + title, never over one a person typed. Returns the new title, or None.""" + from .. import store as ms_store # noqa: PLC0415 + + analysis = result.get("analysis") or {} + new = " ".join(str(analysis.get("title") or "").split())[:120].strip(" .") + if result.get("degraded") or not new or generic_title(new): + return None + meeting = ms_store.get_meeting(meeting_id) or {} + old = str(meeting.get("title") or "") + if not generic_title(old) or new.lower() == old.strip().lower(): + return None + ms_store.update_meeting(meeting_id, {"title": new}) + log.info("meetings: %s titled from its analysis: %r -> %r", meeting_id, old, new) + return new + + +async def _layout(job: Dict[str, Any]) -> str: + """Write the Executive Brief again for the latest analysis, without re-extracting.""" + from ..intelligence import service as intelligence # noqa: PLC0415 + + options = job.get("options") or {} + call = _model_call(options) if load_library_config().processing else None + db.update_job(job["id"], stage="writing the brief", progress=0.2, lease_s=900) + try: + result = await intelligence.recompose(job["meeting_id"], call=call) + except ValueError as exc: + if str(exc) == "no_analysis": + raise JobFailed("There is no analysis to lay out yet.") from exc + raise + brief = ((result.get("visual") or {}).get("brief") or {}) + db.update_job(job["id"], diagnostic={"polished": brief.get("polished"), "source_count": brief.get("source_count")}) + return "done" diff --git a/backend/app/meetingsense/library/media.py b/backend/app/meetingsense/library/media.py new file mode 100644 index 0000000000000000000000000000000000000000..29072b4980afc86a4140e9b95e3bafee4bf63cb5 --- /dev/null +++ b/backend/app/meetingsense/library/media.py @@ -0,0 +1,172 @@ +"""Audio helpers for imported meetings — probing, silence detection, decoding, waveforms. + +FFmpeg does the decoding (HomePilot already needs it for Creator Studio), always on a copy +the library owns and never on the source file. Every call has a timeout and none of them +raises into a request: a file FFmpeg cannot read is reported as unreadable, which is an +answer the import preview can show. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional + +AUDIO_EXTENSIONS = (".mp3", ".m4a", ".wav", ".ogg", ".oga", ".opus", ".flac", ".webm", ".aac", ".mp4") + +#: Content signatures — the extension is a hint, these are the check. +_MAGIC = ( + (b"ID3", "audio/mpeg"), + (b"\xff\xfb", "audio/mpeg"), + (b"\xff\xf3", "audio/mpeg"), + (b"\xff\xf2", "audio/mpeg"), + (b"OggS", "audio/ogg"), + (b"fLaC", "audio/flac"), + (b"\x1a\x45\xdf\xa3", "audio/webm"), +) + +#: Loudest moment below this means nobody spoke: a recorder left on in a quiet room, or a +#: file that is technically audio and contains nothing. Not transcribed; reported as such. +SILENCE_MAX_DB = -55.0 + + +def sniff_audio(head: bytes) -> Optional[str]: + """The audio MIME type a file's first bytes prove, or None.""" + for magic, mime in _MAGIC: + if head.startswith(magic): + return mime + if head[:4] == b"RIFF" and head[8:12] == b"WAVE": + return "audio/wav" + if head[4:8] == b"ftyp": + return "audio/mp4" + if len(head) >= 2 and head[0] == 0xFF and (head[1] & 0xF6) == 0xF0: + return "audio/aac" + return None + + +def ffmpeg_available() -> bool: + return bool(shutil.which("ffmpeg") and shutil.which("ffprobe")) + + +def _run(args: List[str], *, timeout: float, input_bytes: Optional[bytes] = None) -> subprocess.CompletedProcess: + return subprocess.run(args, input=input_bytes, capture_output=True, timeout=timeout, check=False) + + +def probe(path: Path) -> Dict[str, Any]: + """Duration, codec, rate and channels as FFprobe measures them (not as metadata claims).""" + if not shutil.which("ffprobe"): + return {"ok": False, "error": "FFmpeg is not installed on this computer."} + try: + res = _run( + ["ffprobe", "-v", "error", "-show_entries", + "format=duration,bit_rate:stream=codec_type,codec_name,sample_rate,channels", + "-of", "json", str(path)], + timeout=30, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return {"ok": False, "error": f"Could not read the audio ({type(exc).__name__})."} + if res.returncode != 0: + return {"ok": False, "error": "This file is not audio FFmpeg can read."} + try: + body = json.loads(res.stdout or b"{}") + except ValueError: + return {"ok": False, "error": "This file is not audio FFmpeg can read."} + streams = [s for s in body.get("streams") or [] if s.get("codec_type") == "audio"] + if not streams: + return {"ok": False, "error": "The file has no audio track."} + stream = streams[0] + try: + duration_ms = int(round(float((body.get("format") or {}).get("duration") or 0) * 1000)) + except ValueError: + duration_ms = 0 + return { + "ok": True, + "duration_ms": duration_ms, + "codec": stream.get("codec_name"), + "sample_rate": int(stream.get("sample_rate") or 0) or None, + "channels": stream.get("channels"), + } + + +def loudness(path: Path) -> Dict[str, Optional[float]]: + """Mean and peak level in dB (FFmpeg ``volumedetect``).""" + if not shutil.which("ffmpeg"): + return {"mean_db": None, "max_db": None} + try: + res = _run(["ffmpeg", "-hide_banner", "-nostats", "-i", str(path), "-af", "volumedetect", + "-vn", "-f", "null", "-"], timeout=600) + except (OSError, subprocess.TimeoutExpired): + return {"mean_db": None, "max_db": None} + text = (res.stderr or b"").decode("utf-8", errors="replace") + + def grab(key: str) -> Optional[float]: + for line in text.splitlines(): + if f"{key}:" in line: + try: + return float(line.split(f"{key}:")[1].strip().split()[0]) + except (IndexError, ValueError): + return None + return None + + return {"mean_db": grab("mean_volume"), "max_db": grab("max_volume")} + + +def is_silent(level: Dict[str, Optional[float]]) -> bool: + peak = level.get("max_db") + return peak is not None and peak < SILENCE_MAX_DB + + +def decode_chunk(path: Path, *, start_s: float, length_s: float) -> bytes: + """One stretch of the recording as 16 kHz mono 16-bit WAV — what speech models expect. + + Resampled from the library's copy; the original file is never rewritten. + """ + res = _run( + ["ffmpeg", "-hide_banner", "-nostats", "-loglevel", "error", "-ss", f"{max(0.0, start_s):.3f}", + "-t", f"{max(0.1, length_s):.3f}", "-i", str(path), "-vn", "-ac", "1", "-ar", "16000", + "-sample_fmt", "s16", "-f", "wav", "-"], + timeout=max(120.0, length_s * 2), + ) + if res.returncode != 0: + raise RuntimeError((res.stderr or b"").decode("utf-8", errors="replace").strip()[:300] or "decode failed") + return res.stdout + + +def peaks(path: Path, *, bars: int = 400) -> List[float]: + """A waveform: ``bars`` values 0..1 from 8 kHz mono PCM. Empty when it cannot be read.""" + if not shutil.which("ffmpeg"): + return [] + try: + res = _run(["ffmpeg", "-hide_banner", "-nostats", "-loglevel", "error", "-i", str(path), "-vn", + "-ac", "1", "-ar", "8000", "-f", "s16le", "-"], timeout=600) + except (OSError, subprocess.TimeoutExpired): + return [] + data = res.stdout or b"" + count = len(data) // 2 + if res.returncode != 0 or count == 0: + return [] + import array # noqa: PLC0415 + + samples = array.array("h") + samples.frombytes(data[: count * 2]) + if sys.byteorder == "big": # pragma: no cover — little-endian PCM from FFmpeg + samples.byteswap() + step = max(1, count // bars) + out: List[float] = [] + for i in range(0, count, step): + window = samples[i:i + step] + if not window: + break + out.append(max(abs(min(window)), abs(max(window))) / 32768.0) + if len(out) >= bars: + break + top = max(out) if out else 0.0 + # Normalised for display, but a silent file stays flat rather than being blown up to noise. + if top > 0.02: + out = [round(v / top, 3) for v in out] + else: + out = [round(v, 3) for v in out] + return out diff --git a/backend/app/meetingsense/library/parsers.py b/backend/app/meetingsense/library/parsers.py new file mode 100644 index 0000000000000000000000000000000000000000..d93f25d8ee85a995fbeffa0eab20cce849d995c6 --- /dev/null +++ b/backend/app/meetingsense/library/parsers.py @@ -0,0 +1,292 @@ +"""Reading transcripts people bring in — SRT, WebVTT, plain text / Markdown, JSON. + +Each parser returns a :class:`Parsed` with the segments it found and *how much it knows*: +``timing`` is ``imported`` when the file carried timestamps and ``none`` when it did not. A +transcript without times is still a transcript — it is stored, searched and summarised — but +nothing pretends its lines happened at a particular second. + +Speakers are kept exactly as written. Nothing here invents a name for an unlabelled line. + +An empty result is reported as empty. A JSON file whose ``Result_info`` is ``[]`` (an +Insta360 export with no recognised speech) parses to zero segments and the importer says +"Needs transcription" — never "Transcribed". +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import Any, Dict, Iterable, List, Optional + +Segment = Dict[str, Any] + +TEXT_EXTENSIONS = (".txt", ".md", ".markdown") +TRANSCRIPT_EXTENSIONS = (".srt", ".vtt", ".json") + TEXT_EXTENSIONS + + +@dataclass +class Parsed: + segments: List[Segment] = field(default_factory=list) + timing: str = "none" # "imported" when times came from the file + format: str = "" + warnings: List[str] = field(default_factory=list) + + @property + def usable(self) -> bool: + return any((s.get("text") or "").strip() for s in self.segments) + + +_TS = re.compile(r"(?:(\d{1,2}):)?(\d{1,2}):(\d{2})(?:[.,](\d{1,3}))?") + + +def parse_timestamp(value: str) -> Optional[int]: + """``01:02:03,456`` / ``02:03.4`` / ``1:02:03`` → milliseconds, or None.""" + m = _TS.fullmatch(value.strip()) + if not m: + return None + h, mi, s, frac = m.groups() + ms = int((frac or "0").ljust(3, "0")[:3]) + return ((int(h or 0) * 60 + int(mi)) * 60 + int(s)) * 1000 + ms + + +def _clean(text: str) -> str: + text = re.sub(r"<[^>]+>", "", text) # VTT voice/style tags + return re.sub(r"\s+", " ", text).strip() + + +_SPEAKER_PREFIX = re.compile(r"^([A-Z][\w .'\-]{0,40}?|Speaker\s*\d+|SPEAKER_\d+)\s*:\s+(.+)$") + + +def _split_speaker(text: str): + m = _SPEAKER_PREFIX.match(text) + if m and len(m.group(1).split()) <= 4: + return m.group(1).strip(), m.group(2).strip() + return None, text + + +def parse_srt(raw: str) -> Parsed: + out = Parsed(format="srt", timing="imported") + blocks = re.split(r"\n\s*\n", raw.replace("\r\n", "\n").replace("\r", "\n").strip()) + for block in blocks: + lines = [ln for ln in block.split("\n") if ln.strip()] + if not lines: + continue + if lines[0].strip().isdigit(): + lines = lines[1:] + if not lines or "-->" not in lines[0]: + continue + start, _, end = lines[0].partition("-->") + t0 = parse_timestamp(start) + t1 = parse_timestamp(end.split()[0] if end.split() else "") + text = _clean(" ".join(lines[1:])) + if t0 is None or not text: + continue + speaker, text = _split_speaker(text) + out.segments.append({"t0_ms": t0, "t1_ms": t1, "speaker": speaker, "text": text, "conf": None}) + return out + + +def parse_vtt(raw: str) -> Parsed: + out = Parsed(format="vtt", timing="imported") + text = raw.replace("\r\n", "\n").replace("\r", "\n") + if text.startswith(""): + text = text[1:] + for block in re.split(r"\n\s*\n", text.strip()): + lines = [ln for ln in block.split("\n") if ln.strip()] + if not lines or lines[0].startswith(("WEBVTT", "NOTE", "STYLE", "REGION")): + continue + idx = next((i for i, ln in enumerate(lines) if "-->" in ln), None) + if idx is None: + continue + start, _, rest = lines[idx].partition("-->") + t0 = parse_timestamp(start) + t1 = parse_timestamp(rest.split()[0] if rest.split() else "") + body = " ".join(lines[idx + 1:]) + voice = re.match(r"]+)>", body) + speaker = voice.group(1).strip() if voice else None + body = _clean(body) + if not speaker: + speaker, body = _split_speaker(body) + if t0 is None or not body: + continue + out.segments.append({"t0_ms": t0, "t1_ms": t1, "speaker": speaker, "text": body, "conf": None}) + return out + + +# "[00:01:23] Alice: hello", "00:01:23 - Alice: hello", "Alice (01:23): hello", "(01:23) hello" +_LINE_TIMED = ( + re.compile(r"^\[?(\d{1,2}:\d{2}(?::\d{2})?(?:[.,]\d{1,3})?)\]?\s*[-–—]?\s*(.*)$"), + re.compile(r"^(.{1,40}?)\s*[\[(](\d{1,2}:\d{2}(?::\d{2})?)[\])]\s*:?\s*(.*)$"), +) + + +def parse_text(raw: str, *, fmt: str = "txt") -> Parsed: + """Plain text or Markdown. Timestamps at the start of a line are read; others are not.""" + out = Parsed(format=fmt) + timed = 0 + paragraph: List[str] = [] + + def flush(): + if paragraph: + text = _clean(" ".join(paragraph)) + if text: + speaker, text = _split_speaker(text) + out.segments.append({"t0_ms": 0, "t1_ms": None, "speaker": speaker, "text": text, "conf": None}) + paragraph.clear() + + for line in raw.replace("\r\n", "\n").replace("\r", "\n").split("\n"): + stripped = line.strip() + if fmt in ("md", "markdown"): + stripped = re.sub(r"^(#{1,6}\s+|[-*+]\s+|>\s+)", "", stripped) + stripped = stripped.replace("**", "").replace("__", "") + if not stripped: + flush() + continue + m = _LINE_TIMED[0].match(stripped) + if m and parse_timestamp(m.group(1)) is not None and m.group(2).strip(): + flush() + speaker, text = _split_speaker(_clean(m.group(2))) + out.segments.append({"t0_ms": parse_timestamp(m.group(1)), "t1_ms": None, + "speaker": speaker, "text": text, "conf": None}) + timed += 1 + continue + m = _LINE_TIMED[1].match(stripped) + if m and parse_timestamp(m.group(2)) is not None and m.group(3).strip(): + flush() + out.segments.append({"t0_ms": parse_timestamp(m.group(2)), "t1_ms": None, + "speaker": m.group(1).strip(), "text": _clean(m.group(3)), "conf": None}) + timed += 1 + continue + paragraph.append(stripped) + flush() + if timed and timed >= max(1, len(out.segments) // 2): + out.timing = "imported" + _fill_ends(out.segments) + return out + + +def _fill_ends(segments: List[Segment]) -> None: + """An end is the next start when the file only gave starts. Last one stays unknown.""" + for cur, nxt in zip(segments, segments[1:]): + if cur.get("t1_ms") is None and nxt.get("t0_ms") is not None and nxt["t0_ms"] >= cur["t0_ms"]: + cur["t1_ms"] = nxt["t0_ms"] + + +def _num_ms(value: Any, *, seconds: bool) -> Optional[int]: + if value is None or isinstance(value, bool): + return None + if isinstance(value, str): + ts = parse_timestamp(value) + if ts is not None: + return ts + try: + value = float(value) + except ValueError: + return None + if isinstance(value, (int, float)): + return int(round(value * 1000)) if seconds else int(round(value)) + return None + + +_TEXT_KEYS = ("text", "content", "sentence", "transcript", "onebest", "words_text") +_START_KEYS = ("start", "begin", "bg", "start_time", "startTime", "t0", "from", "offset") +_END_KEYS = ("end", "ed", "end_time", "endTime", "t1", "to") +_SPEAKER_KEYS = ("speaker", "spk", "speaker_name", "speakerName", "speaker_id", "role", "name") + + +def _looks_seconds(items: List[Dict[str, Any]], key: str) -> bool: + """Whisper writes seconds (floats); many exporters write milliseconds (big ints).""" + values = [i.get(key) for i in items if isinstance(i.get(key), (int, float))] + if not values: + return True + if any(isinstance(v, float) and not float(v).is_integer() for v in values): + return True + return max(values) < 36_000 # under ten hours if read as seconds + + +def _segments_from_items(items: Iterable[Any]) -> List[Segment]: + items = [i for i in items if isinstance(i, dict)] + if not items: + return [] + start_key = next((k for k in _START_KEYS if any(k in i for i in items)), None) + end_key = next((k for k in _END_KEYS if any(k in i for i in items)), None) + seconds = _looks_seconds(items, start_key) if start_key else True + out: List[Segment] = [] + for item in items: + text = next((item[k] for k in _TEXT_KEYS if isinstance(item.get(k), str) and item[k].strip()), "") + if not text and isinstance(item.get("words"), list): + text = " ".join(str(w.get("word") or w.get("text") or "") for w in item["words"] if isinstance(w, dict)) + text = _clean(text) + if not text: + continue + speaker = next((str(item[k]).strip() for k in _SPEAKER_KEYS if item.get(k) not in (None, "")), None) + out.append({ + "t0_ms": _num_ms(item.get(start_key), seconds=seconds) if start_key else None, + "t1_ms": _num_ms(item.get(end_key), seconds=seconds) if end_key else None, + "speaker": speaker, + "text": text, + "conf": item.get("confidence") if isinstance(item.get("confidence"), (int, float)) else None, + }) + return out + + +def parse_json(raw: str) -> Parsed: + out = Parsed(format="json") + try: + body = json.loads(raw) + except ValueError: + out.warnings.append("The JSON file could not be read.") + return out + items: Any = None + if isinstance(body, list): + items = body + elif isinstance(body, dict): + # Whisper verbose_json, generic {segments|utterances|transcript}, Insta360 AIRecord. + for key in ("segments", "utterances", "transcript", "results", "Result_info", "result_info", "sentences"): + value = body.get(key) + if isinstance(value, list): + items = value + break + if isinstance(value, dict) and isinstance(value.get("segments"), list): + items = value["segments"] + break + if items is None and isinstance(body.get("text"), str) and body["text"].strip(): + out.segments = [{"t0_ms": 0, "t1_ms": None, "speaker": None, "text": _clean(body["text"]), "conf": None}] + return out + if items is None: + out.warnings.append("No transcript was found in the JSON file.") + return out + segments = _segments_from_items(items) + if segments and all(s["t0_ms"] is not None for s in segments): + out.timing = "imported" + _fill_ends(segments) + for s in segments: + if s["t0_ms"] is None: + s["t0_ms"] = 0 + out.segments = segments + return out + + +def parse_transcript(name: str, raw: bytes) -> Parsed: + """Pick a parser by extension (the content is text in every case).""" + text = raw.decode("utf-8-sig", errors="replace") + lower = name.lower() + if lower.endswith(".srt"): + return parse_srt(text) + if lower.endswith(".vtt"): + return parse_vtt(text) + if lower.endswith(".json"): + return parse_json(text) + if lower.endswith((".md", ".markdown")): + return parse_text(text, fmt="md") + return parse_text(text, fmt="txt") + + +def monotonic(segments: List[Segment]) -> List[Segment]: + """Sort by start and clamp ends that run backwards; keeps every line.""" + ordered = sorted(segments, key=lambda s: (int(s.get("t0_ms") or 0))) + for seg in ordered: + if seg.get("t1_ms") is not None and seg["t1_ms"] < seg["t0_ms"]: + seg["t1_ms"] = seg["t0_ms"] + return ordered diff --git a/backend/app/meetingsense/library/projection.py b/backend/app/meetingsense/library/projection.py new file mode 100644 index 0000000000000000000000000000000000000000..aaaed45881119905323059eb6f374f0e4a81c557 --- /dev/null +++ b/backend/app/meetingsense/library/projection.py @@ -0,0 +1,314 @@ +"""The Meetings Library — one list over every kind of meeting HomePilot has. + +Projected, not copied: a meeting recorded live, an imported recording, a synced Insta360 +folder and a scheduled meeting are all rows in ``ms_meetings`` (with the library's companion +row beside the later three), and a Teams room is read from the Teams store as it is. Each +appears once, under a stable ``library_id``, and opens in the place it belongs to. + +Teams rooms are persona conversations, not recordings: they are listed as such (no audio, +transcript-only), only once a room has had a real exchange, and they open in Teams. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional, Sequence + +from . import db + +PAGE_MAX = 60 +#: A Teams room becomes a library item once it has had at least this many messages — a +#: room that was created and never used is not a meeting. +TEAMS_MIN_MESSAGES = 4 + +TABS = ("all", "project", "recorded", "imported", "unsorted", "processing") +SORTS = ("newest", "oldest", "title", "duration") +_PROCESSING = {"queued", "transcribing", "needs_setup", "failed"} + +_SOURCE_LABELS = { + "insta360": "Insta360 Import", + "upload": "Upload", + "folder": "Folder Sync", + "transcript": "Transcript Only", + "teams_room": "Teams room", + "scheduled": "Scheduled", +} +_LIVE_SOURCE_LABELS = { + "zoom": "Zoom", "meet": "Google Meet", "teams": "Microsoft Teams", "webex": "Webex", + "slack": "Slack", "discord": "Discord", "local": "This computer", +} + + +def _projects_by_id() -> Dict[str, Dict[str, Any]]: + try: + from ... import projects as projects_mod # noqa: PLC0415 + + out = {p.get("id"): p for p in projects_mod.list_all_projects() if p.get("id")} + for p in projects_mod.get_example_projects(): + out.setdefault(p["id"], p) + return out + except Exception: # noqa: BLE001 + return {} + + +def _latest_notes(meeting_ids: Sequence[str]) -> Dict[str, Dict[str, Any]]: + out: Dict[str, Dict[str, Any]] = {} + if not meeting_ids: + return out + con = db._connect() + try: + for i in range(0, len(meeting_ids), 500): + part = list(meeting_ids[i:i + 500]) + rows = con.execute( + "SELECT n.meeting_id, n.json FROM ms_notes n JOIN (SELECT meeting_id, MAX(version) v FROM ms_notes" + f" WHERE meeting_id IN ({','.join('?' * len(part))}) GROUP BY meeting_id) x" + " ON x.meeting_id = n.meeting_id AND x.v = n.version", + part, + ).fetchall() + for r in rows: + try: + out[r["meeting_id"]] = json.loads(r["json"]) or {} + except (TypeError, ValueError): + pass + finally: + con.close() + return out + + +def _segment_counts() -> Dict[str, int]: + con = db._connect() + try: + return {r["meeting_id"]: r["c"] for r in con.execute( + "SELECT meeting_id, COUNT(*) c FROM ms_segments GROUP BY meeting_id").fetchall()} + finally: + con.close() + + +def _live_ids() -> set: + try: + from .. import session as session_mod # noqa: PLC0415 + + return {mid for mid, s in session_mod._SESSIONS.items() + if getattr(s, "state", None) == session_mod.MeetingState.LIVE} + except Exception: # noqa: BLE001 + return set() + + +def _summary_line(notes: Dict[str, Any], entry: Dict[str, Any]) -> str: + text = (entry or {}).get("description") or "" + if not text: + body = notes.get("notes") if isinstance(notes.get("notes"), dict) else notes + text = str((body or {}).get("summary") or "") + text = " ".join(text.split()) + return text[:220] + + +def meeting_items(viewer: str = "") -> List[Dict[str, Any]]: + """Every MeetingSense / library meeting the viewer may see, as library items.""" + con = db._connect() + try: + rows = [dict(r) for r in con.execute("SELECT * FROM ms_meetings").fetchall()] + finally: + con.close() + ids = [r["id"] for r in rows] + entries = db.entries_by_id(ids) + links = db.project_links(ids) + projects = _projects_by_id() + notes = _latest_notes(ids) + counts = _segment_counts() + live = _live_ids() + items: List[Dict[str, Any]] = [] + for row in rows: + entry = entries.get(row["id"]) or {} + if entry.get("hidden_at"): + continue + owner = entry.get("owner_user_id") or "" + if viewer and owner and owner != viewer: + continue + items.append(_item(row, entry, links.get(row["id"], []), projects, notes.get(row["id"], {}), + counts.get(row["id"], 0), row["id"] in live)) + return items + + +def _item(row: Dict[str, Any], entry: Dict[str, Any], links: List[Dict[str, Any]], + projects: Dict[str, Dict[str, Any]], notes: Dict[str, Any], segments: int, live: bool) -> Dict[str, Any]: + origin = entry.get("origin_type") or "meetingsense" + status = row.get("status") or "" + if origin == "meetingsense": + recording = "recording" if status in ("live", "suspended") else "none" + transcript = "live" if status in ("live", "suspended") else ("ready" if segments else "none") + source_kind = "meetingsense" + source_label = _LIVE_SOURCE_LABELS.get((row.get("source") or "").lower(), "HomePilot") + else: + recording = entry.get("recording_state") or "none" + transcript = entry.get("transcript_state") or "none" + source_kind = row.get("source") or ("scheduled" if origin == "scheduled" else "upload") + source_label = _SOURCE_LABELS.get(source_kind, source_kind.title()) + project_list = [] + for link in links: + p = projects.get(link["project_id"]) or {} + project_list.append({"id": link["project_id"], "name": p.get("name") or "Missing project", + "kind": link.get("kind"), "missing": not p}) + category = entry.get("category") + if not category: + category = "Project Meeting" if project_list else ("Recorded" if origin == "meetingsense" else "Meeting") + started = row.get("started_at") + ended = row.get("ended_at") + duration_ms = entry.get("duration_ms") + if duration_ms is None and started and ended and ended > started: + duration_ms = int((ended - started) * 1000) + return { + "library_id": row["id"], + "meeting_id": row["id"], + "origin_type": origin, + "origin_id": row["id"], + "title": (row.get("title") or "").strip() or "Untitled meeting", + "description": _summary_line(notes, entry), + "category": category, + "started_at": started, + "ended_at": ended, + "scheduled_for": entry.get("scheduled_for"), + "duration_ms": duration_ms, + "projects": project_list, + "source": {"kind": source_kind, "label": source_label}, + "media_present": bool(entry.get("media_present")), + "recording_state": recording, + "transcript_state": transcript, + "summary_state": entry.get("summary_state") or ("ready" if notes else "none"), + "segments": segments, + "conversation_id": row.get("conversation_id"), + "status": status, + "live": live, + "warnings": entry.get("warnings") or [], + } + + +def teams_items() -> List[Dict[str, Any]]: + """Teams rooms that held a real conversation — read-only, opened in Teams.""" + try: + from ...teams import rooms as rooms_mod # noqa: PLC0415 + + rooms = rooms_mod.list_rooms() + except Exception: # noqa: BLE001 + return [] + out = [] + for room in rooms: + msgs = room.get("messages") or [] + if len(msgs) < TEAMS_MIN_MESSAGES: + continue + first = msgs[0].get("timestamp") or room.get("created_at") + last = msgs[-1].get("timestamp") or room.get("updated_at") + out.append({ + "library_id": f"teams_room:{room['id']}", + "meeting_id": None, + "origin_type": "teams_room", + "origin_id": room["id"], + "title": room.get("name") or "Team meeting", + "description": " ".join(str(room.get("topic") or room.get("description") or "").split())[:220], + "category": "Team Meeting", + "started_at": first, + "ended_at": last, + "scheduled_for": None, + "duration_ms": None, + "projects": [], + "source": {"kind": "teams_room", "label": "Teams room"}, + "media_present": False, + "recording_state": "none", + "transcript_state": "transcript_only", + "summary_state": "none", + "segments": len(msgs), + "conversation_id": None, + "status": room.get("status") or "", + "live": False, + "warnings": [], + }) + return out + + +def _matches_query(item: Dict[str, Any], q: str, transcript_hits: set) -> bool: + if not q: + return True + hay = " ".join([ + item.get("title") or "", item.get("description") or "", item.get("category") or "", + (item.get("source") or {}).get("label") or "", " ".join(p["name"] for p in item.get("projects") or []), + ]).lower() + return all(word in hay for word in q.lower().split()) or (item.get("meeting_id") in transcript_hits) + + +def _transcript_hits(q: str) -> set: + q = " ".join(q.split()) + if len(q) < 3: + return set() + con = db._connect() + try: + return {r["meeting_id"] for r in con.execute( + "SELECT DISTINCT meeting_id FROM ms_segments WHERE text LIKE ? LIMIT 500", (f"%{q}%",)).fetchall()} + finally: + con.close() + + +def _in_tab(item: Dict[str, Any], tab: str) -> bool: + if tab == "project": + return bool(item["projects"]) + if tab == "recorded": + return item["origin_type"] in ("meetingsense",) or (item["origin_type"] == "scheduled" and item["media_present"]) + if tab == "imported": + return item["origin_type"] in ("import", "sync") + if tab == "unsorted": + return not item["projects"] and item["origin_type"] != "teams_room" + if tab == "processing": + return item["transcript_state"] in _PROCESSING or item["summary_state"] == "pending" + return True + + +def list_library(*, viewer: str = "", tab: str = "all", q: str = "", source: str = "", project_id: str = "", + state: str = "", date_from: Optional[float] = None, date_to: Optional[float] = None, + sort: str = "newest", cursor: str = "", limit: int = 24) -> Dict[str, Any]: + items = meeting_items(viewer) + teams_items() + counts = {t: sum(1 for i in items if _in_tab(i, t)) for t in TABS} + hits = _transcript_hits(q) if q else set() + shown = [ + i for i in items + if _in_tab(i, tab if tab in TABS else "all") + and _matches_query(i, q, hits) + and (not source or i["source"]["kind"] == source) + and (not project_id or any(p["id"] == project_id for p in i["projects"])) + and (not state or i["transcript_state"] == state) + and (date_from is None or (i.get("started_at") or 0) >= date_from) + and (date_to is None or (i.get("started_at") or 0) <= date_to) + ] + key = { + "oldest": lambda i: (i.get("started_at") or 0), + "title": lambda i: (i.get("title") or "").lower(), + "duration": lambda i: -(i.get("duration_ms") or 0), + }.get(sort) + if key is None: + shown.sort(key=lambda i: (i.get("started_at") or 0), reverse=True) + else: + shown.sort(key=key) + try: + offset = max(0, int(cursor or 0)) + except ValueError: + offset = 0 + limit = max(1, min(int(limit or 24), PAGE_MAX)) + page = shown[offset:offset + limit] + nxt = str(offset + limit) if offset + limit < len(shown) else None + sources = sorted({(i["source"]["kind"], i["source"]["label"]) for i in items}) + return { + "items": page, + "next_cursor": nxt, + "total": len(shown), + "counts": counts, + "facets": {"sources": [{"kind": k, "label": label} for k, label in sources], + "projects": [{"id": pid, "name": name} for pid, name in sorted( + {(p["id"], p["name"]) for i in items for p in i["projects"]}, key=lambda x: x[1].lower())]}, + } + + +def find_item(library_id: str, viewer: str = "") -> Optional[Dict[str, Any]]: + if library_id.startswith("teams_room:"): + return next((i for i in teams_items() if i["library_id"] == library_id), None) + for item in meeting_items(viewer): + if item["library_id"] == library_id: + return item + return None diff --git a/backend/app/meetingsense/library/routes.py b/backend/app/meetingsense/library/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..3a0afe4afbc8a9f35a933f31660e9eecab32fa85 --- /dev/null +++ b/backend/app/meetingsense/library/routes.py @@ -0,0 +1,977 @@ +"""HTTP routes for the Meetings Library (all additive, all under ``/v1/meetingsense``). + +Registered **before** MeetingSense's own router: its ``GET /v1/meetingsense/{meeting_id}`` +would otherwise read ``library`` or ``imports`` as a meeting id. Every existing route keeps +its path and its payload. + +Gated by ``MEETINGS_LIBRARY_UPGRADE`` (default off) — with it off these answer 404 and the +legacy Meetings view is what the app shows. ``/library/status`` always answers, so the +frontend can ask which one to show. + +Who may see what: imported, synced and scheduled meetings belong to the user who created +them (the data-layer rule HomePilot uses elsewhere); meetings recorded live are visible as +they always were. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import re +import shutil +import uuid +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Cookie, File, Form, Header, HTTPException, Query, Request, UploadFile +from fastapi.responses import JSONResponse, Response, StreamingResponse + +from .. import store +from ..config import load_config +from . import db, importer, jobs, media, projection, sync +from .config import load_library_config, media_root + +router = APIRouter(tags=["meetings-library"]) + + +def _viewer(authorization: Optional[str], session: Optional[str]) -> str: + try: + from ...inventory import _resolve_inventory_user_id # noqa: PLC0415 + + return _resolve_inventory_user_id(authorization or "", session) + except Exception: # noqa: BLE001 + return "" + + +def _require_enabled() -> None: + if not (load_config().enabled and load_library_config().enabled): + raise HTTPException(status_code=404, detail="not found") + db.ensure_migrated() + # Started lazily as well as at startup: a test client or a reload may skip startup. + jobs.start_worker() + sync.start_sync_loop() + + +def _error(status: int, code: str, message: str, hint: str = "") -> HTTPException: + return HTTPException(status_code=status, detail={"code": code, "message": message, "hint": hint}) + + +def _owned_entry(meeting_id: str, viewer: str) -> Dict[str, Any]: + """The meeting, if it exists and this viewer may act on it (404 otherwise, never 403 — + a stranger must not learn which ids exist).""" + meeting = store.get_meeting(meeting_id) + if meeting is None: + raise HTTPException(status_code=404, detail="not found") + entry = db.get_entry(meeting_id) or {} + owner = entry.get("owner_user_id") or "" + # Fail closed: an owned meeting is never shown to an empty viewer. On a single-user + # install the viewer resolves to that user, so this only bites when several accounts + # exist and a request arrives with no token — which used to see everything. + if owner and (not viewer or owner != viewer): + raise HTTPException(status_code=404, detail="not found") + return {"meeting": meeting, "entry": entry} + + +def _require_intelligence() -> None: + """The analysis routes exist only with ``MEETINGS_INTELLIGENCE`` on (404 otherwise, like + every other gated route, so a caller cannot tell a flag from a missing id).""" + if not load_library_config().intelligence: + raise HTTPException(status_code=404, detail="not found") + + +# ── status ────────────────────────────────────────────────────────────────── + + +@router.get("/v1/meetingsense/library/status") +async def library_status() -> Dict[str, Any]: + cfg = load_library_config() + body: Dict[str, Any] = {**cfg.as_dict(), "enabled": bool(cfg.enabled and load_config().enabled), + "ffmpeg": media.ffmpeg_available()} + if body["enabled"]: + try: + body["stt"] = jobs.stt_setup_hint() + except Exception: # noqa: BLE001 + body["stt"] = {"available": False} + return body + + +# ── library ───────────────────────────────────────────────────────────────── + + +@router.get("/v1/meetingsense/library") +async def library_list( + tab: str = Query("all"), q: str = Query(""), source: str = Query(""), project_id: str = Query(""), + state: str = Query(""), date_from: Optional[float] = Query(None), date_to: Optional[float] = Query(None), + sort: str = Query("newest"), cursor: str = Query(""), limit: int = Query(24), + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + _require_enabled() + viewer = _viewer(authorization, homepilot_session) + return await asyncio.to_thread( + projection.list_library, viewer=viewer, tab=tab, q=q.strip(), source=source, project_id=project_id, + state=state, date_from=date_from, date_to=date_to, sort=sort, cursor=cursor, limit=limit, + ) + + +def _public_asset(asset: Dict[str, Any]) -> Dict[str, Any]: + """An asset as the browser sees it: provenance yes, server paths no.""" + return {k: asset.get(k) for k in ( + "id", "kind", "role", "original_name", "relative_path", "sha256", "mime", "size_bytes", + "media_duration_ms", "source_id", "origin_type", "created_at", "metadata", + )} | {"source_available": bool(asset.get("source_available"))} + + +@router.get("/v1/meetingsense/library/item/{library_id}") +async def library_item( + library_id: str, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + """Everything the Meeting Detail page shows, for any kind of library item.""" + _require_enabled() + viewer = _viewer(authorization, homepilot_session) + item = await asyncio.to_thread(projection.find_item, library_id, viewer) + if item is None: + raise HTTPException(status_code=404, detail="not found") + if item["origin_type"] == "teams_room": + from ...teams import rooms as rooms_mod # noqa: PLC0415 + + room = rooms_mod.get_room(item["origin_id"]) or {} + msgs = room.get("messages") or [] + return {"item": item, "teams_room": { + "id": room.get("id"), "name": room.get("name"), "topic": room.get("topic"), + "participants": room.get("participant_ids") or [], "message_count": len(msgs), + "messages": [{"speaker": m.get("sender_name"), "text": m.get("content"), "t": m.get("timestamp")} + for m in msgs[:200]], + }} + meeting_id = item["meeting_id"] + from .. import minutes as minutes_mod # noqa: PLC0415 + + entry = db.get_entry(meeting_id) or {} + return { + "item": item, + "meeting": store.get_meeting(meeting_id), + "entry": {k: entry.get(k) for k in ("origin_type", "category", "description", "scheduled_for", + "recording_plan", "outputs", "raw_metadata", "warnings")}, + "segments": store.get_segments(meeting_id), + "keyframes": store.get_keyframes(meeting_id), + "notes": store.get_notes(meeting_id), + "summaries": minutes_mod.summaries(meeting_id), + "assets": [_public_asset(a) for a in db.assets_for(meeting_id)], + "jobs": db.list_jobs(meeting_id=meeting_id, limit=20), + "versions": db.list_versions(meeting_id), + } + + +_EDITABLE = ("title", "category", "description", "scheduled_for") + + +@router.patch("/v1/meetingsense/library/item/{meeting_id}") +async def library_update( + meeting_id: str, body: Dict[str, Any], + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + _require_enabled() + found = _owned_entry(meeting_id, _viewer(authorization, homepilot_session)) + body = body or {} + if "title" in body: + title = str(body.get("title") or "").strip() + if not title: + raise _error(400, "empty_title", "A meeting needs a title.") + store.update_meeting(meeting_id, {"title": title[:200]}) + entry_fields = {} + for key in ("category", "description"): + if key in body: + entry_fields[key] = (str(body.get(key) or "").strip() or None) + if "scheduled_for" in body and body["scheduled_for"] is not None: + entry_fields["scheduled_for"] = float(body["scheduled_for"]) + if entry_fields: + if not found["entry"]: + db.ensure_entry(meeting_id) + db.update_entry(meeting_id, **entry_fields) + return {"ok": True, "item": projection.find_item(meeting_id, _viewer(authorization, homepilot_session))} + + +@router.post("/v1/meetingsense/library/meetings") +async def library_create_meeting( + body: Dict[str, Any], + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """A meeting that exists before its recording: "Schedule for later" or "External recorder". + + Its recording arrives later — by upload or from a synced folder, matched by time and + confirmed when the match is not certain. + """ + _require_enabled() + viewer = _viewer(authorization, homepilot_session) + body = body or {} + title = str(body.get("title") or "").strip() + if not title: + raise _error(400, "empty_title", "A meeting needs a title.") + plan = str(body.get("recording_plan") or "external") + if plan not in ("live", "external", "hybrid", "later"): + raise _error(400, "bad_plan", "recording_plan must be live, external, hybrid or later.") + try: + when = float(body.get("scheduled_for")) if body.get("scheduled_for") is not None else None + except (TypeError, ValueError): + raise _error(400, "bad_time", "scheduled_for must be a Unix time in seconds.") from None + outputs = body.get("outputs") if isinstance(body.get("outputs"), dict) else None + mid = db.create_meeting_entry( + title=title[:200], origin_type="scheduled", owner_user_id=viewer, started_at=when, status="scheduled", + source="scheduled", project_id=str(body.get("project_id") or "").strip() or None, + category=str(body.get("category") or "").strip() or None, + description=str(body.get("description") or "").strip() or None, scheduled_for=when, + recording_plan=plan, recording_state="awaiting", transcript_state="none", outputs=outputs, + ) + return JSONResponse(status_code=201, content={"ok": True, "meeting_id": mid, + "item": projection.find_item(mid, viewer)}) + + +@router.delete("/v1/meetingsense/library/item/{meeting_id}") +async def library_remove( + meeting_id: str, mode: str = Query("remove"), + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + """``remove`` hides the meeting from the library (reversible); ``delete`` deletes its data. + + A full delete removes the meeting's rows, its transcript versions and the copies + HomePilot made. The originals in a synced folder are never touched. + """ + _require_enabled() + _owned_entry(meeting_id, _viewer(authorization, homepilot_session)) + if mode == "remove": + db.ensure_entry(meeting_id) + import time as _t # noqa: PLC0415 + + db.update_entry(meeting_id, hidden_at=_t.time()) + return {"ok": True, "mode": "remove"} + if mode != "delete": + raise _error(400, "bad_mode", "mode must be remove or delete.") + from .. import retention as retention_mod # noqa: PLC0415 + from .. import session as session_mod # noqa: PLC0415 + + session = session_mod.get(meeting_id) + if session is not None: + try: + await session.stop() + except Exception: # noqa: BLE001 + pass + session_mod.unregister(meeting_id) + for job in db.list_jobs(meeting_id=meeting_id, states=("queued", "running")): + db.update_job(job["id"], state="cancelled", finished=True) + library_rows = db.delete_library_rows(meeting_id) + result = retention_mod.delete_meeting(meeting_id) or {} + folder = media_root() / meeting_id + removed_files = 0 + if folder.is_dir() and folder.resolve().parent == media_root().resolve(): + removed_files = sum(1 for p in folder.rglob("*") if p.is_file()) + shutil.rmtree(folder, ignore_errors=True) + return {"ok": True, "mode": "delete", "removed": {**result, **library_rows, "files": removed_files}} + + +@router.put("/v1/meetingsense/library/item/{meeting_id}/projects") +async def library_project_links( + meeting_id: str, body: Dict[str, Any], + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + """Link a meeting to projects — the same meeting in each, never a copy.""" + _require_enabled() + _owned_entry(meeting_id, _viewer(authorization, homepilot_session)) + ids = body.get("project_ids") if isinstance(body, dict) else None + if not isinstance(ids, list): + raise _error(400, "bad_projects", "project_ids must be a list.") + return {"ok": True, "project_ids": db.set_project_links(meeting_id, [str(i) for i in ids])} + + +# ── media ─────────────────────────────────────────────────────────────────── + + +def _asset_path(meeting_id: str, asset_id: str, viewer: str) -> tuple: + _owned_entry(meeting_id, viewer) + asset = db.get_asset(asset_id) + if not asset or asset.get("meeting_id") != meeting_id or not asset.get("storage_ref"): + raise HTTPException(status_code=404, detail="not found") + root = media_root().resolve() + path = (root / asset["storage_ref"]).resolve() + if root not in path.parents or not path.is_file(): + raise HTTPException(status_code=404, detail="not found") + return asset, path + + +_RANGE = re.compile(r"bytes=(\d*)-(\d*)$") + + +@router.get("/v1/meetingsense/library/item/{meeting_id}/media/{asset_id}") +async def library_media( + meeting_id: str, asset_id: str, request: Request, download: int = Query(0), + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), + token: str = Query(""), +) -> Response: + """Stream a meeting's file, with byte ranges so the player can seek.""" + _require_enabled() + viewer = _viewer(authorization or (f"Bearer {token}" if token else None), homepilot_session) + asset, path = _asset_path(meeting_id, asset_id, viewer) + size = path.stat().st_size + mime = asset.get("mime") or "application/octet-stream" + headers = {"Accept-Ranges": "bytes", "Cache-Control": "private, max-age=3600"} + if download: + safe = asset["original_name"].encode("ascii", "replace").decode().replace('"', "") + headers["Content-Disposition"] = f'attachment; filename="{safe}"' + rng = _RANGE.match(request.headers.get("range", "").strip()) + start, end = 0, size - 1 + status = 200 + if rng and size: + a, b = rng.groups() + if a == "" and b: + start = max(0, size - int(b)) + else: + start = int(a or 0) + end = min(size - 1, int(b)) if b else size - 1 + if start > end or start >= size: + return Response(status_code=416, headers={"Content-Range": f"bytes */{size}"}) + status = 206 + headers["Content-Range"] = f"bytes {start}-{end}/{size}" + length = end - start + 1 if size else 0 + headers["Content-Length"] = str(length) + + def body(): + with open(path, "rb") as fh: + fh.seek(start) + remaining = length + while remaining > 0: + chunk = fh.read(min(256 * 1024, remaining)) + if not chunk: + break + remaining -= len(chunk) + yield chunk + + return StreamingResponse(body(), status_code=status, media_type=mime, headers=headers) + + +@router.get("/v1/meetingsense/library/item/{meeting_id}/media/{asset_id}/peaks") +async def library_peaks( + meeting_id: str, asset_id: str, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + _require_enabled() + asset, path = _asset_path(meeting_id, asset_id, _viewer(authorization, homepilot_session)) + meta = asset.get("metadata") or {} + if isinstance(meta.get("peaks"), list): + return {"peaks": meta["peaks"]} + values = await asyncio.to_thread(media.peaks, path) + db.update_asset(asset_id, metadata={**meta, "peaks": values}) + return {"peaks": values} + + +# ── imports ───────────────────────────────────────────────────────────────── + + +def _safe_name(name: str) -> str: + name = os.path.basename((name or "").replace("\\", "/")).strip() or "upload" + return re.sub(r"[\x00-\x1f]", "", name)[:200] + + +@router.post("/v1/meetingsense/imports/preview") +async def import_preview( + files: List[UploadFile] = File(...), project_id: str = Form(""), + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + """Look at uploaded files and say what importing them would do. Changes nothing.""" + _require_enabled() + viewer = _viewer(authorization, homepilot_session) + cfg = load_library_config() + max_bytes = cfg.max_upload_mb * 1024 * 1024 + import_id = uuid.uuid4().hex + staging = importer.staging_dir(import_id) + raw_dir, tree = staging / "raw", staging / "tree" + raw_dir.mkdir(parents=True, exist_ok=True) + tree.mkdir(parents=True, exist_ok=True) + try: + for upload in files: + name = _safe_name(upload.filename or "") + target = raw_dir / name + written = 0 + with open(target, "wb") as out: + while True: + chunk = await upload.read(1024 * 1024) + if not chunk: + break + written += len(chunk) + if written > max_bytes: + raise _error(413, "too_large", f"{name} is larger than {cfg.max_upload_mb} MB.") + out.write(chunk) + await upload.close() + with open(target, "rb") as fh: + head = fh.read(8) + if head.startswith(b"PK\x03\x04") or name.lower().endswith(".zip"): + from .adapters import UnsafeArchive, extract_zip # noqa: PLC0415 + + try: + await asyncio.to_thread(extract_zip, target, tree / Path(name).stem, max_total_bytes=max_bytes * 2) + except UnsafeArchive as exc: + raise _error(422, "unsafe_archive", str(exc)) from None + else: + shutil.copy2(target, tree / name) + manifest = await asyncio.to_thread(importer.preview_tree, tree) + except HTTPException: + shutil.rmtree(staging, ignore_errors=True) + raise + except Exception as exc: # noqa: BLE001 + shutil.rmtree(staging, ignore_errors=True) + raise _error(422, "unreadable", f"The files could not be read: {exc}") from None + manifest["project_id"] = project_id.strip() or None + if not manifest["items"]: + shutil.rmtree(staging, ignore_errors=True) + raise _error(422, "nothing_found", "No recordings or transcripts were found in those files.", + "Supported: MP3, M4A, WAV, OGG, FLAC, WebM; TXT, MD, SRT, VTT, JSON; ZIP archives; Insta360 AIRecord folders.") + db.create_import(owner_user_id=viewer, source_id="upload", manifest=manifest, staging_dir=str(staging), + import_id=import_id) + return {"import_id": import_id, **manifest} + + +@router.post("/v1/meetingsense/imports") +async def import_commit( + body: Dict[str, Any], + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Do what the preview showed, as confirmed. Idempotent per ``idempotency_key``.""" + _require_enabled() + viewer = _viewer(authorization, homepilot_session) + import_id = str((body or {}).get("import_id") or "") + key = str((body or {}).get("idempotency_key") or "").strip() or None + if key: + done = db.import_by_key(viewer, key) + if done and done["status"] == "committed": + return JSONResponse(status_code=200, content={"import_id": done["id"], "status": "committed", + **(done["manifest"].get("result") or {})}) + row = db.get_import(import_id) + if row is None or (row.get("owner_user_id") or "") != (viewer or ""): + raise HTTPException(status_code=404, detail="not found") + if row["status"] == "committed": + return JSONResponse(status_code=200, content={"import_id": import_id, "status": "committed", + **(row["manifest"].get("result") or {})}) + if row["status"] == "committing": + raise _error(409, "in_progress", "This import is already being committed.") + db.update_import(import_id, status="committing", idempotency_key=key) + try: + result = await asyncio.to_thread( + importer.commit_import, row, (body or {}).get("choices") or [], owner_user_id=viewer, + project_id=str((body or {}).get("project_id") or row["manifest"].get("project_id") or "").strip() or None, + ) + except Exception as exc: # noqa: BLE001 + db.update_import(import_id, status="failed", error=str(exc)) + raise _error(500, "import_failed", f"The import failed: {exc}") from None + manifest = {**row["manifest"], "result": result} + db.update_import(import_id, status="committed", manifest=manifest, committed=True) + importer.cleanup_staging(import_id) + jobs.wake() + return JSONResponse(status_code=202, content={"import_id": import_id, "status": "committed", **result}) + + +@router.get("/v1/meetingsense/imports/{import_id}") +async def import_get( + import_id: str, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + _require_enabled() + row = db.get_import(import_id) + if row is None or (row.get("owner_user_id") or "") != (_viewer(authorization, homepilot_session) or ""): + raise HTTPException(status_code=404, detail="not found") + return {"import_id": row["id"], "status": row["status"], "error": row.get("error"), **row["manifest"]} + + +# ── folder sync ───────────────────────────────────────────────────────────── + + +def _refused(exc: "sync.SyncRefused") -> HTTPException: + status = {"sync_not_enabled": 403, "not_allowed": 403, "not_found": 422, "not_a_folder": 422, + "missing_path": 400}.get(exc.code, 400) + return _error(status, exc.code, str(exc), exc.hint) + + +def _visible(owner: str, viewer: str) -> bool: + return not viewer or not owner or owner == viewer + + +def _owned_source(source_id: str, viewer: str) -> Dict[str, Any]: + source = db.get_source(source_id) + if source is None or not _visible(source.get("owner_user_id") or "", viewer): + raise HTTPException(status_code=404, detail="not found") + return source + + +@router.get("/v1/meetingsense/sync-sources") +async def sync_list( + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + _require_enabled() + cfg = load_library_config() + viewer = _viewer(authorization, homepilot_session) + sources = [s for s in db.list_sources() if _visible(s.get("owner_user_id") or "", viewer)] + for s_ in sources: + s_.pop("resolved_path", None) + return {"sources": sources, "enabled": bool(cfg.sync_roots), "allowed_roots": list(cfg.sync_roots), + "suggestion": cfg.as_dict()["sync_suggestion"], "interval_s": cfg.sync_interval_s} + + +@router.post("/v1/meetingsense/sync-sources") +async def sync_add( + body: Dict[str, Any], + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + _require_enabled() + try: + source = sync.add_source(str((body or {}).get("path") or ""), + display_name=str((body or {}).get("display_name") or ""), + owner_user_id=_viewer(authorization, homepilot_session), + project_id=str((body or {}).get("project_id") or "").strip() or None) + except sync.SyncRefused as exc: + raise _refused(exc) from None + source = dict(source) + source.pop("resolved_path", None) + return JSONResponse(status_code=201, content={"source": source}) + + +@router.post("/v1/meetingsense/sync-sources/{source_id}/scan") +async def sync_scan( + source_id: str, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + _require_enabled() + _owned_source(source_id, _viewer(authorization, homepilot_session)) + try: + counts = await sync.scan_async(source_id) + except sync.SyncRefused as exc: + raise _refused(exc) from None + source = db.get_source(source_id) or {} + source.pop("resolved_path", None) + return JSONResponse(status_code=202, content={"counts": counts, "source": source}) + + +@router.patch("/v1/meetingsense/sync-sources/{source_id}") +async def sync_update( + source_id: str, body: Dict[str, Any], + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + _require_enabled() + _owned_source(source_id, _viewer(authorization, homepilot_session)) + fields = {} + if "enabled" in (body or {}): + fields["enabled"] = bool(body["enabled"]) + if "display_name" in (body or {}): + fields["display_name"] = str(body["display_name"] or "").strip() or "Folder" + if "project_id" in (body or {}): + fields["project_id"] = str(body["project_id"] or "").strip() or None + db.update_source(source_id, **fields) + source = db.get_source(source_id) or {} + source.pop("resolved_path", None) + return {"source": source} + + +@router.delete("/v1/meetingsense/sync-sources/{source_id}") +async def sync_delete( + source_id: str, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + """Disconnect the folder. Meetings already imported from it stay in the library.""" + _require_enabled() + _owned_source(source_id, _viewer(authorization, homepilot_session)) + db.delete_source(source_id) + return {"ok": True} + + +# ── jobs and transcripts ──────────────────────────────────────────────────── + + +@router.get("/v1/meetingsense/jobs") +async def jobs_list( + meeting_id: str = Query(""), active: bool = Query(False), limit: int = Query(50), + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + _require_enabled() + viewer = _viewer(authorization, homepilot_session) + states = ("queued", "running", "needs_setup", "failed") if active else None + rows = db.list_jobs(meeting_id=meeting_id or None, states=states, limit=max(1, min(limit, 200))) + owners = {mid: (e.get("owner_user_id") or "") for mid, e in db.entries_by_id({j["meeting_id"] for j in rows}).items()} + rows = [j for j in rows if _visible(owners.get(j["meeting_id"], ""), viewer)] + titles: Dict[str, str] = {} + for job in rows: + if job["meeting_id"] not in titles: + m = store.get_meeting(job["meeting_id"]) + titles[job["meeting_id"]] = (m or {}).get("title") or "Meeting" + job["meeting_title"] = titles[job["meeting_id"]] + return {"jobs": rows, "stt": jobs.stt_setup_hint()} + + +@router.post("/v1/meetingsense/jobs/{job_id}/cancel") +async def jobs_cancel( + job_id: str, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + _require_enabled() + job = db.get_job(job_id) + if job is None: + raise HTTPException(status_code=404, detail="not found") + _owned_entry(job["meeting_id"], _viewer(authorization, homepilot_session)) + if job["state"] in ("queued", "running"): + db.update_job(job_id, state="cancelled", stage="cancelled", finished=job["state"] == "queued") + if job["type"] == "transcribe" and job["state"] == "queued": + db.update_entry(job["meeting_id"], transcript_state="none") + return {"job": db.get_job(job_id)} + + +@router.post("/v1/meetingsense/jobs/{job_id}/retry") +async def jobs_retry( + job_id: str, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + _require_enabled() + job = db.get_job(job_id) + if job is None: + raise HTTPException(status_code=404, detail="not found") + _owned_entry(job["meeting_id"], _viewer(authorization, homepilot_session)) + if job["state"] in ("queued", "running"): + raise _error(409, "in_progress", "That job is still running.") + new_id = db.create_job(job["meeting_id"], job["type"], options=job.get("options")) + if job["type"] == "transcribe": + db.update_entry(job["meeting_id"], transcript_state="queued") + jobs.wake() + return {"job": db.get_job(new_id)} + + +@router.post("/v1/meetingsense/library/item/{meeting_id}/transcriptions") +async def transcribe_again( + meeting_id: str, body: Optional[Dict[str, Any]] = None, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Transcribe (again) on this computer. The current transcript stays until the new one + is ready, and stays as a version afterwards.""" + _require_enabled() + _owned_entry(meeting_id, _viewer(authorization, homepilot_session)) + if jobs._primary_audio(meeting_id) is None: + raise _error(409, "no_audio", "This meeting has no recording to transcribe.") + for job in db.list_jobs(meeting_id=meeting_id, states=("queued", "running")): + if job["type"] == "transcribe": + raise _error(409, "in_progress", "A transcription is already running for this meeting.") + jid = db.create_job(meeting_id, "transcribe", options=body or {}) + db.ensure_entry(meeting_id) + db.update_entry(meeting_id, transcript_state="queued") + jobs.wake() + return JSONResponse(status_code=202, content={"job": db.get_job(jid)}) + + +@router.post("/v1/meetingsense/library/item/{meeting_id}/ask") +async def ask_library_meeting( + meeting_id: str, body: Optional[Dict[str, Any]] = None, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + """The meeting's own Ask, for a finished library meeting. + + The answer comes from the existing MeetingSense route, unchanged. Only when no model + answered (``degraded == "extractive"``) and the meeting is not live are the quoted lines + chosen again: the core fallback quotes the newest lines, which suits a live meeting, but for + a finished one the lines that match the question are the useful evidence. + """ + from .. import ask as ask_mod # noqa: PLC0415 + from .. import export as export_mod # noqa: PLC0415 + from .. import session as session_mod # noqa: PLC0415 + from ..routes import ask_meeting # noqa: PLC0415 + + _require_enabled() + _owned_entry(meeting_id, _viewer(authorization, homepilot_session)) + result = await ask_meeting(meeting_id, body or {}) + if result.get("degraded") != "extractive": + return result + live = session_mod.get(meeting_id) + if live is not None and live.state == session_mod.MeetingState.LIVE: + return result + question = str((body or {}).get("text") or "") + rows = ask_mod.retrieve(store.get_segments(meeting_id), [], question, limit=3) + rendered = "\n".join( + f"[{export_mod.clock(r.get('t0_ms'))}] " + (f"{r['speaker']}: " if r.get("speaker") else "") + r["text"].strip() + for r in rows if (r.get("text") or "").strip() + ) + if not rendered: + return result + text = f"From the meeting transcript:\n{rendered}" + cited = sorted({export_mod.clock(r.get("t0_ms")) for r in rows}) + return {**result, "text": text, "cited": cited, "sources": len(rows)} + + +@router.post("/v1/meetingsense/library/item/{meeting_id}/summarize") +async def summarize_again( + meeting_id: str, body: Optional[Dict[str, Any]] = None, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + _require_enabled() + _owned_entry(meeting_id, _viewer(authorization, homepilot_session)) + if not store.get_segments(meeting_id): + raise _error(409, "no_transcript", "There is no transcript to summarise yet.") + db.ensure_entry(meeting_id) + jid = db.create_job(meeting_id, "summarize", max_attempts=1, options=body or {}) + db.update_entry(meeting_id, summary_state="pending") + jobs.wake() + return JSONResponse(status_code=202, content={"job": db.get_job(jid)}) + + +# ── meeting intelligence ──────────────────────────────────────────────────── + + +def _analysis_jobs(meeting_id: str) -> List[Dict[str, Any]]: + return [j for j in db.list_jobs(meeting_id=meeting_id, limit=20) if j["type"] in ("analyze", "layout")] + + +def _analysis_payload(meeting_id: str) -> Dict[str, Any]: + from ..intelligence import service as intelligence # noqa: PLC0415 + + current = intelligence.current(meeting_id) + pending = [j for j in _analysis_jobs(meeting_id) if j["state"] in ("queued", "running")] + failed = [j for j in _analysis_jobs(meeting_id) if j["state"] == "failed"] + if pending: + state = "pending" + elif current: + state = "ready" + elif failed: + state = "failed" + elif store.get_segments(meeting_id): + state = "none" + else: + state = "no_transcript" + return { + "state": state, + "job": (pending or failed or [None])[0], + **({k: current.get(k) for k in ("id", "created_at", "model", "degraded", "schema_version", "versions", + "analysis", "visual", "questions", "report")} if current else {}), + } + + +@router.get("/v1/meetingsense/library/item/{meeting_id}/analysis") +async def analysis_read( + meeting_id: str, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + """The newest analysis with its visual page, questions and validator report, plus the + state the page should show (``none`` / ``pending`` / ``ready`` / ``failed`` / ``no_transcript``).""" + _require_enabled() + _require_intelligence() + _owned_entry(meeting_id, _viewer(authorization, homepilot_session)) + return await asyncio.to_thread(_analysis_payload, meeting_id) + + +@router.post("/v1/meetingsense/library/item/{meeting_id}/analysis") +async def analysis_run( + meeting_id: str, body: Optional[Dict[str, Any]] = None, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Queue the analysis (again). The current page stays until the new one is ready.""" + _require_enabled() + _require_intelligence() + _owned_entry(meeting_id, _viewer(authorization, homepilot_session)) + if not store.get_segments(meeting_id): + raise _error(409, "no_transcript", "There is no transcript to analyse yet.", + "Transcribe the recording first; the analysis is written from the transcript.") + for job in _analysis_jobs(meeting_id): + if job["state"] in ("queued", "running"): + return JSONResponse(status_code=202, content={"job": job, "state": "pending"}) + db.ensure_entry(meeting_id) + jid = db.create_job(meeting_id, "analyze", max_attempts=1, options={k: v for k, v in (body or {}).items() if k == "model_target"}) + jobs.wake() + return JSONResponse(status_code=202, content={"job": db.get_job(jid), "state": "pending"}) + + +@router.get("/v1/meetingsense/library/item/{meeting_id}/visual") +async def visual_read( + meeting_id: str, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + from ..intelligence import service as intelligence # noqa: PLC0415 + + _require_enabled() + _require_intelligence() + _owned_entry(meeting_id, _viewer(authorization, homepilot_session)) + current = await asyncio.to_thread(intelligence.current, meeting_id) + if not current: + raise _error(404, "no_analysis", "This meeting has not been analysed yet.") + return {"visual": current.get("visual"), "analysis": current.get("analysis"), "id": current.get("id")} + + +@router.post("/v1/meetingsense/library/item/{meeting_id}/visual") +async def visual_relayout( + meeting_id: str, body: Optional[Dict[str, Any]] = None, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Write the Executive Brief again from the latest analysis (no re-extraction). 409 + ``no_analysis`` until an analysis exists; the current page stays until the new one lands.""" + from ..intelligence import service as intelligence # noqa: PLC0415 + + _require_enabled() + _require_intelligence() + _owned_entry(meeting_id, _viewer(authorization, homepilot_session)) + if not await asyncio.to_thread(intelligence.current, meeting_id): + raise _error(409, "no_analysis", "This meeting has not been analysed yet.", "Generate the insights first.") + for job in _analysis_jobs(meeting_id): + if job["state"] in ("queued", "running"): + return JSONResponse(status_code=202, content={"job": job, "state": "pending"}) + jid = db.create_job(meeting_id, "layout", max_attempts=1, + options={k: v for k, v in (body or {}).items() if k in ("model_target", "template")}) + jobs.wake() + return JSONResponse(status_code=202, content={"job": db.get_job(jid), "state": "pending"}) + + +@router.get("/v1/meetingsense/library/item/{meeting_id}/questions") +async def questions_read( + meeting_id: str, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + from ..intelligence import service as intelligence # noqa: PLC0415 + + _require_enabled() + _require_intelligence() + _owned_entry(meeting_id, _viewer(authorization, homepilot_session)) + current = await asyncio.to_thread(intelligence.current, meeting_id) + return {"questions": (current or {}).get("questions") or []} + + +@router.get("/v1/meetingsense/library/item/{meeting_id}/actions") +async def actions_read( + meeting_id: str, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + from ..intelligence import store as intelligence_store # noqa: PLC0415 + + _require_enabled() + _require_intelligence() + _owned_entry(meeting_id, _viewer(authorization, homepilot_session)) + return {"actions": await asyncio.to_thread(intelligence_store.actions, meeting_id)} + + +@router.patch("/v1/meetingsense/library/item/{meeting_id}/actions/{item_id}") +async def actions_update( + meeting_id: str, item_id: str, body: Dict[str, Any], + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + """Tick, reassign or re-date an action item. Kept on the server, so it follows the meeting + to every device and survives a regenerated analysis.""" + from ..intelligence import store as intelligence_store # noqa: PLC0415 + + _require_enabled() + _require_intelligence() + _owned_entry(meeting_id, _viewer(authorization, homepilot_session)) + row = await asyncio.to_thread( + intelligence_store.update_action, meeting_id, item_id, + **{k: body.get(k) for k in intelligence_store.UPDATABLE if k in body}, + ) + if row is None: + raise HTTPException(status_code=404, detail="not found") + return {"action": row} + + +@router.get("/v1/meetingsense/library/schema") +async def intelligence_schema() -> Dict[str, Any]: + """The published analysis contract, for other consumers (DayPilot reads this).""" + from ..intelligence import schema as schema_mod # noqa: PLC0415 + + _require_enabled() + _require_intelligence() + return schema_mod.json_schema() + + +@router.get("/v1/meetingsense/library/item/{meeting_id}/versions") +async def versions_list( + meeting_id: str, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + _require_enabled() + _owned_entry(meeting_id, _viewer(authorization, homepilot_session)) + return {"versions": db.list_versions(meeting_id)} + + +@router.post("/v1/meetingsense/library/item/{meeting_id}/versions") +async def versions_create( + meeting_id: str, body: Dict[str, Any], + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Save an edited transcript as a new version and show it. Earlier versions stay.""" + _require_enabled() + _owned_entry(meeting_id, _viewer(authorization, homepilot_session)) + segments = (body or {}).get("segments") + if not isinstance(segments, list) or not segments: + raise _error(400, "no_segments", "segments must be a non-empty list.") + clean = [] + for seg in segments: + if not isinstance(seg, dict) or not str(seg.get("text") or "").strip(): + continue + clean.append({"t0_ms": int(seg.get("t0_ms") or 0), + "t1_ms": None if seg.get("t1_ms") is None else int(seg["t1_ms"]), + "speaker": (str(seg.get("speaker")).strip() or None) if seg.get("speaker") else None, + "text": str(seg["text"]).strip()[:4000], "conf": None}) + if not clean: + raise _error(400, "no_segments", "Every segment was empty.") + active = next((v for v in db.list_versions(meeting_id) if v["active"]), None) + vid = db.create_version(meeting_id, clean, origin="edit", timing=(active or {}).get("timing") or "imported", + note=str((body or {}).get("note") or "Edited by hand")[:200]) + db.publish_version(meeting_id, vid) + return JSONResponse(status_code=201, content={"version_id": vid, "versions": db.list_versions(meeting_id)}) + + +@router.post("/v1/meetingsense/library/item/{meeting_id}/versions/{version_id}/publish") +async def versions_publish( + meeting_id: str, version_id: str, + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Dict[str, Any]: + _require_enabled() + _owned_entry(meeting_id, _viewer(authorization, homepilot_session)) + try: + count = db.publish_version(meeting_id, version_id) + except KeyError: + raise HTTPException(status_code=404, detail="not found") from None + return {"ok": True, "segments": count, "versions": db.list_versions(meeting_id)} + + +@router.get("/v1/meetingsense/library/item/{meeting_id}/transcript") +async def transcript_export( + meeting_id: str, fmt: str = Query("txt"), + authorization: Optional[str] = Header(default=None), homepilot_session: Optional[str] = Cookie(default=None), +) -> Response: + """TXT / SRT / VTT / JSON / MD of the transcript shown now (MeetingSense export covers + md, srt and json; TXT and VTT are added here for imported meetings).""" + _require_enabled() + found = _owned_entry(meeting_id, _viewer(authorization, homepilot_session)) + segments = store.get_segments(meeting_id) + title = (found["meeting"].get("title") or "meeting").strip() + fname = re.sub(r"[^\w\-. ]+", "_", title)[:80] or "meeting" + + def clock(ms: Optional[int], sep: str) -> str: + ms = int(ms or 0) + h, rem = divmod(ms, 3_600_000) + m, rem = divmod(rem, 60_000) + s, ms = divmod(rem, 1000) + return f"{h:02d}:{m:02d}:{s:02d}{sep}{ms:03d}" + + fmt = fmt.lower() + if fmt == "json": + content = json.dumps({"title": title, "segments": segments}, ensure_ascii=False, indent=2) + media_type = "application/json" + elif fmt in ("srt", "vtt"): + sep = "," if fmt == "srt" else "." + lines = ["WEBVTT", ""] if fmt == "vtt" else [] + for i, seg in enumerate(segments, 1): + end = seg["t1_ms"] if seg.get("t1_ms") is not None else int(seg["t0_ms"] or 0) + 3000 + if fmt == "srt": + lines.append(str(i)) + text = f"{seg['speaker']}: {seg['text']}" if seg.get("speaker") else seg["text"] + lines += [f"{clock(seg['t0_ms'], sep)} --> {clock(end, sep)}", text, ""] + content = "\n".join(lines) + media_type = "text/vtt" if fmt == "vtt" else "application/x-subrip" + elif fmt in ("txt", "md"): + head = [f"# {title}", ""] if fmt == "md" else [title, ""] + body_lines = [ + f"[{clock(seg['t0_ms'], '.')[:8]}] " + (f"{seg['speaker']}: " if seg.get("speaker") else "") + seg["text"] + for seg in segments + ] + content = "\n".join(head + body_lines) + "\n" + media_type = "text/markdown" if fmt == "md" else "text/plain" + else: + raise _error(400, "bad_format", "fmt must be txt, md, srt, vtt or json.") + return Response(content=content, media_type=f"{media_type}; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{fname}.{fmt}"'}) diff --git a/backend/app/meetingsense/library/sync.py b/backend/app/meetingsense/library/sync.py new file mode 100644 index 0000000000000000000000000000000000000000..eaa9914dce342169ce53ff13b22abb33d9709b4e --- /dev/null +++ b/backend/app/meetingsense/library/sync.py @@ -0,0 +1,266 @@ +"""Folder sync — import finished recordings from a folder such as ``D:\\AIRecord``. + +**Who may read the disk.** The server reads a folder only when it lies below a root the +operator listed in ``MEETINGS_SYNC_ALLOWED_ROOTS``. A path typed in the browser is a request, +not a grant; a backend on another machine cannot see this computer's ``D:`` at all, and is +told so rather than pretending. + +When HomePilot runs under WSL (the usual Windows setup), ``D:\\AIRecord`` is +``/mnt/d/AIRecord`` to the server, and the translation is done here. + +**What a scan does.** It walks the folder, groups files into meetings (the same discovery an +upload uses), and imports each meeting once its files have stopped changing — a recording +still being written is left for the next scan. Re-running a scan is always safe: an +Insta360 meeting is recognised by its ``localMeetingId`` and anything else by the SHA-256 of +its recording, so nothing is imported twice, and a renamed file is followed rather than +duplicated. Source files are only read. A file that later disappears leaves its meeting in +the library, marked "source no longer available". +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import re +import time +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from . import db +from .adapters import discover +from .config import load_library_config +from .importer import analyse, ingest + +log = logging.getLogger(__name__) + +_loop_task: Optional[asyncio.Task] = None +_scan_lock: Optional[asyncio.Lock] = None + + +class SyncRefused(Exception): + def __init__(self, message: str, *, code: str, hint: str = "") -> None: + super().__init__(message) + self.code = code + self.hint = hint + + +_WIN_DRIVE = re.compile(r"^([A-Za-z]):[\\/]*(.*)$") + + +def _is_wsl() -> bool: + try: + return "microsoft" in Path("/proc/version").read_text().lower() + except OSError: + return False + + +def server_path(user_path: str) -> str: + """Where the server finds a folder the user named. ``D:\\X`` → ``/mnt/d/X`` off Windows.""" + raw = (user_path or "").strip().strip('"') + m = _WIN_DRIVE.match(raw) + if m and os.name != "nt": + rest = m.group(2).replace("\\", "/").strip("/") + return f"/mnt/{m.group(1).lower()}/{rest}" if rest else f"/mnt/{m.group(1).lower()}" + return raw + + +def _within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + +def authorize(user_path: str) -> Tuple[str, Path]: + """Validate a requested folder; return (as entered, resolved server path).""" + cfg = load_library_config() + if not user_path or not user_path.strip(): + raise SyncRefused("Choose a folder.", code="missing_path") + if not cfg.sync_roots: + raise SyncRefused( + "Folder sync is turned off on this server.", + code="sync_not_enabled", + hint=("Ask the person who runs HomePilot to allow the folder: set " + "MEETINGS_SYNC_ALLOWED_ROOTS=" + (server_path("D:\\AIRecord") if os.name != "nt" else "D:\\AIRecord") + + " and restart."), + ) + resolved = Path(server_path(user_path)).expanduser() + try: + resolved = resolved.resolve(strict=True) + except (OSError, RuntimeError): + hint = "" + if _WIN_DRIVE.match(user_path.strip()) and os.name != "nt" and not _is_wsl(): + hint = ("This HomePilot server is not running on that Windows computer, so it cannot see its " + "drives. Upload the recordings instead, or run HomePilot on that computer.") + raise SyncRefused(f"The folder “{user_path}” was not found on the HomePilot server.", + code="not_found", hint=hint) + if not resolved.is_dir(): + raise SyncRefused(f"“{user_path}” is not a folder.", code="not_a_folder") + allowed = [] + for root in cfg.sync_roots: + try: + allowed.append(Path(server_path(root)).expanduser().resolve()) + except (OSError, RuntimeError): + continue + if not any(_within(resolved, root) for root in allowed): + raise SyncRefused( + "That folder is outside the folders this server allows.", + code="not_allowed", + hint="Allowed: " + "; ".join(cfg.sync_roots), + ) + return user_path.strip(), resolved + + +def add_source(user_path: str, *, display_name: str = "", owner_user_id: str = "", + project_id: Optional[str] = None) -> Dict[str, Any]: + entered, resolved = authorize(user_path) + for existing in db.list_sources(): + if Path(existing["resolved_path"]) == resolved: + return existing + sid = db.create_source(kind="folder", display_name=display_name.strip() or Path(entered).name or entered, + path=entered, resolved_path=str(resolved), owner_user_id=owner_user_id, + project_id=project_id or None) + return db.get_source(sid) # type: ignore[return-value] + + +def _stable(source_id: str, files, *, now: float, stability_s: int) -> bool: + """Every file of a meeting has stopped changing: not modified for ``stability_s`` seconds, + and the same size and mtime as at the last scan (a file that changed since is still being + written, whatever its mtime says).""" + ok = True + for f in files: + seen = db.observe(source_id, f.rel, f.size, f.mtime) + if seen.get("changed"): + ok = False + continue + settled_since = min(float(seen["first_seen_at"]), float(f.mtime)) + if now - settled_since < stability_s: + ok = False + return ok + + +def scan_source(source_id: str) -> Dict[str, Any]: + """One reconciliation pass over a folder. Idempotent; safe after a crash.""" + source = db.get_source(source_id) + if source is None: + raise SyncRefused("No such folder source.", code="not_found") + cfg = load_library_config() + now = time.time() + counts = {"found": 0, "imported": 0, "already": 0, "waiting": 0, "skipped": 0, "failed": 0} + errors: List[str] = [] + db.update_source(source_id, last_scan_at=now) + try: + _, root = authorize(source["path"]) # re-checked every scan: the operator may revoke + except SyncRefused as exc: + db.update_source(source_id, last_error=f"{exc} {exc.hint}".strip(), counts=counts) + raise + known = {a["relative_path"]: a for a in db.assets_for_source(source_id)} + present = set() + for cand in discover(root): + counts["found"] += 1 + for f in cand.files: + present.add(f.rel) + if not cand.files: + counts["skipped"] += 1 + continue + # Already imported from this folder at this path (fast path, no hashing). + prior = known.get(cand.audio.rel if cand.audio else cand.files[0].rel) + if prior and prior.get("meeting_id"): + counts["already"] += 1 + continue + if not _stable(source_id, cand.files, now=now, stability_s=cfg.stability_s): + counts["waiting"] += 1 + continue + try: + item = analyse(cand) + if item.get("duplicate_of"): + _follow_rename(item, cand, source_id) + counts["already"] += 1 + continue + if item.get("outcome") == "skip": + counts["skipped"] += 1 + continue + # Automatic attach only when exactly one scheduled meeting fits; otherwise the + # recording becomes its own meeting and the match is offered in the library. + attach = item.get("attach_to") if len(item.get("scheduled_matches") or []) == 1 else None + ingest(cand, source_id=source_id, origin_type="sync", owner_user_id=source.get("owner_user_id") or "", + project_id=source.get("project_id"), attach_to=attach, analysed=item) + counts["imported"] += 1 + except Exception as exc: # noqa: BLE001 — one bad file must not stop the folder + counts["failed"] += 1 + errors.append(f"{cand.key}: {exc}") + log.warning("meetings sync: %s failed: %s", cand.key, exc) + # Files that vanished: keep the meeting, say the original is gone. + for rel, asset in known.items(): + available = 1 if rel in present else 0 + if int(asset.get("source_available") or 0) != available: + db.update_asset(asset["id"], source_available=available) + db.update_source(source_id, last_success_at=time.time(), + last_error="; ".join(errors)[:1000] if errors else "", counts=counts) + if counts["imported"]: + from .jobs import wake # noqa: PLC0415 + + wake() + return counts + + +def _follow_rename(item: Dict[str, Any], cand, source_id: str) -> None: + """Same recording, new path: point the asset at its new name instead of re-importing.""" + if cand.audio is None: + return + sha = (item.get("audio") or {}).get("sha256") + for asset in db.assets_with_sha(sha) if sha else []: + if asset["source_id"] == source_id and asset["relative_path"] != cand.audio.rel: + db.update_asset(asset["id"], relative_path=cand.audio.rel, source_available=1) + + +async def scan_async(source_id: str) -> Dict[str, Any]: + global _scan_lock + if _scan_lock is None: + _scan_lock = asyncio.Lock() + async with _scan_lock: + return await asyncio.to_thread(scan_source, source_id) + + +def start_sync_loop() -> bool: + global _loop_task + cfg = load_library_config() + if not (cfg.enabled and cfg.sync_roots) or os.getenv("MEETINGS_LIBRARY_WORKER", "1").strip().lower() in ("0", "false", "off"): + return False + if _loop_task is not None and not _loop_task.done(): + return True + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return False + _loop_task = loop.create_task(_sync_loop()) + return True + + +async def _sync_loop() -> None: + db.ensure_migrated() + while True: + interval = load_library_config().sync_interval_s + for source in db.list_sources(): + if not source.get("enabled"): + continue + try: + await scan_async(source["id"]) + except SyncRefused: + pass # recorded on the source; shown in Folder Sync + except Exception: # noqa: BLE001 + log.exception("meetings sync: scan of %s failed", source["id"]) + await asyncio.sleep(interval) + + +async def stop_sync_loop() -> None: + global _loop_task + if _loop_task is not None: + _loop_task.cancel() + try: + await _loop_task + except (asyncio.CancelledError, Exception): # noqa: BLE001 + pass + _loop_task = None diff --git a/backend/app/meetingsense/notes_engine.py b/backend/app/meetingsense/notes_engine.py index 707882ab25e8959188fbeda8b2ee278c1e4b3e11..afd07095786a1c7f06c3441e54d168deee39cb4f 100644 --- a/backend/app/meetingsense/notes_engine.py +++ b/backend/app/meetingsense/notes_engine.py @@ -501,22 +501,43 @@ async def call_model( model: str = "", provider: str = "", base_url: str = "", + max_tokens: int = 600, + response_format: Optional[str] = None, ) -> str: """The default MeetingSense model call, routed through HomePilot's compute layer. All three of `provider`, `model` and `base_url` are optional: a meeting's setup supplies the chat target the rest of HomePilot is using, and anything it leaves out is resolved from the install's configuration by :func:`resolve_target` rather than assumed. - """ - from ..compute import route_chat + ``max_tokens`` defaults to the 600 the rolling notes were tuned for; longer callers (the + end-of-meeting document, the typed analysis) say what they need. ``response_format="json"`` + asks the provider for a JSON object where that is supported — today Ollama's ``format`` — + and is ignored elsewhere, where the caller's own parsing has to cope. + """ target = resolve_target(provider=provider, model=model, base_url=base_url) - response = await route_chat( - messages, - provider=target["provider"], - base_url=target["base_url"], - temperature=temperature, - max_tokens=600, - model=target["model"], - ) + if response_format and target["provider"] == "ollama": + # The compute router has no response_format parameter yet; the local Ollama path is the + # router's identity case, so calling it directly loses no routing and gains JSON mode. + from .. import llm + + response = await llm.chat_ollama( + messages, + temperature=temperature, + max_tokens=max_tokens, + base_url=target["base_url"], + model=target["model"], + response_format=response_format, + ) + else: + from ..compute import route_chat + + response = await route_chat( + messages, + provider=target["provider"], + base_url=target["base_url"], + temperature=temperature, + max_tokens=max_tokens, + model=target["model"], + ) return ((response.get("choices") or [{}])[0].get("message", {}) or {}).get("content", "") or "" diff --git a/backend/app/personas/avatar_crop.py b/backend/app/personas/avatar_crop.py index 0190df50e56532ebfabc0a38cafa9d6a711fac97..c1e46f0f18a50a975fea290addf0e7938cd325db 100644 --- a/backend/app/personas/avatar_crop.py +++ b/backend/app/personas/avatar_crop.py @@ -167,3 +167,67 @@ def active_crop(appearance: Dict[str, Any]) -> Optional[Dict[str, Any]]: if crop.get("output") != appearance.get("selected_thumb_filename"): return None return crop + + + +def _look(appearance: Dict[str, Any]) -> Optional[Dict[str, Any]]: + sel = appearance.get("selected") + return sel if isinstance(sel, dict) and sel else None + + +def files_rel(url: Optional[str]) -> Optional[str]: + """The upload-relative path a /files/ URL points at (absolute or not).""" + if not isinstance(url, str): + return None + if "/files/" in url: + ref = url.split("/files/", 1)[1] + elif url.startswith("files/"): + ref = url[len("files/"):] + else: + return None + from urllib.parse import unquote + + ref = unquote(ref.split("?", 1)[0].split("#", 1)[0]).lstrip("/") + return ref or None + + +def _same_picture(appearance: Dict[str, Any]) -> set: + """Every file that is the persona's current picture: the original, the + thumbnail shown, and the framing's source, output and automatic thumbnail.""" + crop = appearance.get("avatar_crop") if isinstance(appearance.get("avatar_crop"), dict) else {} + names = (appearance.get("selected_filename"), appearance.get("selected_thumb_filename"), + crop.get("source"), crop.get("output"), crop.get("auto_thumb")) + return {n for n in names if isinstance(n, str) and n} + + +def keeps_manual_picture(before: Dict[str, Any], after: Dict[str, Any], chosen_url: Optional[str] = None) -> bool: + """ + True while a picture chosen in Edit Profile Picture stays: it is in use, and + the user has not picked another look since. A look that is only another + reference to the same picture (a settings panel wrapping the current + picture, or its thumbnail, in a look of its own) is not another look. + """ + if active_crop(before) is None: + return False + if _look(after) == _look(before): + return True + rel = files_rel(chosen_url) + return rel is not None and rel in _same_picture(before) + + +def keep_manual_picture(before: Dict[str, Any], after: Dict[str, Any], chosen_url: Optional[str] = None) -> Dict[str, Any]: + """ + Saving other persona settings must not undo Edit Profile Picture. + + Settings panels send back the whole persona_appearance they loaded — maybe + from before the crop, with the old automatic thumbnail — so, while the user + has not picked another look, the picture, its thumbnail and the framing + stay as they were. Picking another look replaces them, as it always did. + """ + if not keeps_manual_picture(before, after, chosen_url): + return after + kept = dict(after) + for key in ("selected_filename", "selected_thumb_filename", "avatar_crop"): + if key in before: + kept[key] = before[key] + return kept diff --git a/frontend/.cache-bust b/frontend/.cache-bust index eee1321f51b0b76a305cb4ebea691250db66f559..0e41e6f71072ee5ba5572239f808e52a674c266b 100644 --- a/frontend/.cache-bust +++ b/frontend/.cache-bust @@ -1 +1 @@ -1791656982 +1792086980 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 979975c516fc9c1417f759635cbdf83900694d9d..70f67eb8f87c283a12e3fb2d15697c04bf944950 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "2.0.0", "dependencies": { "axios": "^1.7.2", + "html-to-image": "^1.11.13", "lucide-react": "^0.457.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -2881,6 +2882,12 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index c3bbe5e3da962ec5446d55bfa8183d13f9b823c6..7faaccff143e07e7b343a68a64660f3cb5ecef0b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,7 @@ "version": "2.0.0", "type": "module", "scripts": { + "predev": "node scripts/ensure-deps.mjs", "dev": "vite --host 0.0.0.0 --port 3000", "prebuild": "node scripts/generate-model-catalog.mjs", "build": "tsc -b && vite build", @@ -15,6 +16,7 @@ }, "dependencies": { "axios": "^1.7.2", + "html-to-image": "^1.11.13", "lucide-react": "^0.457.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/scripts/ensure-deps.mjs b/frontend/scripts/ensure-deps.mjs new file mode 100644 index 0000000000000000000000000000000000000000..359c9c9472481d69e1ffb3f8b20d532f5cbb60c4 --- /dev/null +++ b/frontend/scripts/ensure-deps.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * Runs before `npm run dev` (the "predev" script): makes sure node_modules has + * what package.json asks for. + * + * The start commands (`make start`, `make start-frontend`, `make dev`) only ran + * `npm install` when node_modules did not exist at all, so a dependency added by + * a `git pull` was never installed and Vite stopped with + * "Failed to resolve import". This compares every direct dependency with the + * version package-lock.json pins, and runs `npm install` only when one is + * missing or different — otherwise it costs a few milliseconds. + * + * Set HOMEPILOT_SKIP_DEPS_CHECK=1 to skip it. + */ +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +function readJson(file) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')) + } catch { + return null + } +} + +/** + * Direct dependencies of the package in `root` that are not installed, or not + * at the version its package-lock.json pins. [{ name, reason }] + */ +export function staleDependencies(root) { + const pkg = readJson(path.join(root, 'package.json')) + if (!pkg) return [] + const pinned = readJson(path.join(root, 'package-lock.json'))?.packages || {} + const names = Object.keys({ ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }) + const stale = [] + for (const name of names) { + const installed = readJson(path.join(root, 'node_modules', name, 'package.json')) + if (!installed) { + stale.push({ name, reason: 'not installed' }) + continue + } + // Linked (file:) packages have no pinned version; existence is enough. + const want = pinned[`node_modules/${name}`]?.version + if (want && installed.version !== want) { + stale.push({ name, reason: `${installed.version} installed, ${want} expected` }) + } + } + return stale +} + +function npmInstall(root) { + // Inside `npm run`, npm_execpath is npm itself: run it with this Node, so it + // works the same on Linux, macOS, WSL and Windows. + const npmCli = process.env.npm_execpath + const args = ['install', '--no-audit', '--no-fund'] + const res = npmCli && /npm-cli\.c?js$/.test(npmCli) + ? spawnSync(process.execPath, [npmCli, ...args], { cwd: root, stdio: 'inherit' }) + : spawnSync('npm', args, { cwd: root, stdio: 'inherit', shell: process.platform === 'win32' }) + return res.status === 0 +} + +function main() { + if (process.env.HOMEPILOT_SKIP_DEPS_CHECK === '1') return + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + const stale = staleDependencies(root) + if (!stale.length) return + const shown = stale.slice(0, 5).map((s) => `${s.name} (${s.reason})`).join(', ') + const more = stale.length > 5 ? ` and ${stale.length - 5} more` : '' + console.log(`[homepilot] Frontend dependencies changed: ${shown}${more}. Running npm install…`) + if (!npmInstall(root)) { + console.error('[homepilot] npm install failed. Run it yourself, then start again: cd frontend && npm install') + process.exit(1) + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main() diff --git a/frontend/src/test/__snapshots__/meetingsLandingGate.test.tsx.snap b/frontend/src/test/__snapshots__/meetingsLandingGate.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..59b5f75782f50caf1fc9aa27120f2dd521ae2702 --- /dev/null +++ b/frontend/src/test/__snapshots__/meetingsLandingGate.test.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`keeps the Meetings landing page exactly as it is 1`] = `"

Meetings

All meetings in one place

Project MeetingTranscribed

Architecture Review

Discussed system architecture.

02/28/2026 52 min Project: HomePilot
Client CallTranscribed

Budget review

Discussed system architecture.

02/28/2026 52 min Project: HomePilot
Project MeetingAwaiting recording

Kickoff

Discussed system architecture.

03/01/2026 52 min Project: HomePilot
"`; diff --git a/frontend/src/test/conversationHub.test.tsx b/frontend/src/test/conversationHub.test.tsx index 0ace167ef2c95923dacc322942e4c6cd4f2184fa..4885f7fd4024f866ba1db193c440a319e6b6913e 100644 --- a/frontend/src/test/conversationHub.test.tsx +++ b/frontend/src/test/conversationHub.test.tsx @@ -120,6 +120,27 @@ describe('ProjectAvatar', () => { expect(screen.getByRole('img', { name: 'Angel' }).tagName).toBe('DIV') }) + it('shows a loading shimmer until the picture has arrived, then the picture', () => { + render() + const frame = document.querySelector('.hp-project-avatar') as HTMLElement + const img = screen.getByRole('img', { name: 'Angel' }) + expect(frame).toHaveAttribute('aria-busy', 'true') + expect(frame.querySelector('.hp-project-avatar__skeleton')).not.toBeNull() + expect(img).toHaveAttribute('data-ready', 'false') + fireEvent.load(img) + expect(frame).not.toHaveAttribute('aria-busy') + expect(frame.querySelector('.hp-project-avatar__skeleton')).toBeNull() + expect(img).toHaveAttribute('data-ready', 'true') + }) + + it('a small avatar pulses until loaded', () => { + const { container } = render() + const img = container.querySelector('img') as HTMLImageElement + expect(img.className).toContain('hp-avatar-loading') + fireEvent.load(img) + expect(img.className).toBe('h-8 w-8 rounded-full') + }) + it('a small avatar shows its fallback mark when no picture loads', () => { const { container } = render(mark} />) const img = container.querySelector('img') as HTMLImageElement diff --git a/frontend/src/test/ensureDeps.test.js b/frontend/src/test/ensureDeps.test.js new file mode 100644 index 0000000000000000000000000000000000000000..a68b9053197c5096ef671a21bf55d6495c6eee46 --- /dev/null +++ b/frontend/src/test/ensureDeps.test.js @@ -0,0 +1,74 @@ +/** + * `npm run dev` first checks that node_modules has what package.json asks for + * (scripts/ensure-deps.mjs). A dependency added by a pull — react-easy-crop for + * Edit Profile Picture — used to stop Vite with "Failed to resolve import" + * because the start commands only installed when node_modules was missing. + */ +import { afterEach, describe, expect, it } from 'vitest' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { staleDependencies } from '../../scripts/ensure-deps.mjs' + +const dirs = [] +afterEach(() => { for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }) }) + +function project({ deps = {}, devDeps = {}, lock = {}, installed = {} }) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hp-deps-')) + dirs.push(root) + const write = (rel, data) => { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }) + fs.writeFileSync(path.join(root, rel), JSON.stringify(data)) + } + write('package.json', { name: 'x', dependencies: deps, devDependencies: devDeps }) + if (lock) { + const packages = { '': {} } + for (const [name, version] of Object.entries(lock)) packages[`node_modules/${name}`] = { version } + write('package-lock.json', { lockfileVersion: 3, packages }) + } + for (const [name, version] of Object.entries(installed)) write(`node_modules/${name}/package.json`, { name, version }) + return root +} + +describe('the dependency check before npm run dev', () => { + it('is quiet when everything is installed at the pinned versions', () => { + const root = project({ + deps: { react: '^18.3.1', 'react-easy-crop': '6.2.3' }, + devDeps: { vite: '^5.0.0' }, + lock: { react: '18.3.1', 'react-easy-crop': '6.2.3', vite: '5.4.0' }, + installed: { react: '18.3.1', 'react-easy-crop': '6.2.3', vite: '5.4.0' }, + }) + expect(staleDependencies(root)).toEqual([]) + }) + + it('finds a dependency a pull added but nobody installed', () => { + const root = project({ + deps: { react: '^18.3.1', 'react-easy-crop': '6.2.3' }, + lock: { react: '18.3.1', 'react-easy-crop': '6.2.3' }, + installed: { react: '18.3.1' }, + }) + expect(staleDependencies(root)).toEqual([{ name: 'react-easy-crop', reason: 'not installed' }]) + }) + + it('finds one installed at a different version from the lockfile', () => { + const root = project({ + devDeps: { vite: '^5.0.0' }, + lock: { vite: '5.4.0' }, + installed: { vite: '5.1.0' }, + }) + expect(staleDependencies(root)).toEqual([{ name: 'vite', reason: '5.1.0 installed, 5.4.0 expected' }]) + }) + + it('without a lockfile, only checks that each one is there', () => { + const root = project({ deps: { a: '^1.0.0', b: '^2.0.0' }, lock: null, installed: { a: '1.9.0' } }) + expect(staleDependencies(root).map((s) => s.name)).toEqual(['b']) + }) + + it('npm run dev runs the check first, and it reads the real frontend files', () => { + const frontend = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') + expect(Array.isArray(staleDependencies(frontend))).toBe(true) + const pkg = JSON.parse(fs.readFileSync(path.join(frontend, 'package.json'), 'utf8')) + expect(pkg.scripts.predev).toBe('node scripts/ensure-deps.mjs') + }) +}) diff --git a/frontend/src/test/meetingIntelligence.test.tsx b/frontend/src/test/meetingIntelligence.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d0441b4708a449f49df633acd173d5978daa41f1 --- /dev/null +++ b/frontend/src/test/meetingIntelligence.test.tsx @@ -0,0 +1,192 @@ +/** + * Meeting intelligence on the detail page: the visual summary renders only what the analysis + * holds and every timestamp seeks the recording; the insights panel is honest about its states + * and disappears when the server has the feature off; the assistant shows its citations. + */ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { VisualSummary } from '../ui/meetingsense/library/meeting-summary/VisualSummary' +import { InsightsPanel } from '../ui/meetingsense/library/InsightsPanel' +import { AssistantDrawer } from '../ui/meetingsense/library/AssistantDrawer' +import { analysisToMarkdown, stampToMs, type MeetingAnalysis, type VisualSpec } from '../ui/meetingsense/library/intelligenceApi' + +const analysis: MeetingAnalysis = { + schema_version: 1, meeting_id: 'm1', language: 'en', created_at: 1_772_272_800, + title: 'Fixing domain status detection', one_line: 'The team confirmed the Langfuse move.', + topics: [{ id: 't1', label: 'Domain status', t0_ms: 0, t1_ms: 30_000, summary: 'Unknown status is misread.' }], + decisions: [ + { 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 }, + { id: 'd2', statement: 'Add an MX lookup', status: 'proposed', evidence: [{ segment_id: 's3', t0_ms: 15_000, quote: 'second lookup' }], confidence: 0.6 }, + ], + 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' }] }], + 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 }], + open_questions: [{ id: 'q1', text: 'Who owns the definition of unknown status?', evidence: [{ segment_id: 's6', t0_ms: 38_000, quote: 'Who owns' }] }], + 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' }] }], + speakers: [{ label: 'Ebony', talk_ms: 20_000, share: 0.5, lines: 2 }], + degraded: null, model: 'qwen', validator: { kept: 5, dropped: 1, repaired: 0, notes: [] }, +} + +const spec: VisualSpec = { + schema_version: 1, analysis_id: 'an1', template: 'autopilot', blocks: [ + { type: 'header', title: analysis.title, text: analysis.one_line, items: [], columns: [], rows: [], meta: { degraded: null } }, + { type: 'kpi_row', items: [], columns: [], rows: [], meta: { kpis: [{ label: 'Decisions', value: 2 }, { label: 'Risks', value: 1 }] } }, + { type: 'risk_cards', title: 'Risks and problems', items: ['r1'], columns: [], rows: [], meta: {} }, + { type: 'decision_list', title: 'Decisions', items: ['d1', 'd2'], columns: [], rows: [], meta: {} }, + { 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 } }, + { type: 'timeline', title: 'Action items', items: ['a1'], columns: [], rows: [], meta: {} }, + { type: 'section', title: 'Still open', items: ['q1'], columns: [], rows: [], tone: 'amber', meta: {} }, + { type: 'callout', title: 'Key conclusion', text: analysis.conclusions[0].text, items: ['c1'], columns: [], rows: [], meta: {} }, + ], +} + +type Route = (url: URL, init?: RequestInit) => { status?: number; body: unknown } +let routes: Array<[RegExp, Route]> = [] +const calls: Array<{ url: string; method: string; body?: any }> = [] +function respond(pattern: RegExp, fn: Route) { routes.push([pattern, fn]) } + +beforeEach(() => { + routes = [] + calls.length = 0 + sessionStorage.clear() + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), 'http://hp.test') + const method = (init?.method || 'GET').toUpperCase() + let body: any + try { body = init?.body && typeof init.body === 'string' ? JSON.parse(init.body) : undefined } catch { body = undefined } + calls.push({ url: url.pathname, method, body }) + for (const [pattern, fn] of routes) { + if (pattern.test(`${method} ${url.pathname}`)) { + const out = fn(url, init) + return new Response(JSON.stringify(out.body), { status: out.status || 200, headers: { 'content-type': 'application/json' } }) + } + } + return new Response(JSON.stringify({ detail: 'not found' }), { status: 404, headers: { 'content-type': 'application/json' } }) + })) +}) +afterEach(() => { vi.unstubAllGlobals() }) + +describe('VisualSummary', () => { + it('renders every block from the analysis and seeks from any timestamp', () => { + const onSeek = vi.fn() + render() + expect(screen.getByRole('heading', { name: 'Fixing domain status detection' })).toBeInTheDocument() + expect(screen.getByText('Unknown status treated as unregistered')).toBeInTheDocument() + expect(screen.getByText('High priority')).toBeInTheDocument() + expect(screen.getByText('Root cause:').parentElement).toHaveTextContent('No consecutive lookups') + expect(screen.getAllByText('Confirmed').length).toBeGreaterThanOrEqual(2) // list pill and table pill + expect(screen.getByText('Proposed')).toBeInTheDocument() + expect(screen.getByTestId('vs-timeline')).toHaveTextContent('Ebony') + expect(screen.getByTestId('vs-callout')).toHaveTextContent('Consecutive lookups') + // Any timestamp plays the recording from that line. + fireEvent.click(screen.getAllByRole('button', { name: 'Play from 00:00:22' })[0]) + expect(onSeek).toHaveBeenCalledWith(22_000) + fireEvent.click(screen.getByRole('button', { name: 'Play from 00:00:30' })) + expect(onSeek).toHaveBeenCalledWith(30_000) + }) + + it('says plainly when the page is extractive, and never shows items the analysis lacks', () => { + const degraded = { ...analysis, degraded: 'extractive', decisions: [] } + const thin: VisualSpec = { ...spec, blocks: [{ ...spec.blocks[0], meta: { degraded: 'extractive' } }, spec.blocks[3]] } + render() + expect(screen.getByText(/No language model answered/)).toBeInTheDocument() + expect(screen.queryByText('Decisions')).not.toBeInTheDocument() // an empty list block renders nothing + }) + + it('exports the analysis as readable Markdown with timestamps', () => { + const md = analysisToMarkdown(analysis) + expect(md).toContain('# Fixing domain status detection') + expect(md).toContain('**Confirmed:** Move prompt management to Langfuse _(00:00:22)_') + expect(md).toContain('- [ ] Inject fifty annotation tickets — Ebony (by Friday)') + expect(stampToMs('00:12:34')).toBe(754_000) + }) +}) + +describe('InsightsPanel', () => { + it('renders nothing when the server has the feature off', async () => { + const { container } = render() + await waitFor(() => expect(calls.some((c) => c.url.endsWith('/analysis'))).toBe(true)) + await waitFor(() => expect(container).toBeEmptyDOMElement()) + }) + + it('offers to generate, then shows progress once the job is queued', async () => { + let state: 'none' | 'pending' = 'none' + respond(/GET .*\/analysis$/, () => ({ body: state === 'none' ? { state } : { state, job: { stage: 'analysing', progress: 0.3, state: 'running' } } })) + respond(/POST .*\/analysis$/, () => { state = 'pending'; return { status: 202, body: { job: { id: 'j1', state: 'queued' }, state: 'pending' } } }) + render() + const cta = await screen.findByRole('button', { name: /Generate insights/ }) + expect(screen.getByTestId('insights-cta')).toHaveTextContent('one-page summary') + fireEvent.click(cta) + await screen.findByTestId('insights-progress') + expect(screen.getByTestId('insights-progress')).toHaveTextContent('analysing') + expect(calls.filter((c) => c.method === 'POST').length).toBe(1) + }) + + const readyBody = () => ({ + state: 'ready', id: 'an1', created_at: 1_772_272_800, model: 'qwen', degraded: null, versions: 1, + analysis, visual: spec, report: analysis.validator, + questions: [{ id: 'sq1', text: 'What was decided about prompt management?', kind: 'decisions' }, { id: 'sq2', text: 'Who owns the tickets?', kind: 'actions' }], + }) + + it('shows the compact card with counts, the verification line and clickable suggested questions when ready', async () => { + respond(/GET .*\/analysis$/, () => ({ body: readyBody() })) + const onAsk = vi.fn() + const onOpenVisual = vi.fn() + render() + const card = await screen.findByTestId('insights-compact') + expect(card).toHaveTextContent('Fixing domain status detection') + expect(card).toHaveTextContent('2 decisions') + expect(card).toHaveTextContent('1 risks') + expect(screen.getByText(/5 items verified against the transcript · 1 dropped/)).toBeInTheDocument() + expect(screen.getByText('Model · qwen')).toBeInTheDocument() + expect(screen.queryByTestId('visual-summary')).not.toBeInTheDocument() // the full page lives in its tab + fireEvent.click(screen.getByRole('button', { name: /Open visual summary/ })) + expect(onOpenVisual).toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: /What was decided about prompt management/ })) + expect(onAsk).toHaveBeenCalledWith('What was decided about prompt management?') + expect(screen.getByRole('button', { name: /Regenerate/ })).toBeEnabled() + }) + + it('shows the whole visual page in place when there is no tab to open', async () => { + respond(/GET .*\/analysis$/, () => ({ body: readyBody() })) + render() + await screen.findByTestId('visual-summary') + expect(screen.queryByTestId('insights-compact')).not.toBeInTheDocument() + }) + + it('waits for a transcript before offering anything', async () => { + respond(/GET .*\/analysis$/, () => ({ body: { state: 'no_transcript' } })) + render() + expect(await screen.findByText(/written once there is a transcript/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Generate insights/ })).not.toBeInTheDocument() + }) +}) + +describe('AssistantDrawer', () => { + it('answers with citations that seek the recording, and asks a seeded question on open', async () => { + respond(/POST .*\/ask$/, (_u, init) => { + const q = JSON.parse(String(init?.body)).text as string + return { body: { text: `Answer to: ${q}`, cited: ['00:00:22', '00:00:30'], degraded: null } } + }) + const onSeek = vi.fn() + const onSeeded = vi.fn() + render( undefined} seed="What was decided?" onSeeded={onSeeded} onSeek={onSeek} hasTranscript />) + expect(await screen.findByText('Answer to: What was decided?')).toBeInTheDocument() + expect(onSeeded).toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Play from 00:00:30' })) + expect(onSeek).toHaveBeenCalledWith(30_000) + // A typed follow-up goes through the same route and the history keeps both turns. + fireEvent.change(screen.getByLabelText('Ask a question about this meeting'), { target: { value: 'And who owns it?' } }) + fireEvent.click(screen.getByRole('button', { name: 'Ask' })) + expect(await screen.findByText('Answer to: And who owns it?')).toBeInTheDocument() + expect(calls.filter((c) => c.method === 'POST').length).toBe(2) + expect(JSON.parse(sessionStorage.getItem('hp_meeting_assistant:m1') || '[]')).toHaveLength(2) + }) + + it('says when an answer is quoted from the transcript because no model answered', async () => { + respond(/POST .*\/ask$/, () => ({ body: { text: 'From the meeting transcript:\n[00:00:22] we agreed', cited: ['00:00:22'], degraded: 'extractive' } })) + render( undefined} seed="Anything?" hasTranscript />) + expect(await screen.findByText(/No language model answered — these are the transcript lines/)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/meetingsLandingGate.test.tsx b/frontend/src/test/meetingsLandingGate.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4dfac45a8301597a7e2715ca6fb7f197d8b61eee --- /dev/null +++ b/frontend/src/test/meetingsLandingGate.test.tsx @@ -0,0 +1,57 @@ +/** + * The Meetings landing page must not change while meeting intelligence lands in the detail + * view. This gate snapshots the rendered markup of `MeetingsHome` with a fixed library, so + * any change to its structure or classes fails here before anyone looks at a screenshot. + * + * Updating the snapshot is a deliberate act (`vitest -u`) that belongs in a pull request + * which says the landing page is meant to change. + */ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import { MeetingsHome } from '../ui/meetingsense/library/MeetingsHome' +import type { LibraryItem } from '../ui/meetingsense/library/api' + +// Noon UTC, so the printed date is the same on every machine between UTC-11 and UTC+11. +const NOON = 1_772_280_000 + +function item(over: Partial = {}): LibraryItem { + return { + library_id: 'm1', meeting_id: 'm1', origin_type: 'import', origin_id: 'm1', title: 'Architecture Review', + description: 'Discussed system architecture.', category: 'Project Meeting', started_at: NOON, ended_at: null, + scheduled_for: null, duration_ms: 3_120_000, projects: [{ id: 'p1', name: 'HomePilot' }], + source: { kind: 'upload', label: 'Upload' }, media_present: true, recording_state: 'present', + transcript_state: 'ready', summary_state: 'ready', segments: 3, conversation_id: null, status: 'completed', + live: false, warnings: [], ...over, + } +} + +const LIBRARY = { + items: [ + item(), + 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' }), + 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 }), + ], + next_cursor: null, total: 3, counts: { all: 3, imported: 2, scheduled: 1, processing: 0 }, + facets: { sources: [{ kind: 'upload', label: 'Upload' }], projects: [{ id: 'p1', name: 'HomePilot' }] }, +} + +beforeEach(() => { + localStorage.clear() + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), 'http://hp.test') + const body = url.pathname.endsWith('/jobs') ? { jobs: [], stt: { available: true } } : LIBRARY + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }) + })) +}) +afterEach(() => { vi.unstubAllGlobals() }) + +it('keeps the Meetings landing page exactly as it is', async () => { + const { container } = render( undefined} canRecordLive />) + await screen.findByTestId('meetings-grid') + await screen.findByText('Architecture Review') + expect(screen.getByTestId('meetings-home')).toBeInTheDocument() + for (const name of ['New Meeting', 'Import Files', 'Folder Sync']) expect(screen.getByRole('button', { name })).toBeInTheDocument() + expect(container.innerHTML).toMatchSnapshot() +}) diff --git a/frontend/src/test/meetingsLibrary.test.tsx b/frontend/src/test/meetingsLibrary.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1ce2ea1e8183a3037446253aecd3ef3b92c1f557 --- /dev/null +++ b/frontend/src/test/meetingsLibrary.test.tsx @@ -0,0 +1,240 @@ +/** + * Meetings Library: honest status chips, readable summaries, the library grid, the details + * panel that can be hidden and stays hidden, and a wizard that records nothing by itself. + */ +import React from 'react' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + isFallbackSummary, + overviewSummary, + plainText, + statusChip, +} from '../ui/meetingsense/library/format' +import { MeetingsHome } from '../ui/meetingsense/library/MeetingsHome' +import { MeetingDetailPage } from '../ui/meetingsense/library/MeetingDetailPage' +import { StartMeetingWizard, summaryStyleFor } from '../ui/meetingsense/library/StartMeetingWizard' +import { resample } from '../ui/meetingsense/library/WaveformPlayer' +import type { LibraryItem } from '../ui/meetingsense/library/api' + +const OUTLINE = + "*No language model was reachable, so this is assembled from the meeting's own words rather than written.*\n\n" + + '## The meeting in order\n\n**00:00:05–00:01:18** — A modular approach.' + +function item(over: Partial = {}): LibraryItem { + return { + library_id: 'm1', meeting_id: 'm1', origin_type: 'import', origin_id: 'm1', title: 'Architecture Review', + description: 'Discussed system architecture.', category: 'Project Meeting', started_at: 1772272800, ended_at: null, + scheduled_for: null, duration_ms: 3_120_000, projects: [{ id: 'p1', name: 'HomePilot' }], + source: { kind: 'upload', label: 'Upload' }, media_present: true, recording_state: 'present', + transcript_state: 'ready', summary_state: 'ready', segments: 3, conversation_id: null, status: 'completed', + live: false, warnings: [], ...over, + } +} + +type Route = (url: URL, init?: RequestInit) => unknown +let routes: Array<[RegExp, Route]> = [] +const calls: Array<{ url: string; method: string; body?: any }> = [] + +function respond(pattern: RegExp, fn: Route) { + routes.push([pattern, fn]) +} + +beforeEach(() => { + routes = [] + calls.length = 0 + localStorage.clear() + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), 'http://hp.test') + const method = (init?.method || 'GET').toUpperCase() + let body: any + try { body = init?.body && typeof init.body === 'string' ? JSON.parse(init.body) : undefined } catch { body = undefined } + calls.push({ url: url.pathname + url.search, method, body }) + for (const [pattern, fn] of routes) { + if (pattern.test(`${method} ${url.pathname}`)) { + return new Response(JSON.stringify(fn(url, init)), { status: 200, headers: { 'content-type': 'application/json' } }) + } + } + return new Response(JSON.stringify({ detail: 'not mocked' }), { status: 404 }) + })) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('status chips', () => { + it('never calls an empty or silent recording transcribed', () => { + expect(statusChip(item({ transcript_state: 'no_speech' })).label).toBe('No speech') + expect(statusChip(item({ transcript_state: 'none', media_present: true })).label).toBe('Needs Transcript') + expect(statusChip(item({ transcript_state: 'none', media_present: false, recording_state: 'awaiting' })).label).toBe('Awaiting recording') + expect(statusChip(item({ transcript_state: 'needs_setup' })).label).toBe('Needs setup') + expect(statusChip(item({ transcript_state: 'transcript_only' })).label).toBe('Transcript only') + expect(statusChip(item({ live: true })).label).toBe('Recording') + }) +}) + +describe('summaries', () => { + it('skips the no-model outline and falls back to notes or the description', () => { + expect(isFallbackSummary({ text: OUTLINE })).toBe(true) + expect(overviewSummary([{ text: OUTLINE }], 'Notes summary.', 'Description.')).toEqual({ text: 'Notes summary.', markdown: false }) + expect(overviewSummary([{ text: OUTLINE }], '', 'Description.')).toEqual({ text: 'Description.', markdown: false }) + }) + + it('shows the written summary without its long outline', () => { + const doc = { text: '## Summary\n\nWe chose **React Native**.\n\n## The meeting in order\n\n**00:00** — long outline' } + const out = overviewSummary([{ text: OUTLINE }, doc], 'notes', 'desc') + expect(out.markdown).toBe(true) + expect(out.text).toContain('React Native') + expect(out.text).not.toContain('meeting in order') + expect(plainText(out.text)).toBe('Summary\n\nWe chose React Native.') + }) + + it('maps the chosen outputs to a summary style', () => { + expect(summaryStyleFor(['transcript', 'minutes'])).toBe('minutes') + expect(summaryStyleFor(['summary', 'actions'])).toBe('notes') + expect(summaryStyleFor(['actions'])).toBe('actions') + }) +}) + +describe('waveform', () => { + it('keeps the loudest peak of each bucket when there are more peaks than bars', () => { + expect(resample([0.1, 0.9, 0.2, 0.3, 0.8, 0.1], 3)).toEqual([0.9, 0.3, 0.8]) + expect(resample([0.5, 0.4], 10)).toEqual([0.5, 0.4]) + expect(resample(Array.from({ length: 400 }, (_, i) => i / 400), 100)).toHaveLength(100) + }) +}) + +describe('Meetings home', () => { + it('lists meetings as cards and asks the server for the chosen tab', async () => { + respond(/GET \/v1\/meetingsense\/library$/, (url) => ({ + items: url.searchParams.get('tab') === 'imported' + ? [item({ library_id: 'm2', meeting_id: 'm2', title: 'Imported meeting · Oct 10, 12:31', transcript_state: 'no_speech' })] + : [item(), item({ library_id: 's1', meeting_id: 's1', title: 'Sprint Planning', origin_type: 'scheduled', transcript_state: 'none', media_present: false, recording_state: 'awaiting' })], + next_cursor: null, total: 2, counts: {}, facets: { sources: [], projects: [] }, + })) + respond(/GET \/v1\/meetingsense\/jobs/, () => ({ jobs: [], stt: { available: true } })) + const onOpen = vi.fn() + render() + + const grid = await screen.findByTestId('meetings-grid') + expect(within(grid).getByText('Architecture Review')).toBeTruthy() + expect(within(grid).getByText('Awaiting recording')).toBeTruthy() + // Icon-only on phones, so the header actions carry their names themselves. + for (const name of ['New Meeting', 'Import Files', 'Folder Sync']) expect(screen.getByRole('button', { name })).toBeTruthy() + + fireEvent.click(screen.getByTestId('meetings-tab-imported')) + await screen.findByText('Imported meeting · Oct 10, 12:31') + expect(screen.getByText('No speech')).toBeTruthy() + expect(calls.some((c) => c.url.includes('tab=imported'))).toBe(true) + expect(localStorage.getItem('hp_meetings_tab')).toBe('imported') + + fireEvent.click(screen.getByTestId('meeting-card-m2')) + expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ library_id: 'm2' })) + }) +}) + +describe('Meeting detail', () => { + function detail(over: Record = {}) { + return { + item: item(), + segments: [ + { id: 'a', t0_ms: 5000, t1_ms: 9000, speaker: 'Jane Smith', text: "Let's start with the architecture proposal." }, + { id: 'b', t0_ms: 16000, t1_ms: 20000, speaker: 'Michael Lee', text: 'A modular approach works.' }, + ], + 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' }] } }, + summaries: [{ id: 's1', text: OUTLINE, degraded: 'extractive', created_at: 1772272900 }], + 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 }], + ...over, + } + } + + it('shows the notes instead of the raw outline, and remembers a hidden panel', async () => { + localStorage.setItem('hp_meeting_details_open', '1') + respond(/GET \/v1\/meetingsense\/library\/item\/m1$/, () => detail()) + const { unmount } = render( undefined} />) + + await screen.findByRole('heading', { name: 'Architecture Review' }) + expect(screen.getAllByText('Agreed on a modular architecture.').length).toBeGreaterThan(0) + expect(screen.queryByText(/No language model was reachable/)).toBeNull() + expect(screen.getByText('Adopt React Native.')).toBeTruthy() + + const panel = screen.getByRole('complementary', { name: 'Meeting details' }) + expect(within(panel).getByText('Write the design doc')).toBeTruthy() + + const toggle = screen.getByTestId('details-toggle') + expect(toggle.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('complementary', { name: 'Meeting details' })).toBeNull() + expect(localStorage.getItem('hp_meeting_details_open')).toBe('0') + unmount() + + render( undefined} />) + await screen.findByRole('heading', { name: 'Architecture Review' }) + expect(screen.queryByRole('complementary', { name: 'Meeting details' })).toBeNull() + expect(screen.getByTestId('details-toggle').textContent).toContain('Show details') + }) + + it('resizes the details panel from the keyboard within its limits', async () => { + localStorage.setItem('hp_meeting_details_open', '1') + respond(/GET \/v1\/meetingsense\/library\/item\/m1$/, () => detail()) + render( undefined} />) + const handle = await screen.findByRole('separator', { name: 'Resize details' }) + expect(handle.getAttribute('aria-valuenow')).toBe('360') + fireEvent.keyDown(handle, { key: 'ArrowLeft' }) + expect(handle.getAttribute('aria-valuenow')).toBe('380') + for (let i = 0; i < 20; i += 1) fireEvent.keyDown(handle, { key: 'ArrowLeft' }) + expect(handle.getAttribute('aria-valuenow')).toBe('520') + expect(localStorage.getItem('hp_meeting_details_width')).toBe('520') + }) + + it('renders a written summary as text, not markdown symbols', async () => { + localStorage.setItem('hp_meeting_details_open', '1') + respond(/GET \/v1\/meetingsense\/library\/item\/m1$/, () => detail({ + notes: null, + summaries: [{ id: 's2', text: '## Decisions\n\n- Keep **Kubernetes**.', created_at: 1772272900 }], + })) + render( undefined} />) + const strong = await screen.findByText('Kubernetes') + expect(strong.tagName).toBe('STRONG') + expect(screen.queryByText(/\*\*Kubernetes\*\*/)).toBeNull() + }) + + it('says plainly why a silent recording has no transcript', async () => { + respond(/GET \/v1\/meetingsense\/library\/item\/m3$/, () => detail({ + item: item({ library_id: 'm3', meeting_id: 'm3', transcript_state: 'no_speech', summary_state: 'none', + warnings: ['No speech detected — the recording is silent (loudest moment -70.3 dB).'] }), + segments: [], notes: null, summaries: [], versions: [], + })) + render( undefined} />) + expect(await screen.findByText('No speech was detected in the recording.')).toBeTruthy() + expect(screen.getByText(/loudest moment -70.3 dB/)).toBeTruthy() + }) +}) + +describe('Start a meeting wizard', () => { + it('needs a title, then schedules the meeting without recording anything', async () => { + respond(/GET \/projects$/, () => ({ projects: [{ id: 'p1', name: 'HomePilot' }] })) + respond(/POST \/v1\/meetingsense\/library\/meetings$/, () => ({ meeting_id: 'new1' })) + const onCreated = vi.fn() + render( undefined} onCreated={onCreated} />) + + fireEvent.click(screen.getByRole('button', { name: /Next: Recording source/ })) + expect(screen.getByRole('alert').textContent).toContain('Give the meeting a title.') + + fireEvent.change(screen.getByPlaceholderText('Project planning meeting'), { target: { value: 'Q4 roadmap review' } }) + fireEvent.click(screen.getByText('Schedule for later')) + fireEvent.click(screen.getByRole('button', { name: /Next: Recording source/ })) + fireEvent.click(screen.getByRole('button', { name: /Next: Assistant/ })) + fireEvent.click(screen.getByRole('button', { name: /Next: Summary/ })) + fireEvent.click(screen.getByRole('button', { name: /Schedule meeting/ })) + + await waitFor(() => expect(onCreated).toHaveBeenCalled()) + const post = calls.find((c) => c.method === 'POST' && c.url.startsWith('/v1/meetingsense/library/meetings')) + expect(post?.body?.title).toBe('Q4 roadmap review') + expect(post?.body?.scheduled_for).toBeGreaterThan(Date.now() / 1000) + expect(calls.some((c) => /\/ws|\/start/.test(c.url))).toBe(false) + }) +}) diff --git a/frontend/src/test/visualSummaryTab.test.tsx b/frontend/src/test/visualSummaryTab.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e587936586ff75d0ab57659dd18b5f9b5dd0a208 --- /dev/null +++ b/frontend/src/test/visualSummaryTab.test.tsx @@ -0,0 +1,220 @@ +/** + * The Visual Summary tab: the Executive Brief renders only what the server verified, numbers + * the sections that have content and hides the rest, shows decision status exactly as + * verified, and every stamp leads back to the transcript. The toolbar re-writes, exports and + * presents the same brief without inventing anything. + */ +import React from 'react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { ExecutiveBriefView } from '../ui/meetingsense/library/meeting-summary/ExecutiveBrief' +import { VisualSummaryTab } from '../ui/meetingsense/library/VisualSummaryTab' +import { slidesFor } from '../ui/meetingsense/library/PresentMode' +import { printBrief, safeFilename } from '../ui/meetingsense/library/briefExport' +import type { ExecutiveBrief, MeetingAnalysis, VisualSpec } from '../ui/meetingsense/library/intelligenceApi' +import type { LibraryItem } from '../ui/meetingsense/library/api' + +const analysis: MeetingAnalysis = { + schema_version: 1, meeting_id: 'm1', language: 'en', created_at: 1_772_272_800, + title: 'Agent debugging sync', one_line: 'The team confirmed the Langfuse move.', + topics: [], + decisions: [ + { 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 }, + { 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 }, + ], + risks: [ + { 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' }] }, + { 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' }] }, + ], + 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 }, + { 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 }, + ], + open_questions: [{ id: 'q1', text: 'Who owns the definition of unknown status?', evidence: [{ segment_id: 's6', t0_ms: 38_000, quote: 'Who owns' }] }], + 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' }] }], + speakers: [], degraded: null, model: 'qwen', validator: { kept: 7, dropped: 0, repaired: 0, notes: [] }, +} + +const brief: ExecutiveBrief = { + eyebrow: 'Technical meeting · Executive brief', + title: 'Domain status fixes and Langfuse prompt management', + intro: 'The team traced agent failures to unknown domain status handling.', + risks: [{ item: 'r1', title: 'Ambiguous domain status handling', summary: 'Unknown statuses are treated as unregistered.', note: 'No consecutive lookups', severity: 'high', polished: true }], + blockers: [{ item: 'r2', title: 'MX lookup tool missing in the test environment', summary: 'The MX record tool is unavailable.', note: '', severity: 'medium', polished: false }], + decisions: [ + { item: 'd1', option: 'Langfuse prompt management', status: 'confirmed', status_label: 'Selected', impact: 'Prompt changes without a redeploy.', polished: true }, + { item: 'd2', option: 'Add an MX record lookup before deciding', status: 'proposed', status_label: 'Proposed', impact: '', polished: false }, + ], + actions: [ + { item: 'a1', bucket: 'This week', label: 'This week', text: 'Inject fifty annotation tickets', owner: 'Ebony', polished: false }, + { item: 'a2', bucket: 'Next steps', label: 'Next steps', text: 'Ask the business systems team for access', owner: 'Vasi', polished: false }, + ], + open_questions: ['q1'], + takeaway: 'Chain the DNS and MX checks and move prompt management to Langfuse.', takeaway_items: ['r1', 'd1'], + source_count: 7, polished: true, notes: ['d2: proposal kept in its own words.'], +} +const spec: VisualSpec = { schema_version: 1, analysis_id: 'an1', template: 'executive_brief', blocks: [], brief } +const item = { + title: 'Agent debugging sync', started_at: 1_772_272_800, scheduled_for: null, duration_ms: 1_500_000, + projects: [{ id: 'p1', name: 'Agents' }], source: { kind: 'import', label: 'Imported' }, +} as unknown as LibraryItem + +type Route = (url: URL, init?: RequestInit) => { status?: number; body: unknown } +let routes: Array<[RegExp, Route]> = [] +const calls: Array<{ url: string; method: string }> = [] +function respond(pattern: RegExp, fn: Route) { routes.push([pattern, fn]) } + +beforeEach(() => { + routes = [] + calls.length = 0 + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), 'http://hp.test') + const method = (init?.method || 'GET').toUpperCase() + calls.push({ url: url.pathname, method }) + for (const [pattern, fn] of routes) { + if (pattern.test(`${method} ${url.pathname}`)) { + const out = fn(url, init) + return new Response(JSON.stringify(out.body), { status: out.status || 200, headers: { 'content-type': 'application/json' } }) + } + } + return new Response(JSON.stringify({ detail: 'not found' }), { status: 404, headers: { 'content-type': 'application/json' } }) + })) +}) +afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers() }) + +const readyBody = () => ({ + state: 'ready', id: 'an1', created_at: 1_772_272_800, model: 'qwen', degraded: null, versions: 1, + analysis, visual: spec, report: analysis.validator, + questions: [{ id: 'sq1', text: 'What was decided about prompt management?', kind: 'decisions' }], +}) + +describe('ExecutiveBriefView', () => { + it('numbers the sections that have content, derives pills from verified status, and leads to the source', () => { + const onEvidence = vi.fn() + render() + expect(screen.getByRole('heading', { name: 'Domain status fixes and Langfuse prompt management' })).toBeInTheDocument() + expect(screen.getByText('01 / Core risks & blockers')).toBeInTheDocument() + expect(screen.getByText('02 / Decision comparison')).toBeInTheDocument() + expect(screen.getByText('03 / Next actions')).toBeInTheDocument() + expect(screen.getByText('04 / Still open')).toBeInTheDocument() + expect(screen.getByTestId('brief-card-r1')).toHaveTextContent('HIGH PRIORITY') + expect(screen.getByTestId('brief-card-r2')).toHaveTextContent('BLOCKER') + // Status pills come from the verified status, never from the wording. + expect(screen.getByTestId('brief-row-d1')).toHaveTextContent('Selected') + expect(screen.getByTestId('brief-row-d2')).toHaveTextContent('Proposed') + expect(screen.getByTestId('brief-timeline')).toHaveTextContent('This week') + expect(screen.getByTestId('brief-timeline')).toHaveTextContent('Next steps') + expect(screen.getByTestId('brief-takeaway')).toHaveTextContent('Chain the DNS and MX checks') + expect(screen.getByText(/Grounded in 7 transcript moments/)).toBeInTheDocument() + expect(screen.getByText(/2 Mar 2026 · 25 min/)).toBeInTheDocument() + // d1 is cited twice (its row and the takeaway); either stamp opens the same line. + fireEvent.click(screen.getAllByRole('button', { name: 'View source at 00:00:22' })[0]) + expect(onEvidence).toHaveBeenCalledWith(22_000, 'd1') + }) + + it('hides empty sections and renumbers the rest, and never invents a blocker or a question', () => { + const thin: ExecutiveBrief = { ...brief, risks: [], blockers: [], open_questions: [], actions: [brief.actions[0]] } + render() + expect(screen.queryByText(/Core risks/)).not.toBeInTheDocument() + expect(screen.queryByText(/Still open/)).not.toBeInTheDocument() + expect(screen.getByText('01 / Decision comparison')).toBeInTheDocument() + expect(screen.getByText('02 / Next actions')).toBeInTheDocument() + // Without a handler, stamps are plain text: nothing to click, nothing broken. + expect(screen.queryByRole('button', { name: /View source/ })).not.toBeInTheDocument() + expect(screen.getAllByText('00:00:22').length).toBeGreaterThan(0) // the decision row and the takeaway both cite d1 + }) + + it('presents only the sections that exist as slides', () => { + expect(slidesFor(brief)).toEqual([['header'], ['risks'], ['decisions'], ['actions'], ['questions'], ['takeaway', 'footer']]) + expect(slidesFor({ ...brief, actions: [], open_questions: [], takeaway: '' })).toEqual([['header'], ['risks'], ['decisions']]) + }) +}) + +describe('VisualSummaryTab', () => { + it('offers to generate when nothing exists yet, and queues the analysis', async () => { + let state = 'none' + respond(/GET .*\/analysis$/, () => ({ body: state === 'none' ? { state } : { state, job: { stage: 'writing the brief', progress: 0.6, state: 'running' } } })) + respond(/POST .*\/analysis$/, () => { state = 'pending'; return { status: 202, body: { job: { id: 'j1', state: 'queued' }, state: 'pending' } } }) + render() + fireEvent.click(await screen.findByRole('button', { name: /Generate visual summary/ })) + await screen.findByTestId('visual-progress') + expect(screen.getByTestId('visual-progress')).toHaveTextContent('writing the brief') + expect(calls.filter((c) => c.method === 'POST').map((c) => c.url)).toEqual(['/v1/meetingsense/library/item/m1/analysis']) + }) + + it('renders the brief, rewrites it on request without re-analysing, and leads from the footer to the first source', async () => { + respond(/GET .*\/analysis$/, () => ({ body: readyBody() })) + respond(/POST .*\/visual$/, () => ({ status: 202, body: { job: { id: 'j2', state: 'queued' }, state: 'pending' } })) + const onEvidence = vi.fn() + const onAsk = vi.fn() + render() + await screen.findByTestId('executive-brief') + expect(screen.getByTestId('visual-footer')).toHaveTextContent('Grounded in 7 referenced transcript moments') + expect(screen.getByText(/AI-written wording · verified · qwen/)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /View source evidence/ })) + expect(onEvidence).toHaveBeenCalledWith(6_000, 'r1') + fireEvent.click(screen.getByRole('button', { name: 'View source at 00:00:44' })) + expect(onEvidence).toHaveBeenCalledWith(44_000, 'r2') + fireEvent.click(screen.getByRole('button', { name: /What was decided about prompt management/ })) + expect(onAsk).toHaveBeenCalledWith('What was decided about prompt management?') + fireEvent.click(screen.getByRole('button', { name: /Rewrite brief/ })) + await waitFor(() => expect(calls.filter((c) => c.method === 'POST').map((c) => c.url)).toEqual(['/v1/meetingsense/library/item/m1/visual'])) + // The validation notes are there for whoever wants them, folded away by default. + fireEvent.click(screen.getByRole('button', { name: /What was checked/ })) + expect(screen.getByText('d2: proposal kept in its own words.')).toBeInTheDocument() + }) + + it('presents the same brief as slides, moves with the keyboard, and leaves on Escape', async () => { + respond(/GET .*\/analysis$/, () => ({ body: readyBody() })) + render() + await screen.findByTestId('executive-brief') + fireEvent.click(screen.getByRole('button', { name: /Present/ })) + const stage = screen.getByTestId('present-mode') + expect(screen.getByTestId('present-counter')).toHaveTextContent('1 / 6') + expect(stage).toHaveTextContent('Domain status fixes and Langfuse prompt management') + fireEvent.keyDown(document, { key: 'ArrowRight' }) + expect(screen.getByTestId('present-counter')).toHaveTextContent('2 / 6') + expect(stage).toHaveTextContent('Core risks & blockers') + fireEvent.keyDown(document, { key: 'End' }) + expect(screen.getByTestId('present-counter')).toHaveTextContent('6 / 6') + expect(stage).toHaveTextContent('Core takeaway') + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByTestId('present-mode')).not.toBeInTheDocument() + }) + + it('says when the feature is off, and waits for a transcript', async () => { + const { unmount } = render() + expect(await screen.findByText(/turned off on this server/)).toBeInTheDocument() + unmount() + respond(/GET .*\/analysis$/, () => ({ body: { state: 'no_transcript' } })) + render() + expect(await screen.findByText(/written once there is a transcript/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Generate visual summary/ })).not.toBeInTheDocument() + }) +}) + +describe('exports', () => { + it('prints a clone of the report on its own and cleans up afterwards', () => { + vi.useFakeTimers() + const print = vi.fn() + vi.stubGlobal('print', print) + const report = document.createElement('div') + report.innerHTML = '
Brief
' + document.body.appendChild(report) + printBrief(report) + act(() => { vi.advanceTimersByTime(60) }) + expect(print).toHaveBeenCalledTimes(1) + expect(document.getElementById('hp-print-root')).toHaveTextContent('Brief') + expect(document.body.classList.contains('hp-print-brief')).toBe(true) + act(() => { vi.advanceTimersByTime(1600) }) + expect(document.getElementById('hp-print-root')).toBeNull() + expect(document.body.classList.contains('hp-print-brief')).toBe(false) + report.remove() + }) + + it('names files after the meeting, safely', () => { + expect(safeFilename('Agent sync: Q3 / review!', 'visual-summary')).toBe('agent-sync-q3-review-visual-summary') + expect(safeFilename('', 'visual-summary')).toBe('meeting-visual-summary') + }) +}) diff --git a/frontend/src/ui/App.tsx b/frontend/src/ui/App.tsx index 546f313ce0c034ea00f7f37b67859703ededc1ef..203c75fc7cb183bf23a07ef4b7518f523cdb68e9 100644 --- a/frontend/src/ui/App.tsx +++ b/frontend/src/ui/App.tsx @@ -145,6 +145,9 @@ import { } from './meetingsense/catalog' import { useMeetingCatalog } from './meetingsense/useMeetingCatalog' import { MeetingLibrary } from './meetingsense/MeetingLibrary' +import { MeetingsHome } from './meetingsense/library/MeetingsHome' +import { MeetingDetailPage } from './meetingsense/library/MeetingDetailPage' +import type { LibraryStatus } from './meetingsense/library/api' import { MeetingSenseProvider } from './meetingsense/MeetingSenseProvider' import type { MeetingSenseStatus } from './meetingsense/entryPoint' @@ -3317,15 +3320,33 @@ export default function App() { // MS29. The one status body the whole feature reads: the nav flag, the record button, and // the consent sheet's copy all come from it, so there is one round trip and one answer. const [meetingStatus, setMeetingStatus] = useState(null) - const meetingsView = useMeetingCatalog({ enabled: meetingsNavEnabled && mode === 'meetings' }) + // Meetings Library upgrade (MEETINGS_LIBRARY_UPGRADE). Its own status route, so the legacy + // catalog below stays exactly as it was whenever the upgrade is off. + const [libraryStatus, setLibraryStatus] = useState(null) + const [meetingDetailId, setMeetingDetailId] = useState(null) + const [teamsRoomToOpen, setTeamsRoomToOpen] = useState(null) + const libraryOn = Boolean(libraryStatus?.enabled) + const meetingsView = useMeetingCatalog({ enabled: meetingsNavEnabled && mode === 'meetings' && !libraryOn }) + useEffect(() => { + if (mode !== 'meetings') setMeetingDetailId(null) + if (mode !== 'teams') setTeamsRoomToOpen(null) + }, [mode]) useEffect(() => { let cancelled = false void (async () => { try { const res = await fetch('/v1/meetingsense/status') const body = res.ok ? await res.json() : null + let library: LibraryStatus | null = null + try { + const lib = await fetch('/v1/meetingsense/library/status') + library = lib.ok ? await lib.json() : null + } catch { + // An older backend has no library: the Meetings view is the legacy catalog. + } if (cancelled) return - setMeetingsNavEnabled(msCatalogEnabled(body)) + setLibraryStatus(library) + setMeetingsNavEnabled(msCatalogEnabled(body) || Boolean(library?.enabled)) setMeetingStatus(body) } catch { // 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. const advisor = useAdvisorUpdates(settingsDraft.backendUrl, advisorCurrent, settingsDraft.apiKey) const projectThumbUrl = projectAvatarUrl(currentProject, settingsDraft.backendUrl, 'thumb') + // Opening a project — from the Projects page, and from a meeting's project link. + const handleProjectSelect = async (projectId: string) => { + try { + // Fetch project info to get instructions and document count + const response = await fetch( + `${settingsDraft.backendUrl.replace(/\/+$/, '')}/projects/${projectId}`, + { + headers: authHeaders, + } + ) + + if (response.ok) { + const data = await response.json() + const project = data.project + + // Store project ID and info for chat context + localStorage.setItem('homepilot_current_project', projectId) + setCurrentProject({ + id: projectId, + name: project.name, + document_count: project.document_count || 0, + project_type: project.project_type, + description: project.description, + instructions: project.instructions, + files: project.files, + agentic: project.agentic, + persona_agent: project.persona_agent, + persona_appearance: project.persona_appearance, + created_at: project.created_at, + updated_at: project.updated_at, + }) + + projectInfoLoadedRef.current = projectId + const lastConvId = project.last_conversation_id + const loadMessages = async (convId: string) => { + const convData = await getJson<{ + ok: boolean + messages: Array<{ role: string; content: string; created_at: string; media?: { images?: string[]; video_url?: string } | null }> + }>(settingsDraft.backendUrl, `/conversations/${convId}/messages`, authHeaders) + return convData.ok && convData.messages ? convData.messages : [] + } + const toMsgs = (rows: Array<{ role: string; content: string; media?: any }>) => + rows.map((m, idx) => { + const h = hydratePersistedMessageMedia(m.media) + return { + id: `restored-${idx}`, + role: m.role as 'user' | 'assistant' | 'system', + text: m.content, + animate: false, + media: h.media, + ...(h.callMemory ? { callMemory: h.callMemory } : {}), + } + }) + + if (project.project_type === 'persona') { + // Companion-grade: the Session Hub opens right away. The persistent + // session and its messages load behind it, in parallel with the last + // conversation (the fallback when the session is still empty) — + // previously four requests ran one after another before the hub showed. + void (async () => { + const lastPromise = lastConvId ? loadMessages(lastConvId).catch(() => []) : Promise.resolve([]) + try { + const session = await resolveSession(projectId, 'text') + console.log('[Project] Resolved persona session:', session.id) + localStorage.setItem('homepilot_active_voice_session', JSON.stringify(session)) + const sessionRows = session.conversation_id === lastConvId + ? await lastPromise + : await loadMessages(session.conversation_id).catch(() => []) + const rows = sessionRows.length > 0 ? sessionRows : await lastPromise + // Nothing to show: clear the chat first — it assigns a fresh + // conversation id, so the session's own id must be set after it. + if (rows.length === 0) onNewConversation() + setConversationId(session.conversation_id) + if (rows.length > 0) setMessages(toMsgs(rows)) + } catch (err) { + console.warn('[Project] Session resolution failed:', err) + const rows = await lastPromise + if (lastConvId && rows.length > 0) { + setConversationId(lastConvId) + setMessages(toMsgs(rows)) + } else { + onNewConversation() + } + } + })() + } else if (lastConvId) { + // Restore previous conversation for this project + try { + const rows = await loadMessages(lastConvId) + if (rows.length > 0) { + setConversationId(lastConvId) + setMessages(toMsgs(rows)) + } else { + // Conversation was empty/deleted — start fresh + onNewConversation() + } + } catch { + // Failed to load — start fresh + onNewConversation() + } + } else { + // No previous conversation — start fresh + onNewConversation() + } + + // Agent projects: auto-enable agentic execution mode + if (project.project_type === 'agent') { + const agentSettings: ChatScopedSettings = { + advancedHelpEnabled: true, + askBeforeActing: project.agentic?.ask_before_acting !== false, + executionProfile: project.agentic?.execution_profile === 'balanced' + ? 'balanced' + : project.agentic?.execution_profile === 'quality' + ? 'quality' + : 'fast', + incognito: false, + } + updateChatSettings(agentSettings) + } + + // Route to the correct mode based on project type + if (project.project_type === 'persona') { + // Companion-grade: show the Session Hub first + // User picks Continue / New Voice / New Text + setShowSessionPanel(true) + setMode('chat') + } else if (project.project_type === 'image') { + setMode('imagine') + } else if (project.project_type === 'video') { + setMode('animate') + } else { + setMode('chat') + } + } else { + // Fallback if fetch fails + localStorage.setItem('homepilot_current_project', projectId) + setCurrentProject(null) + setMode('chat') + } + } catch (error) { + console.error('Error fetching project:', error) + // Fallback + localStorage.setItem('homepilot_current_project', projectId) + setMode('chat') + } + } + return ( { - try { - // Fetch project info to get instructions and document count - const response = await fetch( - `${settingsDraft.backendUrl.replace(/\/+$/, '')}/projects/${projectId}`, - { - headers: authHeaders, - } - ) - - if (response.ok) { - const data = await response.json() - const project = data.project - - // Store project ID and info for chat context - localStorage.setItem('homepilot_current_project', projectId) - setCurrentProject({ - id: projectId, - name: project.name, - document_count: project.document_count || 0, - project_type: project.project_type, - description: project.description, - instructions: project.instructions, - files: project.files, - agentic: project.agentic, - persona_agent: project.persona_agent, - persona_appearance: project.persona_appearance, - created_at: project.created_at, - updated_at: project.updated_at, - }) - - projectInfoLoadedRef.current = projectId - const lastConvId = project.last_conversation_id - const loadMessages = async (convId: string) => { - const convData = await getJson<{ - ok: boolean - messages: Array<{ role: string; content: string; created_at: string; media?: { images?: string[]; video_url?: string } | null }> - }>(settingsDraft.backendUrl, `/conversations/${convId}/messages`, authHeaders) - return convData.ok && convData.messages ? convData.messages : [] - } - const toMsgs = (rows: Array<{ role: string; content: string; media?: any }>) => - rows.map((m, idx) => { - const h = hydratePersistedMessageMedia(m.media) - return { - id: `restored-${idx}`, - role: m.role as 'user' | 'assistant' | 'system', - text: m.content, - animate: false, - media: h.media, - ...(h.callMemory ? { callMemory: h.callMemory } : {}), - } - }) - - if (project.project_type === 'persona') { - // Companion-grade: the Session Hub opens right away. The persistent - // session and its messages load behind it, in parallel with the last - // conversation (the fallback when the session is still empty) — - // previously four requests ran one after another before the hub showed. - void (async () => { - const lastPromise = lastConvId ? loadMessages(lastConvId).catch(() => []) : Promise.resolve([]) - try { - const session = await resolveSession(projectId, 'text') - console.log('[Project] Resolved persona session:', session.id) - localStorage.setItem('homepilot_active_voice_session', JSON.stringify(session)) - const sessionRows = session.conversation_id === lastConvId - ? await lastPromise - : await loadMessages(session.conversation_id).catch(() => []) - const rows = sessionRows.length > 0 ? sessionRows : await lastPromise - // Nothing to show: clear the chat first — it assigns a fresh - // conversation id, so the session's own id must be set after it. - if (rows.length === 0) onNewConversation() - setConversationId(session.conversation_id) - if (rows.length > 0) setMessages(toMsgs(rows)) - } catch (err) { - console.warn('[Project] Session resolution failed:', err) - const rows = await lastPromise - if (lastConvId && rows.length > 0) { - setConversationId(lastConvId) - setMessages(toMsgs(rows)) - } else { - onNewConversation() - } - } - })() - } else if (lastConvId) { - // Restore previous conversation for this project - try { - const rows = await loadMessages(lastConvId) - if (rows.length > 0) { - setConversationId(lastConvId) - setMessages(toMsgs(rows)) - } else { - // Conversation was empty/deleted — start fresh - onNewConversation() - } - } catch { - // Failed to load — start fresh - onNewConversation() - } - } else { - // No previous conversation — start fresh - onNewConversation() - } - - // Agent projects: auto-enable agentic execution mode - if (project.project_type === 'agent') { - const agentSettings: ChatScopedSettings = { - advancedHelpEnabled: true, - askBeforeActing: project.agentic?.ask_before_acting !== false, - executionProfile: project.agentic?.execution_profile === 'balanced' - ? 'balanced' - : project.agentic?.execution_profile === 'quality' - ? 'quality' - : 'fast', - incognito: false, - } - updateChatSettings(agentSettings) - } - - // Route to the correct mode based on project type - if (project.project_type === 'persona') { - // Companion-grade: show the Session Hub first - // User picks Continue / New Voice / New Text - setShowSessionPanel(true) - setMode('chat') - } else if (project.project_type === 'image') { - setMode('imagine') - } else if (project.project_type === 'video') { - setMode('animate') - } else { - setMode('chat') - } - } else { - // Fallback if fetch fails - localStorage.setItem('homepilot_current_project', projectId) - setCurrentProject(null) - setMode('chat') - } - } catch (error) { - console.error('Error fetching project:', error) - // Fallback - localStorage.setItem('homepilot_current_project', projectId) - setMode('chat') - } - }} + onProjectSelect={handleProjectSelect} /> ) : mode === 'imagine' ? ( ) : mode === 'routines' ? ( // 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. void openRoutineConversation(run) }} /> + ) : mode === 'meetings' && libraryOn ? ( + // Meetings Library (MEETINGS_LIBRARY_UPGRADE): one list of every meeting, with + // imports, folder sync and a detail page that does not need a conversation. + meetingDetailId ? ( + setMeetingDetailId(null)} + onOpenConversation={(cid) => { void loadConversation(cid); setMode('chat') }} + onOpenTeamsRoom={(roomId) => { setTeamsRoomToOpen(roomId); setMode('teams') }} + onOpenProject={(projectId) => { void handleProjectSelect(projectId) }} + /> + ) : ( + { + if (item.origin_type === 'teams_room') { + setTeamsRoomToOpen(item.origin_id) + setMode('teams') + } else { + setMeetingDetailId(item.library_id) + } + }} + /> + ) ) : mode === 'meetings' ? ( // MS28, only reachable when `_CATALOG` is on — `mode` cannot be set to this without // the nav item, and the nav item is behind the same flag. diff --git a/frontend/src/ui/components/PersonaSettingsPanel.tsx b/frontend/src/ui/components/PersonaSettingsPanel.tsx index 7c53bbaa29bc8b455c7ec659e4ad6d4bac5a09d4..eea10dae177551da131bd701d33944efe4eb33df 100644 --- a/frontend/src/ui/components/PersonaSettingsPanel.tsx +++ b/frontend/src/ui/components/PersonaSettingsPanel.tsx @@ -405,7 +405,9 @@ export function PersonaSettingsPanel({ project, backendUrl, apiKey, onClose, onS const thumb = pap.selected_thumb_filename as string | undefined const full = pap.selected_filename as string | undefined - const url = fileUrl(backendUrl, thumb || full) + // The original, not the 256 px thumbnail: this look is what Save commits as + // the persona's picture, and a thumbnail (or a framed crop) would replace it. + const url = fileUrl(backendUrl, full || thumb) if (!url) return [] return [ diff --git a/frontend/src/ui/components/ProjectAvatar.tsx b/frontend/src/ui/components/ProjectAvatar.tsx index e72bb10b253ca058ce3ce4acafa5161e17bb06e5..8619075ffed67a4a5732e86d2422d03623f00680 100644 --- a/frontend/src/ui/components/ProjectAvatar.tsx +++ b/frontend/src/ui/components/ProjectAvatar.tsx @@ -7,8 +7,11 @@ * - Without one (or if every URL fails to load): the project's type icon. * - `level` (0..1, optional) makes the ring respond to audio — used by Voice * for a static picture. The face itself is never animated. + * - While a picture is on its way (a full-size original can take a moment), a + * soft shimmer fills the circle and the picture fades in once it has arrived — + * never an empty black disc. A picture already in the cache shows at once. */ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' import { Bot, Film, Image as ImageIcon, MessageSquare, User } from 'lucide-react' const FALLBACK_ACCENT = '139, 92, 246' // violet-500 @@ -73,6 +76,20 @@ export function useImageFallback(urls: Array) { } } +/** + * Whether the showing `src` has finished loading. A cached picture can be + * complete before React listens for "load", so that is checked right away. + */ +export function useImageReady(src: string | null) { + const ref = useRef(null) + const [readySrc, setReadySrc] = useState(null) + useLayoutEffect(() => { + const img = ref.current + if (src && img && img.complete && img.naturalWidth > 0) setReadySrc(src) + }, [src]) + return { ref, ready: !!src && readySrc === src, onLoad: () => setReadySrc(src) } +} + /** A decorative that tries each URL in turn; `fallback` when none loads. */ export function AvatarImage({ urls, @@ -86,16 +103,19 @@ export function AvatarImage({ fallback?: React.ReactNode }) { const { src, onError } = useImageFallback(urls) + const { ref, ready, onLoad } = useImageReady(src) if (!src) return <>{fallback} return ( ) @@ -121,7 +141,9 @@ export function ProjectAvatar({ className?: string }) { const { src, onError } = useImageFallback([url, fallbackUrl]) + const { ref: imgRef, ready, onLoad } = useImageReady(src) const showPicture = !!src + const loading = showPicture && !ready const [accent, setAccent] = useState(FALLBACK_ACCENT) useEffect(() => setAccent(FALLBACK_ACCENT), [src]) const lvl = typeof level === 'number' && Number.isFinite(level) ? Math.max(0, Math.min(1, level)) : null @@ -137,6 +159,7 @@ export function ProjectAvatar({ ['--hp-level' as string]: lvl ?? 0, }} data-kind={showPicture ? 'picture' : 'icon'} + aria-busy={loading || undefined} > {lvl !== null ? ( <> @@ -144,14 +167,18 @@ export function ProjectAvatar({