fow-cli 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.
- fly_on_the_wall/__init__.py +3 -0
- fly_on_the_wall/audio.py +164 -0
- fly_on_the_wall/audio_metadata.py +241 -0
- fly_on_the_wall/cache.py +26 -0
- fly_on_the_wall/cleanup.py +29 -0
- fly_on_the_wall/cli.py +641 -0
- fly_on_the_wall/cli_costs.py +81 -0
- fly_on_the_wall/cli_menu.py +163 -0
- fly_on_the_wall/cli_publish.py +141 -0
- fly_on_the_wall/cli_speaker_review.py +315 -0
- fly_on_the_wall/cli_watch.py +209 -0
- fly_on_the_wall/config.py +92 -0
- fly_on_the_wall/costs.py +169 -0
- fly_on_the_wall/db.py +508 -0
- fly_on_the_wall/doctor.py +142 -0
- fly_on_the_wall/embeddings.py +142 -0
- fly_on_the_wall/exporting.py +155 -0
- fly_on_the_wall/glossary.py +31 -0
- fly_on_the_wall/meetings.py +382 -0
- fly_on_the_wall/normalization.py +166 -0
- fly_on_the_wall/people.py +82 -0
- fly_on_the_wall/people_embeddings.py +68 -0
- fly_on_the_wall/pipeline.py +120 -0
- fly_on_the_wall/processing.py +427 -0
- fly_on_the_wall/providers/__init__.py +1 -0
- fly_on_the_wall/providers/elevenlabs.py +145 -0
- fly_on_the_wall/providers/openai_analysis.py +195 -0
- fly_on_the_wall/providers/openai_cleanup.py +91 -0
- fly_on_the_wall/publishing.py +410 -0
- fly_on_the_wall/reanalysis.py +172 -0
- fly_on_the_wall/recording_quality.py +141 -0
- fly_on_the_wall/rendering.py +115 -0
- fly_on_the_wall/secrets.py +93 -0
- fly_on_the_wall/service_pricing.py +75 -0
- fly_on_the_wall/setup.py +221 -0
- fly_on_the_wall/speaker_identity.py +173 -0
- fly_on_the_wall/speaker_matching.py +134 -0
- fly_on_the_wall/speakers.py +221 -0
- fly_on_the_wall/storage.py +53 -0
- fly_on_the_wall/voice_samples.py +125 -0
- fly_on_the_wall/watch.py +347 -0
- fow_cli-0.1.0.dist-info/METADATA +447 -0
- fow_cli-0.1.0.dist-info/RECORD +46 -0
- fow_cli-0.1.0.dist-info/WHEEL +4 -0
- fow_cli-0.1.0.dist-info/entry_points.txt +2 -0
- fow_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
fly_on_the_wall/cli.py
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from fly_on_the_wall import __version__
|
|
11
|
+
from fly_on_the_wall.cli_costs import costs_app
|
|
12
|
+
from fly_on_the_wall.cli_publish import publish_app
|
|
13
|
+
from fly_on_the_wall.cli_speaker_review import speakers_review
|
|
14
|
+
from fly_on_the_wall.cli_watch import watch_app
|
|
15
|
+
from fly_on_the_wall.config import load_config
|
|
16
|
+
from fly_on_the_wall.db import database
|
|
17
|
+
from fly_on_the_wall.doctor import has_failures, run_checks
|
|
18
|
+
from fly_on_the_wall.meetings import (
|
|
19
|
+
delete_meeting,
|
|
20
|
+
get_meeting,
|
|
21
|
+
list_meetings,
|
|
22
|
+
meeting_stage_status,
|
|
23
|
+
rename_meeting,
|
|
24
|
+
)
|
|
25
|
+
from fly_on_the_wall.people import (
|
|
26
|
+
create_person,
|
|
27
|
+
get_person,
|
|
28
|
+
get_user_person,
|
|
29
|
+
list_people,
|
|
30
|
+
set_user_person,
|
|
31
|
+
unset_user_person,
|
|
32
|
+
)
|
|
33
|
+
from fly_on_the_wall.people_embeddings import (
|
|
34
|
+
backfill_people_embeddings,
|
|
35
|
+
people_embedding_status,
|
|
36
|
+
)
|
|
37
|
+
from fly_on_the_wall.processing import process_audio, refresh_meeting
|
|
38
|
+
from fly_on_the_wall.reanalysis import (
|
|
39
|
+
list_stale_meetings,
|
|
40
|
+
list_stale_stages,
|
|
41
|
+
mark_speaker_reanalysis_stale,
|
|
42
|
+
rerun_speaker_matching,
|
|
43
|
+
rerun_speaker_matching_for_meetings,
|
|
44
|
+
)
|
|
45
|
+
from fly_on_the_wall.recording_quality import RecordingIgnoredError
|
|
46
|
+
from fly_on_the_wall.secrets import (
|
|
47
|
+
SecretError,
|
|
48
|
+
get_api_key_status,
|
|
49
|
+
known_providers,
|
|
50
|
+
remove_api_key,
|
|
51
|
+
set_api_key,
|
|
52
|
+
)
|
|
53
|
+
from fly_on_the_wall.setup import run_setup
|
|
54
|
+
from fly_on_the_wall.speakers import (
|
|
55
|
+
assign_speaker_to_person,
|
|
56
|
+
list_unknown_speakers,
|
|
57
|
+
mark_speaker_ignored,
|
|
58
|
+
)
|
|
59
|
+
from fly_on_the_wall.voice_samples import list_voice_samples
|
|
60
|
+
|
|
61
|
+
app = typer.Typer(
|
|
62
|
+
name="fow",
|
|
63
|
+
help="Personal CLI note-taker for meeting audio.",
|
|
64
|
+
no_args_is_help=True,
|
|
65
|
+
)
|
|
66
|
+
people_app = typer.Typer(help="Manage known people.", no_args_is_help=True)
|
|
67
|
+
people_embeddings_app = typer.Typer(help="Manage known people's voice embeddings.", no_args_is_help=True)
|
|
68
|
+
meetings_app = typer.Typer(help="Inspect meetings.", no_args_is_help=True)
|
|
69
|
+
meeting_speakers_app = typer.Typer(
|
|
70
|
+
help="Review meeting-local speakers and assign them to people.",
|
|
71
|
+
no_args_is_help=True,
|
|
72
|
+
)
|
|
73
|
+
refresh_app = typer.Typer(help="Refresh derived meeting outputs.", no_args_is_help=True)
|
|
74
|
+
secrets_app = typer.Typer(help="Manage API keys in the OS keyring.", no_args_is_help=True)
|
|
75
|
+
app.add_typer(people_app, name="people")
|
|
76
|
+
people_app.add_typer(people_embeddings_app, name="embeddings")
|
|
77
|
+
app.add_typer(meetings_app, name="meetings")
|
|
78
|
+
meetings_app.add_typer(meeting_speakers_app, name="speakers")
|
|
79
|
+
app.add_typer(refresh_app, name="refresh")
|
|
80
|
+
app.add_typer(secrets_app, name="secrets")
|
|
81
|
+
app.add_typer(watch_app, name="watch")
|
|
82
|
+
app.add_typer(publish_app, name="publish")
|
|
83
|
+
app.add_typer(costs_app, name="costs")
|
|
84
|
+
console = Console()
|
|
85
|
+
meeting_speakers_app.command("review")(speakers_review)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _version_callback(show_version: bool) -> None:
|
|
89
|
+
if show_version:
|
|
90
|
+
console.print(f"fly-on-the-wall {__version__}")
|
|
91
|
+
raise typer.Exit
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.callback()
|
|
95
|
+
def main(
|
|
96
|
+
version: bool = typer.Option(
|
|
97
|
+
False,
|
|
98
|
+
"--version",
|
|
99
|
+
callback=_version_callback,
|
|
100
|
+
is_eager=True,
|
|
101
|
+
help="Show the application version.",
|
|
102
|
+
),
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Run Fly on the Wall commands."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command()
|
|
108
|
+
def setup() -> None:
|
|
109
|
+
"""Interactively configure first-run setup."""
|
|
110
|
+
run_setup(console)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command()
|
|
114
|
+
def doctor() -> None:
|
|
115
|
+
"""Check local runtime configuration and dependencies."""
|
|
116
|
+
checks = run_checks()
|
|
117
|
+
table = Table(title="Fly on the Wall Doctor")
|
|
118
|
+
table.add_column("Check")
|
|
119
|
+
table.add_column("Status")
|
|
120
|
+
table.add_column("Detail")
|
|
121
|
+
|
|
122
|
+
for check in checks:
|
|
123
|
+
table.add_row(check.name, "ok" if check.ok else "missing", check.detail)
|
|
124
|
+
|
|
125
|
+
console.print(table)
|
|
126
|
+
if has_failures(checks):
|
|
127
|
+
raise typer.Exit(code=1)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.command()
|
|
131
|
+
def process(
|
|
132
|
+
audio_path: Annotated[Path, typer.Argument(exists=True, file_okay=True, dir_okay=False)],
|
|
133
|
+
title: Annotated[str | None, typer.Option("--title", "-t", help="Manual meeting title override.")] = None,
|
|
134
|
+
description: Annotated[str | None, typer.Option("--description", "-d", help="Meeting context.")] = None,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Process audio from import through markdown export."""
|
|
137
|
+
config = load_config()
|
|
138
|
+
with database() as connection:
|
|
139
|
+
try:
|
|
140
|
+
result = process_audio(
|
|
141
|
+
connection,
|
|
142
|
+
audio_path,
|
|
143
|
+
title,
|
|
144
|
+
config,
|
|
145
|
+
description=description,
|
|
146
|
+
progress=lambda message: console.print(f"-> {message}"),
|
|
147
|
+
)
|
|
148
|
+
except RecordingIgnoredError as exc:
|
|
149
|
+
console.print(f"Ignored recording {exc.meeting.slug}: {exc.quality.reason}")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
console.print(f"Processed meeting {result.meeting.slug}")
|
|
153
|
+
console.print(f"Transcript: {result.export.transcript_path}")
|
|
154
|
+
console.print(f"Analysis: {result.export.analysis_path}")
|
|
155
|
+
console.print(f"Review unknown speakers: fow meetings speakers unknown --meeting {result.meeting.slug}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@meetings_app.command("list")
|
|
159
|
+
def meetings_list() -> None:
|
|
160
|
+
"""List imported meetings."""
|
|
161
|
+
with database() as connection:
|
|
162
|
+
meetings = list_meetings(connection)
|
|
163
|
+
if not meetings:
|
|
164
|
+
console.print("No meetings found. Process one with: fow process <audio>")
|
|
165
|
+
return
|
|
166
|
+
table = Table(title="Meetings")
|
|
167
|
+
table.add_column("Slug")
|
|
168
|
+
table.add_column("Title")
|
|
169
|
+
table.add_column("Title Source")
|
|
170
|
+
table.add_column("Language")
|
|
171
|
+
for meeting in meetings:
|
|
172
|
+
table.add_row(
|
|
173
|
+
meeting["slug"],
|
|
174
|
+
meeting["title"],
|
|
175
|
+
meeting.get("title_source", "manual"),
|
|
176
|
+
meeting["language"],
|
|
177
|
+
)
|
|
178
|
+
console.print(table)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@meetings_app.command("show")
|
|
182
|
+
def meetings_show(meeting: str) -> None:
|
|
183
|
+
"""Show one meeting."""
|
|
184
|
+
with database() as connection:
|
|
185
|
+
found = get_meeting(connection, meeting)
|
|
186
|
+
if found is None:
|
|
187
|
+
console.print(f"Meeting not found: {meeting}")
|
|
188
|
+
raise typer.Exit(code=1)
|
|
189
|
+
console.print(f"Title: {found['title']}")
|
|
190
|
+
console.print(f"Title Source: {found.get('title_source', 'manual')}")
|
|
191
|
+
if found.get("generated_title"):
|
|
192
|
+
console.print(f"Generated Title: {found['generated_title']}")
|
|
193
|
+
console.print(f"Slug: {found['slug']}")
|
|
194
|
+
console.print(f"ID: {found['id']}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@meetings_app.command("rename")
|
|
198
|
+
def meetings_rename(meeting: str, title: str) -> None:
|
|
199
|
+
"""Manually rename a meeting title."""
|
|
200
|
+
with database() as connection:
|
|
201
|
+
try:
|
|
202
|
+
updated = rename_meeting(connection, meeting, title)
|
|
203
|
+
except ValueError as exc:
|
|
204
|
+
console.print(str(exc))
|
|
205
|
+
raise typer.Exit(code=1) from exc
|
|
206
|
+
console.print(f"Renamed meeting {updated['slug']}")
|
|
207
|
+
console.print(f"Title: {updated['title']}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@meetings_app.command("remove")
|
|
211
|
+
def meetings_remove(
|
|
212
|
+
meeting: str,
|
|
213
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Delete without interactive confirmation.")] = False,
|
|
214
|
+
delete_published: Annotated[
|
|
215
|
+
bool,
|
|
216
|
+
typer.Option("--delete-published", help="Also delete externally published notes for this meeting."),
|
|
217
|
+
] = False,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Completely remove a meeting and its stored files."""
|
|
220
|
+
with database() as connection:
|
|
221
|
+
found = get_meeting(connection, meeting)
|
|
222
|
+
if found is None:
|
|
223
|
+
console.print(f"Meeting not found: {meeting}")
|
|
224
|
+
raise typer.Exit(code=1)
|
|
225
|
+
|
|
226
|
+
if not yes:
|
|
227
|
+
message = f"Delete meeting {found['slug']} and all stored data?"
|
|
228
|
+
if delete_published:
|
|
229
|
+
message += " This will also delete externally published notes."
|
|
230
|
+
confirmed = typer.confirm(message, default=False)
|
|
231
|
+
if not confirmed:
|
|
232
|
+
console.print("Cancelled.")
|
|
233
|
+
return
|
|
234
|
+
if not delete_published and _meeting_has_published_items(connection, found["id"]):
|
|
235
|
+
delete_published = typer.confirm(
|
|
236
|
+
"Delete externally published notes for this meeting too?",
|
|
237
|
+
default=False,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
result = delete_meeting(connection, meeting, delete_published=delete_published)
|
|
241
|
+
|
|
242
|
+
console.print(f"Removed meeting {result.slug}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _meeting_has_published_items(connection, meeting_id: str) -> bool:
|
|
246
|
+
row = connection.execute(
|
|
247
|
+
"SELECT 1 FROM published_items WHERE meeting_id = ? LIMIT 1",
|
|
248
|
+
(meeting_id,),
|
|
249
|
+
).fetchone()
|
|
250
|
+
return row is not None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@meetings_app.command("status")
|
|
254
|
+
def meetings_status(meeting: str) -> None:
|
|
255
|
+
"""Show pipeline status for a meeting."""
|
|
256
|
+
with database() as connection:
|
|
257
|
+
stages = meeting_stage_status(connection, meeting)
|
|
258
|
+
if not stages:
|
|
259
|
+
console.print(f"No stage status found for meeting: {meeting}")
|
|
260
|
+
return
|
|
261
|
+
table = Table(title="Pipeline Status")
|
|
262
|
+
table.add_column("Stage")
|
|
263
|
+
table.add_column("Status")
|
|
264
|
+
table.add_column("Error")
|
|
265
|
+
for stage in stages:
|
|
266
|
+
table.add_row(stage["stage_name"], stage["status"], stage["error_message"] or "")
|
|
267
|
+
console.print(table)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@meeting_speakers_app.command("unknown")
|
|
271
|
+
def speakers_unknown(
|
|
272
|
+
meeting: Annotated[str | None, typer.Option("--meeting", "-m", help="Meeting ID or slug.")] = None,
|
|
273
|
+
) -> None:
|
|
274
|
+
"""List meeting-local speakers that are not assigned to people."""
|
|
275
|
+
with database() as connection:
|
|
276
|
+
speakers = list_unknown_speakers(connection, meeting)
|
|
277
|
+
if not speakers:
|
|
278
|
+
console.print("No unknown speakers found.")
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
table = Table(title="Unknown Speakers")
|
|
282
|
+
table.add_column("ID")
|
|
283
|
+
table.add_column("Meeting")
|
|
284
|
+
table.add_column("Label")
|
|
285
|
+
table.add_column("Segments")
|
|
286
|
+
for speaker in speakers:
|
|
287
|
+
table.add_row(
|
|
288
|
+
speaker["id"],
|
|
289
|
+
speaker["meeting_slug"],
|
|
290
|
+
speaker["label"],
|
|
291
|
+
str(speaker["segment_count"]),
|
|
292
|
+
)
|
|
293
|
+
console.print(table)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@meeting_speakers_app.command("assign")
|
|
297
|
+
def speakers_assign(local_speaker_id: str, person: str) -> None:
|
|
298
|
+
"""Assign a meeting-local speaker to a person, creating that person if needed."""
|
|
299
|
+
with database() as connection:
|
|
300
|
+
assignment = assign_speaker_to_person(connection, local_speaker_id, person)
|
|
301
|
+
if assignment["created_person"]:
|
|
302
|
+
console.print(f"Created person {assignment['name']}")
|
|
303
|
+
console.print(f"Assigned {assignment['local_speaker_id']} to {assignment['name']}")
|
|
304
|
+
console.print("Next: fow refresh speakers <meeting>")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@meeting_speakers_app.command("ignore")
|
|
308
|
+
def speakers_ignore(local_speaker_id: str) -> None:
|
|
309
|
+
"""Ignore a meeting-local speaker so it is not shown during review."""
|
|
310
|
+
with database() as connection:
|
|
311
|
+
try:
|
|
312
|
+
mark_speaker_ignored(connection, local_speaker_id)
|
|
313
|
+
except ValueError as exc:
|
|
314
|
+
console.print(str(exc))
|
|
315
|
+
raise typer.Exit(code=1) from exc
|
|
316
|
+
console.print(f"Ignored meeting speaker {local_speaker_id}")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@refresh_app.command("speakers")
|
|
320
|
+
def refresh_speakers(
|
|
321
|
+
meeting: Annotated[str | None, typer.Argument(help="Optional meeting ID or slug.")] = None,
|
|
322
|
+
include_known_speakers: Annotated[
|
|
323
|
+
bool,
|
|
324
|
+
typer.Option(
|
|
325
|
+
"--include-known-speakers",
|
|
326
|
+
help="Also refresh speaker matching for meetings where all speakers are already known.",
|
|
327
|
+
),
|
|
328
|
+
] = False,
|
|
329
|
+
) -> None:
|
|
330
|
+
"""Refresh speaker matching and mark downstream outputs stale."""
|
|
331
|
+
with database() as connection:
|
|
332
|
+
if meeting is None:
|
|
333
|
+
_refresh_speakers_for_all_meetings(connection, include_known_speakers)
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
match_count = _refresh_speakers_for_one_meeting(connection, meeting)
|
|
337
|
+
stages = mark_speaker_reanalysis_stale(connection, meeting) if match_count else []
|
|
338
|
+
console.print(f"New speaker matches: {match_count}")
|
|
339
|
+
if stages:
|
|
340
|
+
console.print(f"Marked stale: {', '.join(stages)}")
|
|
341
|
+
else:
|
|
342
|
+
console.print("No speaker assignment changes; downstream stages left untouched.")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _refresh_speakers_for_all_meetings(connection, include_known_speakers: bool) -> None:
|
|
346
|
+
results = rerun_speaker_matching_for_meetings(
|
|
347
|
+
connection,
|
|
348
|
+
include_known_speakers,
|
|
349
|
+
progress=lambda message: console.print(f"-> {message}"),
|
|
350
|
+
)
|
|
351
|
+
if not results:
|
|
352
|
+
console.print("No meetings found for speaker refresh.")
|
|
353
|
+
return
|
|
354
|
+
_print_speaker_refresh_results(results)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _print_speaker_refresh_results(results) -> None:
|
|
358
|
+
table = Table(title="Speaker Refresh")
|
|
359
|
+
table.add_column("Meeting")
|
|
360
|
+
table.add_column("New Speaker Matches")
|
|
361
|
+
for result in results:
|
|
362
|
+
table.add_row(result["meeting_slug"], str(result["match_count"]))
|
|
363
|
+
console.print(table)
|
|
364
|
+
console.print(f"Speaker matching refreshed for meetings: {len(results)}")
|
|
365
|
+
_print_speaker_refresh_next_step(results)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _print_speaker_refresh_next_step(results) -> None:
|
|
369
|
+
if any(result["match_count"] for result in results):
|
|
370
|
+
console.print("Next: fow refresh stale-meetings")
|
|
371
|
+
return
|
|
372
|
+
console.print("No speaker assignment changes; downstream stages left untouched.")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _refresh_speakers_for_one_meeting(connection, meeting: str) -> int:
|
|
376
|
+
try:
|
|
377
|
+
return rerun_speaker_matching(
|
|
378
|
+
connection,
|
|
379
|
+
meeting,
|
|
380
|
+
progress=lambda message: console.print(f"-> {message}"),
|
|
381
|
+
)
|
|
382
|
+
except RuntimeError as exc:
|
|
383
|
+
console.print(f"Speaker matching skipped: {exc}")
|
|
384
|
+
return 0
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@refresh_app.command("stale-meetings")
|
|
388
|
+
def refresh_stale_meetings(
|
|
389
|
+
dry_run: Annotated[
|
|
390
|
+
bool,
|
|
391
|
+
typer.Option("--dry-run", help="List stale meetings without refreshing them."),
|
|
392
|
+
] = False,
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Refresh all meetings with stale derived outputs."""
|
|
395
|
+
with database() as connection:
|
|
396
|
+
stale_meetings = list_stale_meetings(connection)
|
|
397
|
+
if not stale_meetings:
|
|
398
|
+
console.print("No stale meetings found.")
|
|
399
|
+
return
|
|
400
|
+
if dry_run:
|
|
401
|
+
_print_stale_stages(connection)
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
config = load_config()
|
|
405
|
+
results: list[tuple[str, str, str]] = []
|
|
406
|
+
for stale_meeting in stale_meetings:
|
|
407
|
+
meeting_slug = stale_meeting["meeting_slug"]
|
|
408
|
+
try:
|
|
409
|
+
result = refresh_meeting(
|
|
410
|
+
connection,
|
|
411
|
+
meeting_slug,
|
|
412
|
+
config,
|
|
413
|
+
progress=lambda message: console.print(f"-> {message}"),
|
|
414
|
+
)
|
|
415
|
+
except (RecordingIgnoredError, ValueError) as exc:
|
|
416
|
+
console.print(f"Refresh failed for {meeting_slug}: {exc}")
|
|
417
|
+
results.append((meeting_slug, "failed", ""))
|
|
418
|
+
continue
|
|
419
|
+
results.append((result.meeting.slug, "refreshed", str(result.export.transcript_path)))
|
|
420
|
+
|
|
421
|
+
table = Table(title="Refresh Stale Meetings")
|
|
422
|
+
table.add_column("Meeting")
|
|
423
|
+
table.add_column("Status")
|
|
424
|
+
table.add_column("Transcript")
|
|
425
|
+
for meeting_slug, status, transcript_path in results:
|
|
426
|
+
table.add_row(meeting_slug, status, transcript_path)
|
|
427
|
+
console.print(table)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@refresh_app.command("meeting")
|
|
431
|
+
def refresh_meeting_command(meeting: str) -> None:
|
|
432
|
+
"""Refresh derived outputs for one meeting."""
|
|
433
|
+
config = load_config()
|
|
434
|
+
with database() as connection:
|
|
435
|
+
try:
|
|
436
|
+
result = refresh_meeting(
|
|
437
|
+
connection,
|
|
438
|
+
meeting,
|
|
439
|
+
config,
|
|
440
|
+
progress=lambda message: console.print(f"-> {message}"),
|
|
441
|
+
)
|
|
442
|
+
except (RecordingIgnoredError, ValueError) as exc:
|
|
443
|
+
console.print(str(exc))
|
|
444
|
+
raise typer.Exit(code=1) from exc
|
|
445
|
+
console.print(f"Refreshed {result.meeting.slug}")
|
|
446
|
+
console.print(f"Transcript: {result.export.transcript_path}")
|
|
447
|
+
console.print(f"Analysis: {result.export.analysis_path}")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _print_stale_stages(connection) -> None:
|
|
451
|
+
stale = list_stale_stages(connection)
|
|
452
|
+
table = Table(title="Stale Stages")
|
|
453
|
+
table.add_column("Meeting")
|
|
454
|
+
table.add_column("Stage")
|
|
455
|
+
for stage in stale:
|
|
456
|
+
table.add_row(stage["meeting_slug"], stage["stage_name"])
|
|
457
|
+
console.print(table)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@people_app.command("create")
|
|
461
|
+
def people_create(name: str) -> None:
|
|
462
|
+
"""Create a known person."""
|
|
463
|
+
with database() as connection:
|
|
464
|
+
person = create_person(connection, name)
|
|
465
|
+
console.print(f"Created person {person.display_name}")
|
|
466
|
+
console.print(f"ID: {person.id}")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@people_app.command("list")
|
|
470
|
+
def people_list() -> None:
|
|
471
|
+
"""List known people."""
|
|
472
|
+
with database() as connection:
|
|
473
|
+
people = list_people(connection)
|
|
474
|
+
|
|
475
|
+
if not people:
|
|
476
|
+
console.print('No people found. Create one with: fow people create "Name"')
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
table = Table(title="People")
|
|
480
|
+
table.add_column("Name")
|
|
481
|
+
table.add_column("User")
|
|
482
|
+
table.add_column("ID")
|
|
483
|
+
for person in people:
|
|
484
|
+
table.add_row(person.display_name, "yes" if person.is_user else "", person.id)
|
|
485
|
+
console.print(table)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@people_app.command("show")
|
|
489
|
+
def people_show(person: str) -> None:
|
|
490
|
+
"""Show one known person."""
|
|
491
|
+
with database() as connection:
|
|
492
|
+
found = get_person(connection, person)
|
|
493
|
+
|
|
494
|
+
if found is None:
|
|
495
|
+
console.print(f"Person not found: {person}")
|
|
496
|
+
raise typer.Exit(code=1)
|
|
497
|
+
|
|
498
|
+
console.print(f"Name: {found.display_name}")
|
|
499
|
+
console.print(f"User: {'yes' if found.is_user else 'no'}")
|
|
500
|
+
console.print(f"ID: {found.id}")
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
@people_app.command("set-user")
|
|
504
|
+
def people_set_user(person: str) -> None:
|
|
505
|
+
"""Mark one known person as the system user."""
|
|
506
|
+
with database() as connection:
|
|
507
|
+
try:
|
|
508
|
+
updated = set_user_person(connection, person)
|
|
509
|
+
except ValueError as exc:
|
|
510
|
+
console.print(str(exc))
|
|
511
|
+
raise typer.Exit(code=1) from exc
|
|
512
|
+
console.print(f"System user: {updated.display_name}")
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@people_app.command("show-user")
|
|
516
|
+
def people_show_user() -> None:
|
|
517
|
+
"""Show the person marked as the system user."""
|
|
518
|
+
with database() as connection:
|
|
519
|
+
person = get_user_person(connection)
|
|
520
|
+
if person is None:
|
|
521
|
+
console.print("No system user configured.")
|
|
522
|
+
return
|
|
523
|
+
console.print(f"System user: {person.display_name}")
|
|
524
|
+
console.print(f"ID: {person.id}")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@people_app.command("unset-user")
|
|
528
|
+
def people_unset_user() -> None:
|
|
529
|
+
"""Clear the system user marker."""
|
|
530
|
+
with database() as connection:
|
|
531
|
+
person = unset_user_person(connection)
|
|
532
|
+
if person is None:
|
|
533
|
+
console.print("No system user configured.")
|
|
534
|
+
return
|
|
535
|
+
console.print(f"Cleared system user: {person.display_name}")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@people_app.command("voice-samples")
|
|
539
|
+
def people_voice_samples(person: str) -> None:
|
|
540
|
+
"""List confirmed voice samples for one person."""
|
|
541
|
+
with database() as connection:
|
|
542
|
+
found = get_person(connection, person)
|
|
543
|
+
if found is None:
|
|
544
|
+
console.print(f"Person not found: {person}")
|
|
545
|
+
raise typer.Exit(code=1)
|
|
546
|
+
samples = list_voice_samples(connection, found.id)
|
|
547
|
+
|
|
548
|
+
if not samples:
|
|
549
|
+
console.print("No voice samples found.")
|
|
550
|
+
return
|
|
551
|
+
|
|
552
|
+
table = Table(title=f"Voice Samples: {found.display_name}")
|
|
553
|
+
table.add_column("ID")
|
|
554
|
+
table.add_column("Audio")
|
|
555
|
+
table.add_column("Start")
|
|
556
|
+
table.add_column("End")
|
|
557
|
+
for sample in samples:
|
|
558
|
+
table.add_row(
|
|
559
|
+
sample.id,
|
|
560
|
+
str(sample.audio_path),
|
|
561
|
+
"" if sample.start_time is None else f"{sample.start_time:.2f}",
|
|
562
|
+
"" if sample.end_time is None else f"{sample.end_time:.2f}",
|
|
563
|
+
)
|
|
564
|
+
console.print(table)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
@people_embeddings_app.command("status")
|
|
568
|
+
def people_embeddings_status() -> None:
|
|
569
|
+
"""Show voice embedding coverage for known people."""
|
|
570
|
+
with database() as connection:
|
|
571
|
+
status = people_embedding_status(connection)
|
|
572
|
+
|
|
573
|
+
table = Table(title="People Voice Embeddings")
|
|
574
|
+
table.add_column("Metric")
|
|
575
|
+
table.add_column("Value")
|
|
576
|
+
table.add_row("People", str(status.people))
|
|
577
|
+
table.add_row("Voice samples", str(status.voice_samples))
|
|
578
|
+
table.add_row("Embedded voice samples", str(status.embedded_voice_samples))
|
|
579
|
+
table.add_row(
|
|
580
|
+
"Missing voice sample embeddings",
|
|
581
|
+
str(status.missing_voice_sample_embeddings),
|
|
582
|
+
)
|
|
583
|
+
console.print(table)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
@people_embeddings_app.command("backfill")
|
|
587
|
+
def people_embeddings_backfill() -> None:
|
|
588
|
+
"""Create missing voice embeddings for known people."""
|
|
589
|
+
try:
|
|
590
|
+
with database() as connection:
|
|
591
|
+
result = backfill_people_embeddings(connection)
|
|
592
|
+
except RuntimeError as exc:
|
|
593
|
+
console.print(str(exc))
|
|
594
|
+
raise typer.Exit(code=1) from exc
|
|
595
|
+
|
|
596
|
+
console.print(f"People voice embedding backfill complete: {result.embedded} embedded, {result.failed} failed.")
|
|
597
|
+
if result.embedded:
|
|
598
|
+
console.print("Next: fow refresh speakers")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
@secrets_app.command("status")
|
|
602
|
+
def secrets_status() -> None:
|
|
603
|
+
"""Show whether API keys are available without printing values."""
|
|
604
|
+
table = Table(title="Secrets")
|
|
605
|
+
table.add_column("Provider")
|
|
606
|
+
table.add_column("Source")
|
|
607
|
+
table.add_column("Env Var")
|
|
608
|
+
for provider in known_providers():
|
|
609
|
+
status = get_api_key_status(provider)
|
|
610
|
+
table.add_row(provider, status.source, status.env_var or "")
|
|
611
|
+
console.print(table)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@secrets_app.command("set")
|
|
615
|
+
def secrets_set(provider: str) -> None:
|
|
616
|
+
"""Store an API key in the OS keyring."""
|
|
617
|
+
value = typer.prompt(f"{provider} API key", hide_input=True)
|
|
618
|
+
try:
|
|
619
|
+
set_api_key(provider, value)
|
|
620
|
+
except SecretError as exc:
|
|
621
|
+
console.print(str(exc))
|
|
622
|
+
_print_secret_env_fallback(provider)
|
|
623
|
+
raise typer.Exit(code=1) from exc
|
|
624
|
+
console.print(f"Stored {provider.lower()} API key in OS keyring.")
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _print_secret_env_fallback(provider: str) -> None:
|
|
628
|
+
status = get_api_key_status(provider)
|
|
629
|
+
if status.env_var:
|
|
630
|
+
console.print(f"Alternative: set {status.env_var} in your shell environment.")
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@secrets_app.command("remove")
|
|
634
|
+
def secrets_remove(provider: str) -> None:
|
|
635
|
+
"""Remove an API key from the OS keyring."""
|
|
636
|
+
try:
|
|
637
|
+
remove_api_key(provider)
|
|
638
|
+
except SecretError as exc:
|
|
639
|
+
console.print(str(exc))
|
|
640
|
+
raise typer.Exit(code=1) from exc
|
|
641
|
+
console.print(f"Removed {provider.lower()} API key from OS keyring if it existed.")
|