wirenote 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wirenote/__init__.py +3 -0
- wirenote/calendar/__init__.py +2 -0
- wirenote/calendar/ics.py +236 -0
- wirenote/capture/__init__.py +2 -0
- wirenote/capture/recorder.py +304 -0
- wirenote/capture/wav.py +58 -0
- wirenote/cli.py +262 -0
- wirenote/config/__init__.py +2 -0
- wirenote/config/apps.py +158 -0
- wirenote/config/migrate.py +130 -0
- wirenote/config/perms.py +61 -0
- wirenote/config/secrets.py +98 -0
- wirenote/config/settings.py +176 -0
- wirenote/config/vocabulary.py +32 -0
- wirenote/data/wirenote-ui.service +15 -0
- wirenote/data/wirenote.desktop +10 -0
- wirenote/data/wirenote.service +15 -0
- wirenote/detector/__init__.py +2 -0
- wirenote/detector/core.py +115 -0
- wirenote/detector/daemon.py +743 -0
- wirenote/detector/graph.py +102 -0
- wirenote/detector/identity.py +205 -0
- wirenote/detector/streams.py +69 -0
- wirenote/detector/tracker.py +304 -0
- wirenote/net/__init__.py +2 -0
- wirenote/net/peer.py +101 -0
- wirenote/notify/__init__.py +2 -0
- wirenote/notify/clipboard.py +43 -0
- wirenote/notify/dbus.py +89 -0
- wirenote/process/__init__.py +2 -0
- wirenote/process/jobs.py +122 -0
- wirenote/process/merge.py +87 -0
- wirenote/process/models.py +57 -0
- wirenote/process/note.py +124 -0
- wirenote/process/speakers.py +295 -0
- wirenote/process/summarize.py +287 -0
- wirenote/process/transcribe.py +97 -0
- wirenote/process/worker.py +229 -0
- wirenote/tools/__init__.py +2 -0
- wirenote/tools/dump_streams.py +595 -0
- wirenote/tools/install.py +140 -0
- wirenote/tools/setup.py +338 -0
- wirenote/ui/__init__.py +2 -0
- wirenote/ui/data.py +783 -0
- wirenote/ui/launch.py +88 -0
- wirenote/ui/server.py +423 -0
- wirenote/ui/static/app.css +907 -0
- wirenote/ui/static/app.js +1267 -0
- wirenote/ui/static/fonts/OFL.txt +93 -0
- wirenote/ui/static/fonts/inter-latin-ext.woff2 +0 -0
- wirenote/ui/static/fonts/inter-latin.woff2 +0 -0
- wirenote/ui/static/icon.svg +1 -0
- wirenote/ui/static/icons.svg +31 -0
- wirenote/ui/static/index.html +57 -0
- wirenote/ui/static/login.html +19 -0
- wirenote-0.1.0.dist-info/METADATA +106 -0
- wirenote-0.1.0.dist-info/RECORD +60 -0
- wirenote-0.1.0.dist-info/WHEEL +4 -0
- wirenote-0.1.0.dist-info/entry_points.txt +2 -0
- wirenote-0.1.0.dist-info/licenses/LICENSE +661 -0
wirenote/ui/data.py
ADDED
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""Read and change past calls for the UI (docs/dev/ui-design.md, docs/dev/features-2-design.md).
|
|
4
|
+
|
|
5
|
+
Everything comes from what the daemon and worker already write: each
|
|
6
|
+
recording's manifest.json, transcript.json and summary.json, the jobs
|
|
7
|
+
database and status.json. Nothing here touches a running recording, except
|
|
8
|
+
my_notes.md, which the person writes during the call.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import dataclasses
|
|
14
|
+
import datetime as dt
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from ..capture.recorder import read_manifest, write_manifest
|
|
27
|
+
from ..config.secrets import (calendar_sources, load_secrets, save_secrets, with_config_value,
|
|
28
|
+
write_config_atomic)
|
|
29
|
+
from ..config.settings import DEFAULT_CONFIG_TOML, Settings, config_dir, parse_settings
|
|
30
|
+
from ..config.vocabulary import load_vocabulary, save_vocabulary
|
|
31
|
+
from ..process.jobs import Jobs
|
|
32
|
+
from ..process.merge import Line, as_text, paragraphs, stamp
|
|
33
|
+
from ..process.note import action_line, own_note, segment_file, title_for
|
|
34
|
+
from ..process.speakers import Voices, display_name
|
|
35
|
+
from ..process.summarize import Summary, SummaryError, make_backend
|
|
36
|
+
from ..process.worker import MY_NOTES, SUMMARY, TRANSCRIPT, read_my_notes, rewrite_note
|
|
37
|
+
|
|
38
|
+
REC_ID = re.compile(r"^\d{8}-\d{6}-[A-Za-z0-9_.-]{1,64}$")
|
|
39
|
+
LABEL = re.compile(r"^(S\d{1,3}|Others)$")
|
|
40
|
+
MIX = "mix.ogg"
|
|
41
|
+
ASKS = "asks.json"
|
|
42
|
+
FOLLOWUP = "followup.json"
|
|
43
|
+
_WORD = re.compile(r"\w+", re.UNICODE)
|
|
44
|
+
# Ask: how much transcript a local model gets (its context is 8k tokens).
|
|
45
|
+
OLLAMA_ASK_CHARS = 18000
|
|
46
|
+
ALL_CALLS_CHARS_API = 120000
|
|
47
|
+
|
|
48
|
+
ASK_SYSTEM = """You answer questions about one call, using only its transcript.
|
|
49
|
+
"Me" is the person asking you; other speakers appear by name or as "Speaker N".
|
|
50
|
+
If the transcript does not contain the answer, say so plainly.
|
|
51
|
+
Answer briefly, in the language of the question.
|
|
52
|
+
citations: the [HH:MM:SS] timestamps of the transcript lines your answer rests on, \
|
|
53
|
+
at most 5, as "HH:MM:SS"."""
|
|
54
|
+
|
|
55
|
+
ASK_SCHEMA = {"type": "object",
|
|
56
|
+
"properties": {"answer": {"type": "string"},
|
|
57
|
+
"citations": {"type": "array", "items": {"type": "string"}}},
|
|
58
|
+
"required": ["answer", "citations"], "additionalProperties": False}
|
|
59
|
+
|
|
60
|
+
ASK_ALL_SYSTEM = """You answer questions about the person's past calls, using only the \
|
|
61
|
+
excerpts given. Each call has an id like C3; each excerpt line starts with [C3 HH:MM:SS].
|
|
62
|
+
"Me" is the person asking you; other speakers appear by name or as "Speaker N".
|
|
63
|
+
If the excerpts do not contain the answer, say so plainly. Mention which call (title, \
|
|
64
|
+
date) an answer comes from. Answer briefly, in the language of the question.
|
|
65
|
+
citations: the labels of the lines your answer rests on, at most 6, as "C3 HH:MM:SS"."""
|
|
66
|
+
|
|
67
|
+
FOLLOWUP_SYSTEM = """You write a short follow-up email after a call, from its notes.
|
|
68
|
+
Write it in the language of the notes, friendly and plain, as the person who \
|
|
69
|
+
recorded the call ("Me" in the notes means the writer, so write "I").
|
|
70
|
+
Structure: greeting; one or two sentences on what the call covered; decisions; \
|
|
71
|
+
action items with owner and due date where known; a short closing line such as "Best,".
|
|
72
|
+
Do not add a name or "Me" after the closing. Do not invent anything that is not in \
|
|
73
|
+
the notes, in particular no deadlines that are not written there.
|
|
74
|
+
subject: a short subject line."""
|
|
75
|
+
|
|
76
|
+
# Settings the UI may change; everything else stays
|
|
77
|
+
# in config.toml.
|
|
78
|
+
EDITABLE = ("consent_line", "summary_backend", "ollama_model", "anthropic_model",
|
|
79
|
+
"whisper_model", "whisper_language", "summary_language")
|
|
80
|
+
BACKENDS = ("ollama", "anthropic", "none")
|
|
81
|
+
MAX_SETTING = 500
|
|
82
|
+
CALENDAR_LINK = re.compile(r"^(https|webcal)://\S{1,2000}$", re.IGNORECASE)
|
|
83
|
+
|
|
84
|
+
CLOUD_DISCLOSURE = ("With Claude on, these go to Anthropic: the transcript of each call (in "
|
|
85
|
+
"parts), the calendar title and invitees, your notes, your vocabulary, "
|
|
86
|
+
"questions you ask with the transcript, and for Ask across meetings up to "
|
|
87
|
+
"120,000 characters from all your calls.")
|
|
88
|
+
|
|
89
|
+
FOLLOWUP_SCHEMA = {"type": "object",
|
|
90
|
+
"properties": {"subject": {"type": "string"}, "body": {"type": "string"}},
|
|
91
|
+
"required": ["subject", "body"], "additionalProperties": False}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class NotFound(Exception):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Invalid(Exception):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _read_json(path: Path, default=None):
|
|
103
|
+
try:
|
|
104
|
+
return json.loads(path.read_text())
|
|
105
|
+
except (OSError, ValueError):
|
|
106
|
+
return default
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _write_json(path: Path, data) -> None:
|
|
110
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
111
|
+
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
|
112
|
+
tmp.replace(path)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _words(text: str) -> set[str]:
|
|
116
|
+
return {w for w in _WORD.findall(text.lower()) if len(w) > 2}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _stems(text: str) -> set[str]:
|
|
120
|
+
"""Words cut to 5 letters, so German inflections (Release/Releases) still match."""
|
|
121
|
+
return {w[:5] for w in _words(text)}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def poke_daemon(command: str) -> None:
|
|
125
|
+
"""Tell the daemon something changed; it is fine if it is not running."""
|
|
126
|
+
from ..tools.setup import _poke_daemon
|
|
127
|
+
|
|
128
|
+
_poke_daemon(command)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _calendar_id(source: str) -> str:
|
|
132
|
+
return hashlib.sha256(source.encode()).hexdigest()[:12]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _secrets() -> dict:
|
|
136
|
+
try:
|
|
137
|
+
return load_secrets()
|
|
138
|
+
except ValueError as e:
|
|
139
|
+
raise Invalid("secrets.toml cannot be read; fix or delete it") from e
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _now() -> str:
|
|
143
|
+
return dt.datetime.now().astimezone().isoformat(timespec="seconds")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Store:
|
|
147
|
+
def __init__(self, settings: Settings, state_dir: Path, voices: Voices | None = None) -> None:
|
|
148
|
+
self.settings = settings
|
|
149
|
+
self.root = settings.recordings_path()
|
|
150
|
+
self.state_dir = state_dir
|
|
151
|
+
self.jobs = Jobs(state_dir / "jobs.db")
|
|
152
|
+
self.voices = voices or Voices()
|
|
153
|
+
self._jobs_lock = threading.Lock()
|
|
154
|
+
self._mix_locks: dict[str, threading.Lock] = {}
|
|
155
|
+
self._upcoming: tuple[float, list] = (0.0, [])
|
|
156
|
+
|
|
157
|
+
# Paths -----------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def rec_dir(self, rec_id: str) -> Path:
|
|
160
|
+
if not REC_ID.match(rec_id or ""):
|
|
161
|
+
raise NotFound(rec_id)
|
|
162
|
+
d = self.root / rec_id
|
|
163
|
+
if not (d / "manifest.json").is_file():
|
|
164
|
+
raise NotFound(rec_id)
|
|
165
|
+
return d
|
|
166
|
+
|
|
167
|
+
def _note(self, manifest: dict) -> Path | None:
|
|
168
|
+
return own_note(manifest, self.settings.notes_path())
|
|
169
|
+
|
|
170
|
+
def _rec_dirs(self) -> list[Path]:
|
|
171
|
+
if not self.root.is_dir():
|
|
172
|
+
return []
|
|
173
|
+
return [d for d in sorted(self.root.iterdir(), reverse=True)
|
|
174
|
+
if (d / "manifest.json").is_file()]
|
|
175
|
+
|
|
176
|
+
# Status ----------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def status(self) -> dict:
|
|
179
|
+
st = _read_json(self.state_dir / "status.json", {}) or {}
|
|
180
|
+
pid = st.get("pid")
|
|
181
|
+
st["alive"] = bool(st.get("running") and pid and Path(f"/proc/{pid}").exists())
|
|
182
|
+
return st
|
|
183
|
+
|
|
184
|
+
# Listing ---------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _job_state(self, d: Path) -> tuple[str | None, str | None]:
|
|
187
|
+
with self._jobs_lock:
|
|
188
|
+
job = self.jobs.by_recording(d)
|
|
189
|
+
if job is None:
|
|
190
|
+
return None, None
|
|
191
|
+
return job.state, (job.error.strip().splitlines()[-1] if job.error else None)
|
|
192
|
+
|
|
193
|
+
def _summary(self, d: Path) -> dict | None:
|
|
194
|
+
return _read_json(d / SUMMARY)
|
|
195
|
+
|
|
196
|
+
def _transcript(self, d: Path) -> dict:
|
|
197
|
+
return _read_json(d / TRANSCRIPT, {}) or {}
|
|
198
|
+
|
|
199
|
+
def _title(self, m: dict, summary: dict | None) -> str:
|
|
200
|
+
if m.get("title_override"):
|
|
201
|
+
return m["title_override"]
|
|
202
|
+
return title_for(m, Summary(title=(summary or {}).get("title", "")))
|
|
203
|
+
|
|
204
|
+
def _brief(self, d: Path, recording_id: str | None) -> dict:
|
|
205
|
+
m = read_manifest(d)
|
|
206
|
+
s = self._summary(d)
|
|
207
|
+
state, error = self._job_state(d)
|
|
208
|
+
if recording_id == m["id"]:
|
|
209
|
+
state = "recording"
|
|
210
|
+
segs = m.get("segments", [])
|
|
211
|
+
duration = max((x.get("end_offset_s") or 0.0 for x in segs), default=0.0)
|
|
212
|
+
actions = (s or {}).get("action_items", [])
|
|
213
|
+
cal = m.get("calendar") or {}
|
|
214
|
+
speakers = self._transcript(d).get("speakers", {})
|
|
215
|
+
return {"id": m["id"], "title": self._title(m, s), "started": m["started"],
|
|
216
|
+
"duration_s": round(duration), "app": m.get("app"), "name": m.get("name"),
|
|
217
|
+
"service": m.get("service"), "room": m.get("room"),
|
|
218
|
+
"calendar": cal.get("title"), "attendees": cal.get("attendees") or [],
|
|
219
|
+
"emails": cal.get("emails") or [],
|
|
220
|
+
"speakers": [display_name(l, speakers) for l in sorted(speakers)],
|
|
221
|
+
"state": state, "error": error, "has_summary": s is not None,
|
|
222
|
+
"has_transcript": (d / TRANSCRIPT).exists(),
|
|
223
|
+
"summary_error": m.get("summary_error"),
|
|
224
|
+
"summary_backend": m.get("summary_backend"),
|
|
225
|
+
"actions": len(actions),
|
|
226
|
+
"open_actions": sum(1 for a in actions if not a.get("done")),
|
|
227
|
+
"my_open_actions": sum(1 for a in actions
|
|
228
|
+
if not a.get("done") and a.get("owner") == "Me"),
|
|
229
|
+
"preview": ((s or {}).get("summary") or [""])[0]}
|
|
230
|
+
|
|
231
|
+
def calls(self, query: str = "") -> list[dict]:
|
|
232
|
+
recording_id = self.status().get("recording_id")
|
|
233
|
+
q = query.strip().lower()
|
|
234
|
+
out = []
|
|
235
|
+
for d in self._rec_dirs():
|
|
236
|
+
try:
|
|
237
|
+
brief = self._brief(d, recording_id)
|
|
238
|
+
except (OSError, ValueError, KeyError):
|
|
239
|
+
continue
|
|
240
|
+
if q:
|
|
241
|
+
hit = self._search(d, brief, q)
|
|
242
|
+
if hit is None:
|
|
243
|
+
continue
|
|
244
|
+
brief["match"] = hit
|
|
245
|
+
out.append(brief)
|
|
246
|
+
return out
|
|
247
|
+
|
|
248
|
+
def _search(self, d: Path, brief: dict, q: str) -> dict | None:
|
|
249
|
+
if q in brief["title"].lower():
|
|
250
|
+
return {"where": "title", "text": brief["title"]}
|
|
251
|
+
for name in brief["speakers"] + brief["attendees"]:
|
|
252
|
+
if q in name.lower():
|
|
253
|
+
return {"where": "people", "text": name}
|
|
254
|
+
s = self._summary(d) or {}
|
|
255
|
+
for key in ("summary", "decisions", "open_questions"):
|
|
256
|
+
for item in s.get(key, []):
|
|
257
|
+
if q in item.lower():
|
|
258
|
+
return {"where": key, "text": item}
|
|
259
|
+
for a in s.get("action_items", []):
|
|
260
|
+
if q in a.get("task", "").lower():
|
|
261
|
+
return {"where": "action_items", "text": a["task"]}
|
|
262
|
+
if q in read_my_notes(d).lower():
|
|
263
|
+
return {"where": "my_notes", "text": read_my_notes(d)[:140]}
|
|
264
|
+
for line in self._transcript(d).get("lines", []):
|
|
265
|
+
text = line.get("text", "")
|
|
266
|
+
i = text.lower().find(q)
|
|
267
|
+
if i >= 0:
|
|
268
|
+
lo, hi = max(0, i - 60), min(len(text), i + len(q) + 80)
|
|
269
|
+
snippet = ("…" if lo else "") + text[lo:hi] + ("…" if hi < len(text) else "")
|
|
270
|
+
return {"where": "transcript", "text": snippet, "start": line.get("start"),
|
|
271
|
+
"speaker": line.get("speaker")}
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
# One call --------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
def call(self, rec_id: str) -> dict:
|
|
277
|
+
d = self.rec_dir(rec_id)
|
|
278
|
+
brief = self._brief(d, self.status().get("recording_id"))
|
|
279
|
+
m = read_manifest(d)
|
|
280
|
+
tr = self._transcript(d)
|
|
281
|
+
speakers = tr.get("speakers", {})
|
|
282
|
+
note = self._note(m)
|
|
283
|
+
has_audio = any(d.glob("*.wav"))
|
|
284
|
+
lines = tr.get("lines", [])
|
|
285
|
+
talk: dict[str, float] = {}
|
|
286
|
+
for l in lines:
|
|
287
|
+
talk[l["speaker"]] = talk.get(l["speaker"], 0.0) + max(0.0, l["end"] - l["start"])
|
|
288
|
+
people = [{"label": label, "name": display_name(label, speakers),
|
|
289
|
+
"named": bool((speakers.get(label) or {}).get("name")),
|
|
290
|
+
"source": (speakers.get(label) or {}).get("source"),
|
|
291
|
+
"evidence": (speakers.get(label) or {}).get("evidence", ""),
|
|
292
|
+
"seconds": round(talk.get(label, 0.0)),
|
|
293
|
+
"can_remember": bool((speakers.get(label) or {}).get("embedding"))}
|
|
294
|
+
for label in sorted(talk, key=lambda l: (l != "Me", -talk[l]))]
|
|
295
|
+
return {**brief,
|
|
296
|
+
"summary": self._summary(d),
|
|
297
|
+
"lines": lines,
|
|
298
|
+
"people": people,
|
|
299
|
+
"names": {p["label"]: p["name"] for p in people},
|
|
300
|
+
"runs": tr.get("runs", []),
|
|
301
|
+
"echo_dropped": len(tr.get("dropped_echo", [])),
|
|
302
|
+
"transcript_model": tr.get("model"),
|
|
303
|
+
"note": str(note) if note and note.exists() else None,
|
|
304
|
+
"has_audio": has_audio,
|
|
305
|
+
"audio_deleted": m.get("audio_deleted"),
|
|
306
|
+
"end_reason": m.get("end_reason"),
|
|
307
|
+
"my_notes": read_my_notes(d),
|
|
308
|
+
"followup": _read_json(d / FOLLOWUP),
|
|
309
|
+
"asks": _read_json(d / ASKS, []) or [],
|
|
310
|
+
"known_voices": [v["name"] for v in self.voices.names()]}
|
|
311
|
+
|
|
312
|
+
def rename(self, rec_id: str, title: str) -> None:
|
|
313
|
+
d = self.rec_dir(rec_id)
|
|
314
|
+
title = title.strip()[:120]
|
|
315
|
+
m = read_manifest(d)
|
|
316
|
+
if title:
|
|
317
|
+
m["title_override"] = title
|
|
318
|
+
else:
|
|
319
|
+
m.pop("title_override", None)
|
|
320
|
+
write_manifest(d, m)
|
|
321
|
+
|
|
322
|
+
def set_action_done(self, rec_id: str, index: int, done: bool) -> None:
|
|
323
|
+
d = self.rec_dir(rec_id)
|
|
324
|
+
s = self._summary(d)
|
|
325
|
+
if not s or not 0 <= index < len(s.get("action_items", [])):
|
|
326
|
+
raise Invalid("no such action item")
|
|
327
|
+
item = s["action_items"][index]
|
|
328
|
+
item["done"] = bool(done)
|
|
329
|
+
_write_json(d / SUMMARY, s)
|
|
330
|
+
note = self._note(read_manifest(d))
|
|
331
|
+
if note and note.exists():
|
|
332
|
+
_tick_in_note(note, item, done)
|
|
333
|
+
|
|
334
|
+
def save_my_notes(self, rec_id: str, text: str) -> None:
|
|
335
|
+
d = self.rec_dir(rec_id)
|
|
336
|
+
text = text[:20000]
|
|
337
|
+
(d / MY_NOTES).write_text(text)
|
|
338
|
+
state, _ = self._job_state(d)
|
|
339
|
+
if state == "done" and read_manifest(d).get("note"):
|
|
340
|
+
rewrite_note(d, self.settings)
|
|
341
|
+
|
|
342
|
+
def rename_speaker(self, rec_id: str, label: str, name: str = "", remember: bool = False,
|
|
343
|
+
merge_into: str = "") -> None:
|
|
344
|
+
"""Name a speaker (and optionally remember the voice), or merge two that are one person."""
|
|
345
|
+
d = self.rec_dir(rec_id)
|
|
346
|
+
if not LABEL.match(label or ""):
|
|
347
|
+
raise Invalid("bad speaker")
|
|
348
|
+
tr = self._transcript(d)
|
|
349
|
+
if not tr:
|
|
350
|
+
raise Invalid("no transcript yet")
|
|
351
|
+
speakers = tr.setdefault("speakers", {})
|
|
352
|
+
lines = [Line(**l) for l in tr.get("lines", [])]
|
|
353
|
+
if merge_into:
|
|
354
|
+
if not LABEL.match(merge_into) or merge_into == label:
|
|
355
|
+
raise Invalid("bad merge target")
|
|
356
|
+
lines = paragraphs([Line(merge_into if l.speaker == label else l.speaker,
|
|
357
|
+
l.start, l.end, l.text) for l in lines],
|
|
358
|
+
self.settings.merge_gap_s)
|
|
359
|
+
gone = speakers.pop(label, None) or {}
|
|
360
|
+
target = speakers.setdefault(merge_into, {"name": None, "source": None})
|
|
361
|
+
target["seconds"] = round((target.get("seconds") or 0) + (gone.get("seconds") or 0), 1)
|
|
362
|
+
if not target.get("name") and gone.get("name"):
|
|
363
|
+
target.update(name=gone["name"], source=gone.get("source"))
|
|
364
|
+
tr["lines"] = [l.as_dict() for l in lines]
|
|
365
|
+
else:
|
|
366
|
+
name = name.strip()[:80]
|
|
367
|
+
info = speakers.setdefault(label, {})
|
|
368
|
+
for other, o in speakers.items():
|
|
369
|
+
if other != label and name and (o.get("name") or "").lower() == name.lower():
|
|
370
|
+
raise Invalid(f"{name} is already {display_name(other, speakers)}; "
|
|
371
|
+
"merge the two instead")
|
|
372
|
+
info.update(name=name or None, source="you" if name else None, evidence="")
|
|
373
|
+
if remember and name and info.get("embedding"):
|
|
374
|
+
self.voices.remember(name, info["embedding"])
|
|
375
|
+
_write_json(d / TRANSCRIPT, tr)
|
|
376
|
+
if read_manifest(d).get("note"):
|
|
377
|
+
rewrite_note(d, self.settings)
|
|
378
|
+
|
|
379
|
+
# Processing ------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
def requeue(self, rec_id: str, summary_only: bool) -> int:
|
|
382
|
+
d = self.rec_dir(rec_id)
|
|
383
|
+
if not summary_only and not any(d.glob("*.wav")):
|
|
384
|
+
raise Invalid("the audio was deleted; only the summary can be redone")
|
|
385
|
+
if summary_only and not (d / TRANSCRIPT).exists():
|
|
386
|
+
raise Invalid("no transcript yet")
|
|
387
|
+
with self._jobs_lock:
|
|
388
|
+
return self.jobs.add(d, summary_only=summary_only)
|
|
389
|
+
|
|
390
|
+
def _backend(self):
|
|
391
|
+
try:
|
|
392
|
+
backend = make_backend(self.settings)
|
|
393
|
+
except SummaryError as e:
|
|
394
|
+
raise Invalid(str(e)) from e
|
|
395
|
+
if backend is None:
|
|
396
|
+
raise Invalid('no AI backend (summary_backend = "none")')
|
|
397
|
+
return backend
|
|
398
|
+
|
|
399
|
+
def _complete(self, backend, system: str, user: str, schema: dict) -> dict:
|
|
400
|
+
try:
|
|
401
|
+
return backend.complete_json(system, user, schema)
|
|
402
|
+
except SummaryError as e:
|
|
403
|
+
raise Invalid(str(e)) from e
|
|
404
|
+
except (ValueError, KeyError) as e:
|
|
405
|
+
raise Invalid(f"{backend.name} gave an unusable answer: {e}") from e
|
|
406
|
+
|
|
407
|
+
def ask(self, rec_id: str, question: str) -> dict:
|
|
408
|
+
question = question.strip()
|
|
409
|
+
if not question:
|
|
410
|
+
raise Invalid("empty question")
|
|
411
|
+
d = self.rec_dir(rec_id)
|
|
412
|
+
tr = self._transcript(d)
|
|
413
|
+
lines = [Line(**l) for l in tr.get("lines", [])]
|
|
414
|
+
if not lines:
|
|
415
|
+
raise Invalid("no transcript yet")
|
|
416
|
+
backend = self._backend()
|
|
417
|
+
names = {l: display_name(l, tr.get("speakers", {})) for l in tr.get("speakers", {})}
|
|
418
|
+
limit = OLLAMA_ASK_CHARS if backend.name.startswith("ollama:") else None
|
|
419
|
+
excerpt, partial = _relevant(lines, question, limit, names)
|
|
420
|
+
head = "Transcript excerpts (most relevant parts):" if partial else "Transcript:"
|
|
421
|
+
data = self._complete(backend, ASK_SYSTEM, f"{head}\n{excerpt}\n\nQuestion: {question}",
|
|
422
|
+
ASK_SCHEMA)
|
|
423
|
+
cites = [c for c in data.get("citations", []) if re.match(r"^\d\d:\d\d:\d\d$", str(c))]
|
|
424
|
+
entry = {"t": _now(), "question": question, "answer": str(data.get("answer", "")).strip(),
|
|
425
|
+
"citations": [_seconds(c) for c in cites][:5], "backend": backend.name,
|
|
426
|
+
"partial": partial}
|
|
427
|
+
asks = _read_json(d / ASKS, []) or []
|
|
428
|
+
asks.append(entry)
|
|
429
|
+
_write_json(d / ASKS, asks[-50:])
|
|
430
|
+
return entry
|
|
431
|
+
|
|
432
|
+
def followup(self, rec_id: str) -> dict:
|
|
433
|
+
d = self.rec_dir(rec_id)
|
|
434
|
+
s = self._summary(d)
|
|
435
|
+
if not s:
|
|
436
|
+
raise Invalid("no summary yet; the follow-up is written from it")
|
|
437
|
+
m = read_manifest(d)
|
|
438
|
+
backend = self._backend()
|
|
439
|
+
notes = [f"Call: {self._title(m, s)} ({m['started'][:16].replace('T', ' ')})"]
|
|
440
|
+
cal = m.get("calendar") or {}
|
|
441
|
+
if cal.get("attendees"):
|
|
442
|
+
notes.append("Invited: " + ", ".join(cal["attendees"]))
|
|
443
|
+
notes.append("Summary:\n" + "\n".join(f"- {x}" for x in s.get("summary", [])))
|
|
444
|
+
if s.get("decisions"):
|
|
445
|
+
notes.append("Decisions:\n" + "\n".join(f"- {x}" for x in s["decisions"]))
|
|
446
|
+
if s.get("action_items"):
|
|
447
|
+
notes.append("Action items:\n" + "\n".join(
|
|
448
|
+
f"- {a['owner']}: {a['task']}"
|
|
449
|
+
+ (f" (due {a['due']})" if a.get("due") else " (no deadline given)")
|
|
450
|
+
for a in s["action_items"]))
|
|
451
|
+
if s.get("open_questions"):
|
|
452
|
+
notes.append("Open questions:\n" + "\n".join(f"- {x}" for x in s["open_questions"]))
|
|
453
|
+
if read_my_notes(d):
|
|
454
|
+
notes.append("My own notes:\n" + read_my_notes(d)[:3000])
|
|
455
|
+
data = self._complete(backend, FOLLOWUP_SYSTEM, "\n\n".join(notes), FOLLOWUP_SCHEMA)
|
|
456
|
+
body = re.sub(r"\n+\s*(Me|Ich|I)\s*$", "", str(data.get("body", "")).strip())
|
|
457
|
+
entry = {"t": _now(), "subject": str(data.get("subject", "")).strip(),
|
|
458
|
+
"body": body, "to": cal.get("emails") or [],
|
|
459
|
+
"backend": backend.name}
|
|
460
|
+
_write_json(d / FOLLOWUP, entry)
|
|
461
|
+
return entry
|
|
462
|
+
|
|
463
|
+
# Across calls ----------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
def action_items(self) -> list[dict]:
|
|
466
|
+
out = []
|
|
467
|
+
for d in self._rec_dirs():
|
|
468
|
+
s = self._summary(d)
|
|
469
|
+
if not s:
|
|
470
|
+
continue
|
|
471
|
+
m = read_manifest(d)
|
|
472
|
+
title = self._title(m, s)
|
|
473
|
+
for i, a in enumerate(s.get("action_items", [])):
|
|
474
|
+
out.append({"call": m["id"], "title": title, "started": m["started"], "index": i,
|
|
475
|
+
"owner": a.get("owner", ""), "task": a.get("task", ""),
|
|
476
|
+
"due": a.get("due", ""), "done": bool(a.get("done"))})
|
|
477
|
+
return out
|
|
478
|
+
|
|
479
|
+
def ask_all(self, question: str) -> dict:
|
|
480
|
+
question = question.strip()
|
|
481
|
+
if not question:
|
|
482
|
+
raise Invalid("empty question")
|
|
483
|
+
backend = self._backend()
|
|
484
|
+
budget = OLLAMA_ASK_CHARS if backend.name.startswith("ollama:") else ALL_CALLS_CHARS_API
|
|
485
|
+
q = _stems(question)
|
|
486
|
+
calls, scored = [], []
|
|
487
|
+
for d in self._rec_dirs():
|
|
488
|
+
tr = self._transcript(d)
|
|
489
|
+
if not tr.get("lines"):
|
|
490
|
+
continue
|
|
491
|
+
m = read_manifest(d)
|
|
492
|
+
s = self._summary(d)
|
|
493
|
+
idx = len(calls)
|
|
494
|
+
names = {l: display_name(l, tr.get("speakers", {})) for l in tr.get("speakers", {})}
|
|
495
|
+
lines = [Line(**l) for l in tr["lines"]]
|
|
496
|
+
calls.append({"id": m["id"], "title": self._title(m, s), "started": m["started"],
|
|
497
|
+
"lines": lines, "names": names})
|
|
498
|
+
topic = _stems(self._title(m, s) + " " + " ".join((s or {}).get("summary", [])))
|
|
499
|
+
bonus = 0.5 * len(q & topic)
|
|
500
|
+
for i, l in enumerate(lines):
|
|
501
|
+
hits = len(q & _stems(l.text))
|
|
502
|
+
if hits:
|
|
503
|
+
scored.append(((hits + bonus) / math.sqrt(len(_words(l.text)) + 4), idx, i))
|
|
504
|
+
if not calls:
|
|
505
|
+
raise Invalid("no transcripts yet")
|
|
506
|
+
scored.sort(reverse=True)
|
|
507
|
+
keep: dict[int, set[int]] = {}
|
|
508
|
+
size = 0
|
|
509
|
+
overview = "\n".join(f"C{i + 1} = {c['title']} ({c['started'][:16].replace('T', ' ')})"
|
|
510
|
+
for i, c in enumerate(calls[:60]))
|
|
511
|
+
size += len(overview)
|
|
512
|
+
for _, ci, li in scored:
|
|
513
|
+
for j in (li - 1, li, li + 1):
|
|
514
|
+
if 0 <= j < len(calls[ci]["lines"]) and j not in keep.get(ci, set()):
|
|
515
|
+
cost = len(calls[ci]["lines"][j].text) + 40
|
|
516
|
+
if size + cost > budget:
|
|
517
|
+
break
|
|
518
|
+
keep.setdefault(ci, set()).add(j)
|
|
519
|
+
size += cost
|
|
520
|
+
if size >= budget * 0.95:
|
|
521
|
+
break
|
|
522
|
+
parts = [f"Calls:\n{overview}"]
|
|
523
|
+
for ci in sorted(keep):
|
|
524
|
+
c = calls[ci]
|
|
525
|
+
body = "\n".join(
|
|
526
|
+
f"[C{ci + 1} {stamp(c['lines'][j].start)}] "
|
|
527
|
+
f"{c['names'].get(c['lines'][j].speaker, c['lines'][j].speaker)}: {c['lines'][j].text}"
|
|
528
|
+
for j in sorted(keep[ci]))
|
|
529
|
+
parts.append(f"C{ci + 1} {c['title']}:\n{body}")
|
|
530
|
+
if not keep:
|
|
531
|
+
parts.append("(No transcript line matched the question's words.)")
|
|
532
|
+
data = self._complete(backend, ASK_ALL_SYSTEM,
|
|
533
|
+
"\n\n".join(parts) + f"\n\nQuestion: {question}", ASK_SCHEMA)
|
|
534
|
+
cites = []
|
|
535
|
+
for c in data.get("citations", []):
|
|
536
|
+
mt = re.match(r"^C(\d+)\s+(\d\d:\d\d:\d\d)$", str(c).strip())
|
|
537
|
+
if mt and 1 <= int(mt.group(1)) <= len(calls):
|
|
538
|
+
call = calls[int(mt.group(1)) - 1]
|
|
539
|
+
cites.append({"call": call["id"], "title": call["title"],
|
|
540
|
+
"t": _seconds(mt.group(2))})
|
|
541
|
+
entry = {"t": _now(), "question": question, "answer": str(data.get("answer", "")).strip(),
|
|
542
|
+
"citations": cites[:6], "backend": backend.name,
|
|
543
|
+
"searched": len(calls), "used": len(keep)}
|
|
544
|
+
path = self.state_dir / ASKS
|
|
545
|
+
asks = _read_json(path, []) or []
|
|
546
|
+
asks.append(entry)
|
|
547
|
+
_write_json(path, asks[-100:])
|
|
548
|
+
return entry
|
|
549
|
+
|
|
550
|
+
def asks(self) -> list[dict]:
|
|
551
|
+
return _read_json(self.state_dir / ASKS, []) or []
|
|
552
|
+
|
|
553
|
+
# Settings --------------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
def settings_info(self) -> dict:
|
|
556
|
+
s = self.settings
|
|
557
|
+
try:
|
|
558
|
+
key_set = bool(load_secrets().get("anthropic_api_key"))
|
|
559
|
+
except ValueError:
|
|
560
|
+
key_set = False
|
|
561
|
+
return {"summary_backend": s.summary_backend,
|
|
562
|
+
"model": s.ollama_model if s.summary_backend == "ollama" else s.anthropic_model,
|
|
563
|
+
"whisper_model": s.whisper_model, "diarize": s.diarize,
|
|
564
|
+
"audio_retention_days": s.audio_retention_days,
|
|
565
|
+
"notes_dir": str(s.notes_path()),
|
|
566
|
+
"vocabulary": load_vocabulary(), "voices": self.voices.names(),
|
|
567
|
+
"calendars": self.calendars(),
|
|
568
|
+
"editable": {k: getattr(s, k) for k in EDITABLE},
|
|
569
|
+
"anthropic_key_set": key_set or bool(os.environ.get("ANTHROPIC_API_KEY")),
|
|
570
|
+
"cloud_disclosure": CLOUD_DISCLOSURE}
|
|
571
|
+
|
|
572
|
+
def update_settings(self, changes: dict, confirm_cloud: bool) -> dict:
|
|
573
|
+
"""Change some of EDITABLE in config.toml; the file is untouched unless all are valid."""
|
|
574
|
+
if not isinstance(changes, dict) or not changes:
|
|
575
|
+
raise Invalid("nothing to change")
|
|
576
|
+
unknown = sorted(set(changes) - set(EDITABLE))
|
|
577
|
+
if unknown:
|
|
578
|
+
raise Invalid(f"cannot change {', '.join(unknown)} here")
|
|
579
|
+
for k, v in changes.items():
|
|
580
|
+
if not isinstance(v, str) or len(v) > MAX_SETTING:
|
|
581
|
+
raise Invalid(f"{k}: expected text of at most {MAX_SETTING} characters")
|
|
582
|
+
if k != "consent_line" and not v.isprintable():
|
|
583
|
+
raise Invalid(f"{k}: one line of plain text")
|
|
584
|
+
backend = changes.get("summary_backend")
|
|
585
|
+
if backend is not None and backend not in BACKENDS:
|
|
586
|
+
raise Invalid(f"summary_backend: one of {', '.join(BACKENDS)}")
|
|
587
|
+
if backend == "anthropic" and self.settings.summary_backend != "anthropic" \
|
|
588
|
+
and not confirm_cloud:
|
|
589
|
+
raise Invalid("confirm what is sent to Claude first")
|
|
590
|
+
path = config_dir() / "config.toml"
|
|
591
|
+
text = path.read_text() if path.exists() else DEFAULT_CONFIG_TOML
|
|
592
|
+
for k, v in changes.items():
|
|
593
|
+
text = with_config_value(text, k, json.dumps(v, ensure_ascii=False))
|
|
594
|
+
try:
|
|
595
|
+
new = parse_settings(text)
|
|
596
|
+
except ValueError as e:
|
|
597
|
+
raise Invalid(f"config.toml would not be valid: {e}") from e
|
|
598
|
+
write_config_atomic(path, text)
|
|
599
|
+
self.settings = dataclasses.replace(self.settings,
|
|
600
|
+
**{k: getattr(new, k) for k in changes})
|
|
601
|
+
poke_daemon("reload-settings")
|
|
602
|
+
return self.settings_info()
|
|
603
|
+
|
|
604
|
+
def set_api_key(self, key: str) -> dict:
|
|
605
|
+
"""Store the Claude API key in secrets.toml; it is never sent back to the page."""
|
|
606
|
+
key = str(key).strip()
|
|
607
|
+
if not 20 <= len(key) <= 300 or not key.isascii() or not key.isprintable() \
|
|
608
|
+
or any(c.isspace() for c in key):
|
|
609
|
+
raise Invalid("that does not look like an API key")
|
|
610
|
+
secrets = _secrets()
|
|
611
|
+
secrets["anthropic_api_key"] = key
|
|
612
|
+
save_secrets(secrets)
|
|
613
|
+
return {"set": True}
|
|
614
|
+
|
|
615
|
+
def calendars(self) -> list[dict]:
|
|
616
|
+
"""Calendars by id and host or file name; links are secret and never shown."""
|
|
617
|
+
from ..calendar import ics
|
|
618
|
+
|
|
619
|
+
try:
|
|
620
|
+
links = set(load_secrets().get("calendar_urls", []))
|
|
621
|
+
except ValueError:
|
|
622
|
+
links = set()
|
|
623
|
+
return [{"id": _calendar_id(src), "label": ics.source_label(src),
|
|
624
|
+
"kind": "link" if src in links else "file"}
|
|
625
|
+
for src in calendar_sources(self.settings.calendar_urls)]
|
|
626
|
+
|
|
627
|
+
def add_calendar(self, url: str) -> list[dict]:
|
|
628
|
+
from ..calendar import ics
|
|
629
|
+
|
|
630
|
+
url = str(url).strip()
|
|
631
|
+
if not CALENDAR_LINK.match(url):
|
|
632
|
+
raise Invalid("paste the calendar's https:// or webcal:// ICS link")
|
|
633
|
+
try:
|
|
634
|
+
ics.parse(ics.fetch(url), dt.datetime.now().astimezone())
|
|
635
|
+
except Exception as e: # noqa: BLE001 - say what failed, without the link
|
|
636
|
+
raise Invalid(f"could not read that calendar: {ics.describe_error(url, e)}") from e
|
|
637
|
+
secrets = _secrets()
|
|
638
|
+
secrets["calendar_urls"] = list(dict.fromkeys([*secrets.get("calendar_urls", []), url]))
|
|
639
|
+
save_secrets(secrets)
|
|
640
|
+
self._upcoming = (0.0, [])
|
|
641
|
+
poke_daemon("refresh-calendar")
|
|
642
|
+
return self.calendars()
|
|
643
|
+
|
|
644
|
+
def remove_calendar(self, cal_id: str) -> list[dict]:
|
|
645
|
+
secrets = _secrets()
|
|
646
|
+
links = secrets.get("calendar_urls", [])
|
|
647
|
+
keep = [u for u in links if _calendar_id(u) != cal_id]
|
|
648
|
+
if len(keep) == len(links):
|
|
649
|
+
if any(_calendar_id(u) == cal_id for u in self.settings.calendar_urls):
|
|
650
|
+
raise Invalid("this calendar is set in config.toml; remove it there")
|
|
651
|
+
raise NotFound("calendar")
|
|
652
|
+
secrets["calendar_urls"] = keep
|
|
653
|
+
save_secrets(secrets)
|
|
654
|
+
self._upcoming = (0.0, [])
|
|
655
|
+
poke_daemon("refresh-calendar")
|
|
656
|
+
return self.calendars()
|
|
657
|
+
|
|
658
|
+
def doctor(self) -> dict:
|
|
659
|
+
from .. import __version__
|
|
660
|
+
from ..tools import setup
|
|
661
|
+
|
|
662
|
+
return {"checks": [dataclasses.asdict(c) for c in setup.checks(self.settings)],
|
|
663
|
+
"version": __version__}
|
|
664
|
+
|
|
665
|
+
def set_vocabulary(self, terms: list[str]) -> list[str]:
|
|
666
|
+
return save_vocabulary([str(t) for t in terms])
|
|
667
|
+
|
|
668
|
+
def forget_voice(self, name: str) -> None:
|
|
669
|
+
if not self.voices.forget(name):
|
|
670
|
+
raise NotFound(name)
|
|
671
|
+
|
|
672
|
+
# Files -----------------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
def mixed_audio(self, rec_id: str) -> Path:
|
|
675
|
+
"""Both tracks mixed into one Opus file, made on first use and cached."""
|
|
676
|
+
d = self.rec_dir(rec_id)
|
|
677
|
+
m = read_manifest(d)
|
|
678
|
+
segs = [s for s in m.get("segments", [])
|
|
679
|
+
if (f := segment_file(d, s)) and f.exists() and f.stat().st_size > 44]
|
|
680
|
+
if not segs:
|
|
681
|
+
raise NotFound("no audio")
|
|
682
|
+
out = d / MIX
|
|
683
|
+
lock = self._mix_locks.setdefault(rec_id, threading.Lock())
|
|
684
|
+
with lock:
|
|
685
|
+
newest = max((d / s["file"]).stat().st_mtime for s in segs)
|
|
686
|
+
if out.exists() and out.stat().st_mtime >= newest:
|
|
687
|
+
return out
|
|
688
|
+
cmd = ["ffmpeg", "-nostdin", "-v", "error", "-y"]
|
|
689
|
+
for s in segs:
|
|
690
|
+
cmd += ["-i", str(d / s["file"])]
|
|
691
|
+
chains = [f"[{i}:a]adelay={int(s['start_offset_s'] * 1000)}:all=1[a{i}]"
|
|
692
|
+
for i, s in enumerate(segs)]
|
|
693
|
+
mix = "".join(f"[a{i}]" for i in range(len(segs)))
|
|
694
|
+
graph = ";".join(chains) + f";{mix}amix=inputs={len(segs)}:normalize=0[out]"
|
|
695
|
+
tmp = d / (MIX + ".tmp.ogg")
|
|
696
|
+
cmd += ["-filter_complex", graph, "-map", "[out]", "-ac", "1",
|
|
697
|
+
"-c:a", "libopus", "-b:a", "32k", str(tmp)]
|
|
698
|
+
subprocess.run(cmd, check=True, timeout=600)
|
|
699
|
+
tmp.replace(out)
|
|
700
|
+
return out
|
|
701
|
+
|
|
702
|
+
def delete_audio(self, rec_id: str) -> None:
|
|
703
|
+
d = self.rec_dir(rec_id)
|
|
704
|
+
if self.status().get("recording_id") == rec_id:
|
|
705
|
+
raise Invalid("still recording")
|
|
706
|
+
for f in [*d.glob("*.wav"), d / MIX]:
|
|
707
|
+
f.unlink(missing_ok=True)
|
|
708
|
+
m = read_manifest(d)
|
|
709
|
+
m["audio_deleted"] = _now()
|
|
710
|
+
write_manifest(d, m)
|
|
711
|
+
|
|
712
|
+
def delete_call(self, rec_id: str) -> None:
|
|
713
|
+
d = self.rec_dir(rec_id)
|
|
714
|
+
if self.status().get("recording_id") == rec_id:
|
|
715
|
+
raise Invalid("still recording")
|
|
716
|
+
state, _ = self._job_state(d)
|
|
717
|
+
if state in ("transcribing", "summarizing"):
|
|
718
|
+
raise Invalid("a note is being written for this call; try again when it is done")
|
|
719
|
+
note = self._note(read_manifest(d))
|
|
720
|
+
if note:
|
|
721
|
+
note.unlink(missing_ok=True)
|
|
722
|
+
shutil.rmtree(d)
|
|
723
|
+
with self._jobs_lock:
|
|
724
|
+
self.jobs.delete(d)
|
|
725
|
+
|
|
726
|
+
def note_path(self, rec_id: str) -> Path:
|
|
727
|
+
note = self._note(read_manifest(self.rec_dir(rec_id)))
|
|
728
|
+
if not note or not note.exists():
|
|
729
|
+
raise NotFound("no note")
|
|
730
|
+
return note
|
|
731
|
+
|
|
732
|
+
# Calendar --------------------------------------------------------------
|
|
733
|
+
|
|
734
|
+
def upcoming(self) -> list[dict]:
|
|
735
|
+
from ..calendar import ics
|
|
736
|
+
|
|
737
|
+
fetched, events = self._upcoming
|
|
738
|
+
if not fetched or time.monotonic() - fetched >= self.settings.calendar_refresh_s:
|
|
739
|
+
sources = calendar_sources(self.settings.calendar_urls)
|
|
740
|
+
events = ics.load(sources, dt.datetime.now().astimezone())[0] if sources else []
|
|
741
|
+
self._upcoming = (time.monotonic(), events)
|
|
742
|
+
now = dt.datetime.now().astimezone()
|
|
743
|
+
soon = [e for e in events if e.end > now and e.start < now + dt.timedelta(hours=18)]
|
|
744
|
+
return [{"title": e.title, "start": e.start.isoformat(), "end": e.end.isoformat(),
|
|
745
|
+
"attendees": list(e.attendees), "call": bool(e.service_links(None))}
|
|
746
|
+
for e in sorted(soon, key=lambda e: e.start)[:8]]
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _seconds(hhmmss: str) -> float:
|
|
750
|
+
h, m, s = (int(x) for x in hhmmss.split(":"))
|
|
751
|
+
return float(h * 3600 + m * 60 + s)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _relevant(lines: list[Line], question: str, limit: int | None,
|
|
755
|
+
names: dict[str, str] | None = None) -> tuple[str, bool]:
|
|
756
|
+
"""The whole transcript if it fits, else the paragraphs that share the most
|
|
757
|
+
words with the question (with their neighbours), in time order."""
|
|
758
|
+
full = as_text(lines, names)
|
|
759
|
+
if limit is None or len(full) <= limit:
|
|
760
|
+
return full, False
|
|
761
|
+
q = _stems(question)
|
|
762
|
+
ranked = sorted(range(len(lines)), key=lambda i: -len(q & _stems(lines[i].text)))
|
|
763
|
+
keep: set[int] = set()
|
|
764
|
+
size = 0
|
|
765
|
+
for i in ranked:
|
|
766
|
+
for j in (i - 1, i, i + 1):
|
|
767
|
+
if 0 <= j < len(lines) and j not in keep:
|
|
768
|
+
cost = len(lines[j].text) + 30
|
|
769
|
+
if size + cost > limit:
|
|
770
|
+
break
|
|
771
|
+
keep.add(j)
|
|
772
|
+
size += cost
|
|
773
|
+
if size >= limit * 0.95:
|
|
774
|
+
break
|
|
775
|
+
return as_text([lines[i] for i in sorted(keep)], names), True
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _tick_in_note(note: Path, item: dict, done: bool) -> None:
|
|
779
|
+
args = (item["owner"], item["task"], item.get("due", ""))
|
|
780
|
+
old, new = action_line(*args, not done), action_line(*args, done)
|
|
781
|
+
text = note.read_text()
|
|
782
|
+
if old in text:
|
|
783
|
+
note.write_text(text.replace(old, new, 1))
|