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.
Files changed (60) hide show
  1. wirenote/__init__.py +3 -0
  2. wirenote/calendar/__init__.py +2 -0
  3. wirenote/calendar/ics.py +236 -0
  4. wirenote/capture/__init__.py +2 -0
  5. wirenote/capture/recorder.py +304 -0
  6. wirenote/capture/wav.py +58 -0
  7. wirenote/cli.py +262 -0
  8. wirenote/config/__init__.py +2 -0
  9. wirenote/config/apps.py +158 -0
  10. wirenote/config/migrate.py +130 -0
  11. wirenote/config/perms.py +61 -0
  12. wirenote/config/secrets.py +98 -0
  13. wirenote/config/settings.py +176 -0
  14. wirenote/config/vocabulary.py +32 -0
  15. wirenote/data/wirenote-ui.service +15 -0
  16. wirenote/data/wirenote.desktop +10 -0
  17. wirenote/data/wirenote.service +15 -0
  18. wirenote/detector/__init__.py +2 -0
  19. wirenote/detector/core.py +115 -0
  20. wirenote/detector/daemon.py +743 -0
  21. wirenote/detector/graph.py +102 -0
  22. wirenote/detector/identity.py +205 -0
  23. wirenote/detector/streams.py +69 -0
  24. wirenote/detector/tracker.py +304 -0
  25. wirenote/net/__init__.py +2 -0
  26. wirenote/net/peer.py +101 -0
  27. wirenote/notify/__init__.py +2 -0
  28. wirenote/notify/clipboard.py +43 -0
  29. wirenote/notify/dbus.py +89 -0
  30. wirenote/process/__init__.py +2 -0
  31. wirenote/process/jobs.py +122 -0
  32. wirenote/process/merge.py +87 -0
  33. wirenote/process/models.py +57 -0
  34. wirenote/process/note.py +124 -0
  35. wirenote/process/speakers.py +295 -0
  36. wirenote/process/summarize.py +287 -0
  37. wirenote/process/transcribe.py +97 -0
  38. wirenote/process/worker.py +229 -0
  39. wirenote/tools/__init__.py +2 -0
  40. wirenote/tools/dump_streams.py +595 -0
  41. wirenote/tools/install.py +140 -0
  42. wirenote/tools/setup.py +338 -0
  43. wirenote/ui/__init__.py +2 -0
  44. wirenote/ui/data.py +783 -0
  45. wirenote/ui/launch.py +88 -0
  46. wirenote/ui/server.py +423 -0
  47. wirenote/ui/static/app.css +907 -0
  48. wirenote/ui/static/app.js +1267 -0
  49. wirenote/ui/static/fonts/OFL.txt +93 -0
  50. wirenote/ui/static/fonts/inter-latin-ext.woff2 +0 -0
  51. wirenote/ui/static/fonts/inter-latin.woff2 +0 -0
  52. wirenote/ui/static/icon.svg +1 -0
  53. wirenote/ui/static/icons.svg +31 -0
  54. wirenote/ui/static/index.html +57 -0
  55. wirenote/ui/static/login.html +19 -0
  56. wirenote-0.1.0.dist-info/METADATA +106 -0
  57. wirenote-0.1.0.dist-info/RECORD +60 -0
  58. wirenote-0.1.0.dist-info/WHEEL +4 -0
  59. wirenote-0.1.0.dist-info/entry_points.txt +2 -0
  60. wirenote-0.1.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,295 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """Who spoke when on the Others track, and who they are (docs/dev/features-2-design.md › Speakers).
4
+
5
+ - Diarization: sherpa-onnx, pyannote segmentation 3.0 plus a speaker-embedding
6
+ model, CPU only. Speakers are labelled S1, S2, ... in order of first speech.
7
+ - Whisper's word timestamps are split at speaker changes, so one line never
8
+ mixes two people.
9
+ - Names, in this order: a remembered voice (voices.json, taught by renaming a
10
+ speaker in the app), then the summary backend's reading of the transcript
11
+ ("Thanks, Anna") checked against the calendar attendees, else "Speaker N".
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import datetime as dt
17
+ import json
18
+ import os
19
+ import re
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ import numpy as np
24
+
25
+ from ..config.settings import Settings, data_dir
26
+ from .models import EMB_REPO, SEG_FILE, SEG_REPO, hub_file # noqa: F401 - re-exported
27
+
28
+ EMBED_SECONDS = 30.0 # speech per speaker used for its voice print
29
+ MIN_TURN_S = 0.8 # shorter turns are too short for a reliable voice print
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Turn:
34
+ start: float
35
+ end: float
36
+ label: int
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Word:
41
+ start: float
42
+ end: float
43
+ text: str
44
+
45
+
46
+ def label_name(label: str) -> str:
47
+ """S3 -> Speaker 3; Me and legacy Others stay."""
48
+ m = re.fullmatch(r"S(\d+)", label)
49
+ return f"Speaker {m.group(1)}" if m else label
50
+
51
+
52
+ def display_name(label: str, speakers: dict) -> str:
53
+ info = speakers.get(label) or {}
54
+ return info.get("name") or label_name(label)
55
+
56
+
57
+ def _unit(v) -> np.ndarray:
58
+ v = np.asarray(v, dtype=np.float32)
59
+ n = float(np.linalg.norm(v))
60
+ return v / n if n else v
61
+
62
+
63
+ class Diarizer:
64
+ def __init__(self, settings: Settings) -> None:
65
+ import sherpa_onnx
66
+
67
+ seg = hub_file(SEG_REPO, SEG_FILE)
68
+ emb = hub_file(EMB_REPO, settings.diarize_model)
69
+ cfg = sherpa_onnx.OfflineSpeakerDiarizationConfig(
70
+ segmentation=sherpa_onnx.OfflineSpeakerSegmentationModelConfig(
71
+ pyannote=sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig(model=seg)),
72
+ embedding=sherpa_onnx.SpeakerEmbeddingExtractorConfig(model=emb),
73
+ clustering=sherpa_onnx.FastClusteringConfig(num_clusters=-1,
74
+ threshold=settings.diarize_threshold),
75
+ min_duration_on=0.3, min_duration_off=0.5)
76
+ if not cfg.validate():
77
+ raise RuntimeError("speaker separation models are incomplete")
78
+ self.sd = sherpa_onnx.OfflineSpeakerDiarization(cfg)
79
+ self.extractor = sherpa_onnx.SpeakerEmbeddingExtractor(
80
+ sherpa_onnx.SpeakerEmbeddingExtractorConfig(model=emb))
81
+
82
+ def run(self, audio: np.ndarray, rate: int = 16000) -> tuple[list[Turn], dict[int, np.ndarray]]:
83
+ """Speaker turns of one file, plus one voice print per speaker."""
84
+ turns = [Turn(s.start, s.end, s.speaker) for s in self.sd.process(audio).sort_by_start_time()]
85
+ prints = {}
86
+ for label in {t.label for t in turns}:
87
+ mine = sorted((t for t in turns if t.label == label), key=lambda t: t.start - t.end)
88
+ chunks, total = [], 0.0
89
+ for t in mine:
90
+ if t.end - t.start < MIN_TURN_S and chunks:
91
+ continue
92
+ chunks.append(audio[int(t.start * rate):int(t.end * rate)])
93
+ total += t.end - t.start
94
+ if total >= EMBED_SECONDS:
95
+ break
96
+ stream = self.extractor.create_stream()
97
+ stream.accept_waveform(rate, np.concatenate(chunks))
98
+ stream.input_finished()
99
+ prints[label] = _unit(self.extractor.compute(stream))
100
+ return turns, prints
101
+
102
+
103
+ def split_by_speaker(words: list[Word], turns: list[Turn]) -> list[tuple[int | None, list[Word]]]:
104
+ """Group consecutive words by the speaker whose turn holds each word's middle
105
+ (the nearest turn when none does)."""
106
+ runs: list[tuple[int | None, list[Word]]] = []
107
+ for w in words:
108
+ mid = (w.start + w.end) / 2
109
+ inside = [t for t in turns if t.start <= mid <= t.end]
110
+ if inside:
111
+ label = min(inside, key=lambda t: t.end - t.start).label
112
+ elif turns:
113
+ label = min(turns, key=lambda t: min(abs(t.start - mid), abs(t.end - mid))).label
114
+ else:
115
+ label = None
116
+ if runs and runs[-1][0] == label:
117
+ runs[-1][1].append(w)
118
+ else:
119
+ runs.append((label, [w]))
120
+ return runs
121
+
122
+
123
+ SENTENCE_END = (".", "?", "!", "…", "。", "?", "!")
124
+ MAX_PAUSE_S = 1.0 # a longer pause ends a sentence even without punctuation
125
+ SPLIT_BELOW_SHARE = 0.6 # a sentence is split only if no speaker has this share of it
126
+ MIN_SPLIT_WORDS = 6 # shorter sentences are never split
127
+
128
+
129
+ def sentences(words: list[Word]) -> list[list[Word]]:
130
+ """Words grouped into sentences: at end punctuation or a pause. Whisper's own
131
+ segments are not used, because a vocabulary prompt turns them into one block."""
132
+ out: list[list[Word]] = []
133
+ for w in words:
134
+ if out and not out[-1][-1].text.strip().endswith(SENTENCE_END) \
135
+ and w.start - out[-1][-1].end <= MAX_PAUSE_S:
136
+ out[-1].append(w)
137
+ else:
138
+ out.append([w])
139
+ return out
140
+
141
+
142
+ def _overlap(w: Word, t: Turn) -> float:
143
+ return max(0.0, min(w.end, t.end) - max(w.start, t.start))
144
+
145
+
146
+ def assign_sentence(words: list[Word], turns: list[Turn]) -> list[tuple[int | None, list[Word]]]:
147
+ """A sentence goes to the speaker who covers most of it. Only a long sentence
148
+ that really spans two people is split; 1-word runs then rejoin a neighbour."""
149
+ share: dict[int, float] = {}
150
+ for w in words:
151
+ for t in turns:
152
+ o = _overlap(w, t)
153
+ if o:
154
+ share[t.label] = share.get(t.label, 0.0) + o
155
+ if not share: # between turns: the nearest speaker takes the whole sentence
156
+ runs = split_by_speaker(words, turns)
157
+ return [(runs[0][0] if runs else None, words)]
158
+ best = max(share, key=share.get)
159
+ if len(words) < MIN_SPLIT_WORDS or share[best] / sum(share.values()) >= SPLIT_BELOW_SHARE:
160
+ return [(best, words)]
161
+ runs = split_by_speaker(words, turns)
162
+ merged: list[tuple[int | None, list[Word]]] = []
163
+ for label, ws in runs:
164
+ if merged and (len(ws) < 2 or merged[-1][0] == label):
165
+ merged[-1] = (merged[-1][0], merged[-1][1] + ws)
166
+ else:
167
+ merged.append((label, list(ws)))
168
+ if len(merged) > 1 and len(merged[0][1]) < 2:
169
+ merged[1] = (merged[1][0], merged[0][1] + merged[1][1])
170
+ merged.pop(0)
171
+ return merged
172
+
173
+
174
+ class SpeakerBook:
175
+ """Speakers of one recording across its segment files, labelled S1, S2, ..."""
176
+
177
+ def __init__(self, same_voice: float) -> None:
178
+ self.same_voice = same_voice
179
+ self.prints: dict[str, np.ndarray] = {}
180
+ self.seconds: dict[str, float] = {}
181
+
182
+ def label_for(self, voice: np.ndarray) -> str:
183
+ best, score = None, -1.0
184
+ for label, v in self.prints.items():
185
+ s = float(v @ voice)
186
+ if s > score:
187
+ best, score = label, s
188
+ if best is not None and score >= self.same_voice:
189
+ return best
190
+ label = f"S{len(self.prints) + 1}"
191
+ self.prints[label] = voice
192
+ return label
193
+
194
+ def add_time(self, label: str, seconds: float) -> None:
195
+ self.seconds[label] = self.seconds.get(label, 0.0) + seconds
196
+
197
+
198
+ # Remembered voices ---------------------------------------------------------
199
+
200
+ def voices_path() -> Path:
201
+ return data_dir() / "voices.json"
202
+
203
+
204
+ class Voices:
205
+ """Voice prints the user named in the app. Stay on this machine."""
206
+
207
+ def __init__(self, path: Path | None = None) -> None:
208
+ self.path = path or voices_path()
209
+ try:
210
+ self.data: dict = json.loads(self.path.read_text())
211
+ except (OSError, ValueError):
212
+ self.data = {}
213
+
214
+ def names(self) -> list[dict]:
215
+ return [{"name": n, "samples": v.get("count", 1), "updated": v.get("updated")}
216
+ for n, v in sorted(self.data.items())]
217
+
218
+ def match(self, voice, threshold: float) -> tuple[str | None, float]:
219
+ best, score = None, -1.0
220
+ for name, v in self.data.items():
221
+ s = float(_unit(v["embedding"]) @ _unit(voice))
222
+ if s > score:
223
+ best, score = name, s
224
+ return (best, score) if score >= threshold else (None, score)
225
+
226
+ def remember(self, name: str, voice) -> None:
227
+ old = self.data.get(name)
228
+ if old:
229
+ n = old.get("count", 1)
230
+ merged = _unit((_unit(old["embedding"]) * n + _unit(voice)) / (n + 1))
231
+ count = n + 1
232
+ else:
233
+ merged, count = _unit(voice), 1
234
+ self.data[name] = {"embedding": [round(float(x), 6) for x in merged], "count": count,
235
+ "updated": dt.datetime.now().astimezone().isoformat(timespec="seconds")}
236
+ self._save()
237
+
238
+ def forget(self, name: str) -> bool:
239
+ if self.data.pop(name, None) is None:
240
+ return False
241
+ self._save()
242
+ return True
243
+
244
+ def _save(self) -> None:
245
+ self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
246
+ tmp = self.path.with_suffix(".tmp")
247
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
248
+ with os.fdopen(fd, "w") as f:
249
+ json.dump(self.data, f)
250
+ tmp.replace(self.path)
251
+
252
+
253
+ # Names from the transcript ---------------------------------------------------
254
+
255
+ NAME_SYSTEM = """You identify the speakers of a call transcript.
256
+ Lines are labelled "Me" (the person who recorded) or S1, S2, ... (other people).
257
+ For each S label, give the person's name only if the transcript makes it clear: \
258
+ they introduce themselves, someone addresses them by name right before they answer, \
259
+ or they are greeted by name. Otherwise give "".
260
+ {attendees}evidence: the short quote that shows it, or "".
261
+ Never give the same name to two labels. Never guess from voice or topic."""
262
+
263
+ NAME_SCHEMA = {"type": "object", "properties": {"speakers": {"type": "array", "items": {
264
+ "type": "object",
265
+ "properties": {"label": {"type": "string"}, "name": {"type": "string"},
266
+ "evidence": {"type": "string"}},
267
+ "required": ["label", "name", "evidence"], "additionalProperties": False}}},
268
+ "required": ["speakers"], "additionalProperties": False}
269
+
270
+
271
+ def guess_names(text: str, labels: list[str], attendees: list[str], backend) -> dict[str, dict]:
272
+ """{label: {"name", "evidence"}} for labels the transcript names, checked:
273
+ the name must occur in the transcript (or be an invited attendee whose first
274
+ name occurs there)."""
275
+ if not labels:
276
+ return {}
277
+ who = (f"People invited to the call: {', '.join(attendees)}. Prefer these spellings.\n"
278
+ if attendees else "")
279
+ data = backend.complete_json(NAME_SYSTEM.format(attendees=who),
280
+ f"Labels to name: {', '.join(labels)}\n\nTranscript:\n{text}",
281
+ NAME_SCHEMA)
282
+ low = text.lower()
283
+ out: dict[str, dict] = {}
284
+ used: set[str] = set()
285
+ for item in data.get("speakers", []):
286
+ label, name = str(item.get("label", "")), str(item.get("name", "")).strip()
287
+ if label not in labels or not name or name.lower() in used:
288
+ continue
289
+ first = name.split()[0].lower()
290
+ if not re.search(rf"\b{re.escape(first)}\b", low):
291
+ continue # not supported by the transcript
292
+ full = next((a for a in attendees if a.lower().split()[0] == first), name)
293
+ out[label] = {"name": full, "evidence": str(item.get("evidence", ""))[:200]}
294
+ used.add(full.lower())
295
+ return out
@@ -0,0 +1,287 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """Meeting summary through a pluggable backend (docs/dev/PLAN.md › Processing, step 3).
4
+
5
+ Backends: Ollama on this machine (the default, nothing leaves the laptop) or
6
+ the Claude API. Both return JSON for one schema: title, summary points,
7
+ decisions, action items with owners, open questions. Transcripts longer than
8
+ summary_chunk_s are summarized per chunk, then the chunk notes are combined.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ipaddress
14
+ import json
15
+ import socket
16
+ import urllib.error
17
+ import urllib.request
18
+ from urllib.parse import urlsplit, urlunsplit
19
+ from dataclasses import asdict, dataclass, field
20
+ from typing import Protocol
21
+
22
+ from ..config.secrets import load_secrets, secrets_path
23
+ from ..config.settings import Settings
24
+ from ..net.peer import listener_uid, trusted_owner
25
+ from .merge import Line, as_text
26
+
27
+ _LIST = {"type": "array", "items": {"type": "string"}}
28
+ _ACTIONS = {"type": "array", "items": {
29
+ "type": "object",
30
+ "properties": {"owner": {"type": "string"}, "task": {"type": "string"},
31
+ "due": {"type": "string"}},
32
+ "required": ["owner", "task", "due"], "additionalProperties": False}}
33
+ _NOTES = {"summary": _LIST, "decisions": _LIST, "action_items": _ACTIONS,
34
+ "open_questions": _LIST}
35
+
36
+ CHUNK_SCHEMA = {"type": "object", "properties": _NOTES, "required": list(_NOTES),
37
+ "additionalProperties": False}
38
+ FINAL_SCHEMA = {"type": "object", "properties": {"title": {"type": "string"}, **_NOTES},
39
+ "required": ["title", *_NOTES], "additionalProperties": False}
40
+
41
+ SYSTEM = """You write meeting notes from a call transcript.
42
+ Speakers: "Me" is the person who recorded the call. Other people appear by name, \
43
+ as "Speaker N" when their name is unknown, or as "Others" when several people share \
44
+ one label. Use names as owners where you know them.
45
+ The transcript comes from speech recognition, so expect some misheard words; \
46
+ infer the meaning, do not quote errors.
47
+ Only state what the transcript supports. Do not invent names, dates or decisions.
48
+ Write every field, including the title, in {language}.
49
+
50
+ Fields:
51
+ - summary: 3 to 8 short bullet points covering the main topics and outcomes.
52
+ - decisions: things the participants agreed on. Empty if none.
53
+ - action_items: every task someone said they or another person will do, \
54
+ including tasks "Me" took on. One entry per task. owner is "Me", the name \
55
+ mentioned for it, or "Others" when unclear. due is the deadline said for that \
56
+ task, or "" if none was said for it; do not reuse another task's date.
57
+ - open_questions: questions raised but not resolved. Empty if none."""
58
+
59
+ TITLE_FIELD = "\n- title: a short title for the meeting, at most 8 words."
60
+
61
+ COMBINE = """These are notes for consecutive parts of one call, in order. Merge them \
62
+ into notes for the whole call: remove repeats, keep every distinct decision, \
63
+ action item and open question, and keep the summary to at most 8 points."""
64
+
65
+
66
+ class SummaryError(Exception):
67
+ pass
68
+
69
+
70
+ @dataclass
71
+ class ActionItem:
72
+ owner: str
73
+ task: str
74
+ due: str = ""
75
+
76
+
77
+ @dataclass
78
+ class Summary:
79
+ title: str = ""
80
+ summary: list[str] = field(default_factory=list)
81
+ decisions: list[str] = field(default_factory=list)
82
+ action_items: list[ActionItem] = field(default_factory=list)
83
+ open_questions: list[str] = field(default_factory=list)
84
+
85
+ def as_dict(self) -> dict:
86
+ return asdict(self)
87
+
88
+
89
+ class Backend(Protocol):
90
+ name: str
91
+
92
+ def complete_json(self, system: str, user: str, schema: dict) -> dict: ...
93
+
94
+
95
+ # Backends --------------------------------------------------------------------
96
+
97
+ def pin_local(url: str) -> tuple[str, str, int] | None:
98
+ """For a URL on this machine: (the URL with its host replaced by the numeric
99
+ loopback address, that address, the port); None for another machine.
100
+
101
+ The owner check and the request must use one address: "localhost" may
102
+ resolve to ::1, where someone else can listen while Ollama has 127.0.0.1."""
103
+ u = urlsplit(url)
104
+ host = u.hostname or ""
105
+ port = u.port or (443 if u.scheme == "https" else 80)
106
+ if host == "localhost":
107
+ ip = "127.0.0.1"
108
+ else:
109
+ try:
110
+ ip = str(ipaddress.ip_address(host))
111
+ except ValueError:
112
+ try:
113
+ ip = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)[0][4][0]
114
+ except (OSError, IndexError):
115
+ return None
116
+ if not ipaddress.ip_address(ip).is_loopback:
117
+ return None
118
+ netloc = f"[{ip}]:{port}" if ":" in ip else f"{ip}:{port}"
119
+ return urlunsplit((u.scheme, netloc, u.path, u.query, u.fragment)).rstrip("/"), ip, port
120
+
121
+
122
+ class OllamaBackend:
123
+ """Ollama's /api/chat with a JSON schema as the format."""
124
+
125
+ def __init__(self, url: str, model: str, timeout_s: float = 900.0) -> None:
126
+ self.url = url.rstrip("/")
127
+ self.local = pin_local(self.url)
128
+ self.model = model
129
+ self.timeout_s = timeout_s
130
+ self.name = f"ollama:{model}"
131
+
132
+ def complete_json(self, system: str, user: str, schema: dict) -> dict:
133
+ # Other accounts can listen on loopback too: never hand a transcript to
134
+ # their server. Nothing listening is fine; the request fails below.
135
+ url = self.url
136
+ if self.local:
137
+ url, ip, port = self.local
138
+ owner = listener_uid(port, ip)
139
+ if owner is not None and not trusted_owner(owner):
140
+ raise SummaryError(f"Ollama on port {port} belongs to another user; "
141
+ "not sending the transcript")
142
+ body = {"model": self.model, "stream": False, "format": schema, "think": False,
143
+ "messages": [{"role": "system", "content": system},
144
+ {"role": "user", "content": user}],
145
+ # 8k tokens holds a 15 min chunk; the model unloads soon after, so
146
+ # its RAM is free again for the next call or transcription.
147
+ "keep_alive": "60s", "options": {"num_ctx": 8192, "temperature": 0.2}}
148
+ req = urllib.request.Request(f"{url}/api/chat", data=json.dumps(body).encode(),
149
+ headers={"Content-Type": "application/json"})
150
+ try:
151
+ with urllib.request.urlopen(req, timeout=self.timeout_s) as resp:
152
+ data = json.loads(resp.read())
153
+ except urllib.error.HTTPError as e:
154
+ detail = e.read().decode("utf-8", "replace")[:300]
155
+ raise SummaryError(f"Ollama returned {e.code}: {detail}") from e
156
+ except (urllib.error.URLError, OSError) as e:
157
+ raise SummaryError(f"Ollama not reachable at {self.url} ({e}); is it installed "
158
+ f"and running, and is the model pulled "
159
+ f"(ollama pull {self.model})?") from e
160
+ return _parse_json(data.get("message", {}).get("content", ""))
161
+
162
+
163
+ class AnthropicBackend:
164
+ """Claude API with structured output. Transcripts leave this machine."""
165
+
166
+ def __init__(self, model: str, api_key: str | None = None) -> None:
167
+ import anthropic
168
+
169
+ # No key in secrets.toml: the SDK falls back to ANTHROPIC_API_KEY or an
170
+ # `ant auth login` profile.
171
+ self.client = anthropic.Anthropic(api_key=api_key) if api_key else anthropic.Anthropic()
172
+ self.model = model
173
+ self.name = f"anthropic:{model}"
174
+
175
+ def complete_json(self, system: str, user: str, schema: dict) -> dict:
176
+ import anthropic
177
+
178
+ try:
179
+ resp = self.client.beta.messages.create(
180
+ model=self.model,
181
+ max_tokens=16000,
182
+ system=system,
183
+ messages=[{"role": "user", "content": user}],
184
+ output_config={"effort": "medium",
185
+ "format": {"type": "json_schema", "schema": schema}},
186
+ # On a policy decline, re-run on Anthropic's recommended fallback model.
187
+ betas=["server-side-fallback-2026-07-01"],
188
+ fallbacks="default",
189
+ )
190
+ except anthropic.AuthenticationError as e:
191
+ raise SummaryError("Claude API: no valid API key (run `wirenote setup claude`, "
192
+ f"or set anthropic_api_key in {secrets_path()})") from e
193
+ except anthropic.RateLimitError as e:
194
+ raise SummaryError("Claude API: rate limited, retry later") from e
195
+ except anthropic.APIStatusError as e:
196
+ raise SummaryError(f"Claude API error {e.status_code}: {e.message}") from e
197
+ except anthropic.APIConnectionError as e:
198
+ raise SummaryError(f"Claude API not reachable: {e}") from e
199
+ if resp.stop_reason == "refusal":
200
+ raise SummaryError("Claude API declined to summarize this transcript")
201
+ if resp.stop_reason == "max_tokens":
202
+ raise SummaryError("Claude API output was cut off (max_tokens)")
203
+ text = next((b.text for b in resp.content if b.type == "text"), "")
204
+ return _parse_json(text)
205
+
206
+
207
+ def make_backend(settings: Settings) -> Backend | None:
208
+ if settings.summary_backend == "none":
209
+ return None
210
+ if settings.summary_backend == "ollama":
211
+ return OllamaBackend(settings.ollama_url, settings.ollama_model)
212
+ if settings.summary_backend == "anthropic":
213
+ return AnthropicBackend(settings.anthropic_model, load_secrets().get("anthropic_api_key"))
214
+ raise SummaryError(f"unknown summary_backend {settings.summary_backend!r} "
215
+ "(ollama, anthropic or none)")
216
+
217
+
218
+ # Summarizing -----------------------------------------------------------------
219
+
220
+ def chunks(lines: list[Line], chunk_s: float) -> list[list[Line]]:
221
+ """Split at paragraph boundaries into windows of about chunk_s seconds."""
222
+ out: list[list[Line]] = []
223
+ for line in lines:
224
+ if not out or line.start - out[-1][0].start >= chunk_s:
225
+ out.append([])
226
+ out[-1].append(line)
227
+ return out
228
+
229
+
230
+ def summarize(lines: list[Line], backend: Backend, chunk_s: float, language: str,
231
+ context: str = "", names: dict[str, str] | None = None) -> Summary:
232
+ if not lines:
233
+ raise SummaryError("empty transcript")
234
+ lang = "the same language as the transcript" if language == "auto" else language
235
+ system = SYSTEM.format(language=lang)
236
+ head = f"{context}\n\n" if context else ""
237
+ parts = chunks(lines, chunk_s)
238
+ if len(parts) == 1:
239
+ data = _ask(backend, system + TITLE_FIELD,
240
+ f"{head}Transcript:\n{as_text(lines, names)}", FINAL_SCHEMA)
241
+ return _to_summary(data)
242
+ notes = []
243
+ for i, part in enumerate(parts, 1):
244
+ user = f"{head}Transcript, part {i} of {len(parts)}:\n{as_text(part, names)}"
245
+ notes.append(_ask(backend, system, user, CHUNK_SCHEMA))
246
+ user = f"{head}{COMBINE}\n\n" + "\n\n".join(
247
+ f"Part {i}:\n{json.dumps(n, ensure_ascii=False)}" for i, n in enumerate(notes, 1))
248
+ return _to_summary(_ask(backend, system + TITLE_FIELD, user, FINAL_SCHEMA))
249
+
250
+
251
+ def _ask(backend: Backend, system: str, user: str, schema: dict) -> dict:
252
+ """One call, retried once if the JSON does not match the schema."""
253
+ last = None
254
+ for _ in range(2):
255
+ try:
256
+ data = backend.complete_json(system, user, schema)
257
+ _check(data, schema)
258
+ return data
259
+ except (ValueError, KeyError, TypeError) as e:
260
+ last = e
261
+ raise SummaryError(f"{backend.name} returned unusable JSON twice: {last}")
262
+
263
+
264
+ def _parse_json(text: str) -> dict:
265
+ data = json.loads(text)
266
+ if not isinstance(data, dict):
267
+ raise ValueError("expected a JSON object")
268
+ return data
269
+
270
+
271
+ def _check(data: dict, schema: dict) -> None:
272
+ for key in schema["required"]:
273
+ want = schema["properties"][key]["type"]
274
+ value = data[key]
275
+ if want == "string" and not isinstance(value, str):
276
+ raise TypeError(f"{key}: expected a string")
277
+ if want == "array" and not isinstance(value, list):
278
+ raise TypeError(f"{key}: expected a list")
279
+
280
+
281
+ def _to_summary(data: dict) -> Summary:
282
+ strs = lambda xs: [str(x).strip() for x in xs if str(x).strip()] # noqa: E731
283
+ items = [ActionItem(str(a.get("owner") or "Unassigned").strip(), str(a.get("task", "")).strip(),
284
+ str(a.get("due") or "").strip())
285
+ for a in data.get("action_items", []) if isinstance(a, dict) and a.get("task")]
286
+ return Summary(str(data.get("title", "")).strip(), strs(data.get("summary", [])),
287
+ strs(data.get("decisions", [])), items, strs(data.get("open_questions", [])))
@@ -0,0 +1,97 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """Transcribe each recorded segment with faster-whisper (docs/dev/PLAN.md › Processing, step 1).
4
+
5
+ CPU, int8; voice-activity filter on, word timestamps on, language auto-detect
6
+ unless configured. Samples are decoded here (16 kHz mono s16 from pw-record)
7
+ because faster-whisper's own decoder breaks with the installed PyAV.
8
+
9
+ The vocabulary (names, product terms) goes in as hotwords, which faster-whisper
10
+ puts into every window's prompt. On the system track, speakers are separated
11
+ (process/speakers.py) and lines are split at speaker changes.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from ..capture import wav
20
+ from ..config.settings import Settings
21
+ from .merge import Line
22
+ from .speakers import Diarizer, SpeakerBook, Word, assign_sentence, sentences
23
+
24
+ MIN_SEGMENT_S = 0.5
25
+
26
+
27
+ def log(msg: str) -> None:
28
+ print(f"worker: {msg}", file=sys.stderr, flush=True)
29
+
30
+
31
+ class Transcriber:
32
+ def __init__(self, settings: Settings, vocabulary: list[str] | None = None) -> None:
33
+ from faster_whisper import WhisperModel
34
+
35
+ self.settings = settings
36
+ from .models import whisper_path
37
+
38
+ self.model = WhisperModel(whisper_path(settings.whisper_model), device="cpu",
39
+ compute_type="int8")
40
+ # Each term as its own sentence: Whisper copies the prompt's style, and a bare
41
+ # comma list made it drop sentence punctuation (docs/dev/features-2-design.md).
42
+ self.hotwords = " ".join(f"{t.rstrip('.')}." for t in vocabulary or [])[:600] or None
43
+ self.diarizer = None
44
+ if settings.diarize:
45
+ try:
46
+ self.diarizer = Diarizer(settings)
47
+ except Exception as e: # noqa: BLE001 - fall back to a single "Others"
48
+ log(f"speaker separation unavailable, using 'Others': {e!r}")
49
+
50
+ def segment(self, path: Path, offset_s: float, track: str,
51
+ book: SpeakerBook) -> tuple[list[Line], dict]:
52
+ """Lines of one WAV, shifted by its offset in the recording, plus run info."""
53
+ if wav.duration_s(path) < MIN_SEGMENT_S:
54
+ return [], {"file": path.name, "skipped": "too short"}
55
+ audio = wav.load_float(path)
56
+ lang = None if self.settings.whisper_language == "auto" else self.settings.whisper_language
57
+ segments, info = self.model.transcribe(audio, language=lang, vad_filter=True,
58
+ word_timestamps=True, hotwords=self.hotwords)
59
+ segments = [s for s in segments if s.text.strip()]
60
+ run = {"file": path.name, "language": info.language,
61
+ "language_probability": round(info.language_probability, 2),
62
+ "audio_s": round(info.duration, 1), "speech_s": round(info.duration_after_vad, 1)}
63
+
64
+ words = [Word(w.start, w.end, w.word) for s in segments for w in (s.words or [])]
65
+ text = lambda ws: "".join(w.text for w in ws).strip() # noqa: E731
66
+ line = lambda who, ws: Line(who, round(offset_s + ws[0].start, 2), # noqa: E731
67
+ round(offset_s + ws[-1].end, 2), text(ws))
68
+
69
+ if track == "mic":
70
+ return [line("Me", ws) for ws in sentences(words) if text(ws)], run
71
+ if self.diarizer is None:
72
+ return [line("Others", ws) for ws in sentences(words) if text(ws)], run
73
+
74
+ turns, prints = self.diarizer.run(audio)
75
+ labels = {local: book.label_for(v) for local, v in prints.items()}
76
+ for t in turns:
77
+ book.add_time(labels[t.label], t.end - t.start)
78
+ lines = []
79
+ for sent in sentences(words):
80
+ for local, ws in assign_sentence(sent, turns):
81
+ if text(ws):
82
+ lines.append(line(labels.get(local, "Others"), ws))
83
+ run["speakers"] = len(prints)
84
+ return lines, run
85
+
86
+ def recording(self, rec_dir: Path, manifest: dict) -> tuple[list[Line], list[dict], SpeakerBook]:
87
+ book = SpeakerBook(self.settings.voice_match)
88
+ lines, runs = [], []
89
+ for seg in manifest.get("segments", []):
90
+ path = rec_dir / seg["file"]
91
+ if not path.exists():
92
+ runs.append({"file": seg["file"], "skipped": "missing (audio deleted?)"})
93
+ continue
94
+ got, run = self.segment(path, seg["start_offset_s"], seg["track"], book)
95
+ lines += got
96
+ runs.append(run)
97
+ return lines, runs, book