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.
Files changed (46) hide show
  1. fly_on_the_wall/__init__.py +3 -0
  2. fly_on_the_wall/audio.py +164 -0
  3. fly_on_the_wall/audio_metadata.py +241 -0
  4. fly_on_the_wall/cache.py +26 -0
  5. fly_on_the_wall/cleanup.py +29 -0
  6. fly_on_the_wall/cli.py +641 -0
  7. fly_on_the_wall/cli_costs.py +81 -0
  8. fly_on_the_wall/cli_menu.py +163 -0
  9. fly_on_the_wall/cli_publish.py +141 -0
  10. fly_on_the_wall/cli_speaker_review.py +315 -0
  11. fly_on_the_wall/cli_watch.py +209 -0
  12. fly_on_the_wall/config.py +92 -0
  13. fly_on_the_wall/costs.py +169 -0
  14. fly_on_the_wall/db.py +508 -0
  15. fly_on_the_wall/doctor.py +142 -0
  16. fly_on_the_wall/embeddings.py +142 -0
  17. fly_on_the_wall/exporting.py +155 -0
  18. fly_on_the_wall/glossary.py +31 -0
  19. fly_on_the_wall/meetings.py +382 -0
  20. fly_on_the_wall/normalization.py +166 -0
  21. fly_on_the_wall/people.py +82 -0
  22. fly_on_the_wall/people_embeddings.py +68 -0
  23. fly_on_the_wall/pipeline.py +120 -0
  24. fly_on_the_wall/processing.py +427 -0
  25. fly_on_the_wall/providers/__init__.py +1 -0
  26. fly_on_the_wall/providers/elevenlabs.py +145 -0
  27. fly_on_the_wall/providers/openai_analysis.py +195 -0
  28. fly_on_the_wall/providers/openai_cleanup.py +91 -0
  29. fly_on_the_wall/publishing.py +410 -0
  30. fly_on_the_wall/reanalysis.py +172 -0
  31. fly_on_the_wall/recording_quality.py +141 -0
  32. fly_on_the_wall/rendering.py +115 -0
  33. fly_on_the_wall/secrets.py +93 -0
  34. fly_on_the_wall/service_pricing.py +75 -0
  35. fly_on_the_wall/setup.py +221 -0
  36. fly_on_the_wall/speaker_identity.py +173 -0
  37. fly_on_the_wall/speaker_matching.py +134 -0
  38. fly_on_the_wall/speakers.py +221 -0
  39. fly_on_the_wall/storage.py +53 -0
  40. fly_on_the_wall/voice_samples.py +125 -0
  41. fly_on_the_wall/watch.py +347 -0
  42. fow_cli-0.1.0.dist-info/METADATA +447 -0
  43. fow_cli-0.1.0.dist-info/RECORD +46 -0
  44. fow_cli-0.1.0.dist-info/WHEEL +4 -0
  45. fow_cli-0.1.0.dist-info/entry_points.txt +2 -0
  46. fow_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from fly_on_the_wall.costs import cost_summary, meeting_cost_summary
8
+ from fly_on_the_wall.db import database
9
+
10
+ console = Console()
11
+ costs_app = typer.Typer(help="Inspect external service usage and estimated costs.", no_args_is_help=True)
12
+
13
+
14
+ @costs_app.command("summary")
15
+ def costs_summary() -> None:
16
+ """Show estimated external service costs by provider and service."""
17
+ with database() as connection:
18
+ rows = cost_summary(connection)
19
+ if not rows:
20
+ console.print("No service usage recorded yet.")
21
+ return
22
+ table = Table(title="Estimated Service Costs")
23
+ table.add_column("Provider")
24
+ table.add_column("Service")
25
+ table.add_column("Calls", justify="right")
26
+ table.add_column("Input", justify="right")
27
+ table.add_column("Output", justify="right")
28
+ table.add_column("Estimated Cost", justify="right")
29
+ for row in rows:
30
+ table.add_row(
31
+ row["provider"],
32
+ row["service"],
33
+ str(row["calls"]),
34
+ _format_quantity(row["input_quantity"]),
35
+ _format_quantity(row["output_quantity"]),
36
+ _format_usd(row["estimated_cost_usd"]),
37
+ )
38
+ console.print(table)
39
+
40
+
41
+ @costs_app.command("meeting")
42
+ def costs_meeting(meeting: str) -> None:
43
+ """Show estimated external service costs for one meeting."""
44
+ with database() as connection:
45
+ rows = meeting_cost_summary(connection, meeting)
46
+ if not rows:
47
+ console.print(f"No service usage recorded for meeting: {meeting}")
48
+ return
49
+ table = Table(title=f"Estimated Service Costs: {meeting}")
50
+ table.add_column("Provider")
51
+ table.add_column("Service")
52
+ table.add_column("Model")
53
+ table.add_column("Calls", justify="right")
54
+ table.add_column("Input", justify="right")
55
+ table.add_column("Output", justify="right")
56
+ table.add_column("Estimated Cost", justify="right")
57
+ for row in rows:
58
+ table.add_row(
59
+ row["provider"],
60
+ row["service"],
61
+ row["model"],
62
+ str(row["calls"]),
63
+ _format_quantity(row["input_quantity"]),
64
+ _format_quantity(row["output_quantity"]),
65
+ _format_usd(row["estimated_cost_usd"]),
66
+ )
67
+ console.print(table)
68
+
69
+
70
+ def _format_usd(value: float | None) -> str:
71
+ if value is None:
72
+ return "unknown"
73
+ return f"${value:.4f}"
74
+
75
+
76
+ def _format_quantity(value: float | None) -> str:
77
+ if value is None:
78
+ return "0"
79
+ if float(value).is_integer():
80
+ return str(int(value))
81
+ return f"{value:.2f}"
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import threading
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from prompt_toolkit.application import Application
9
+ from prompt_toolkit.key_binding import KeyBindings
10
+ from prompt_toolkit.layout import HSplit, Layout, Window
11
+ from prompt_toolkit.layout.controls import FormattedTextControl
12
+
13
+ from fly_on_the_wall.audio import AudioError, start_audio_playback, stop_audio_playback
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class MenuChoice:
18
+ shortcut: str | None
19
+ label: str
20
+ value: str | None
21
+ playback_path: Path | None = None
22
+
23
+
24
+ def select_menu(title: str, choices: list[MenuChoice]) -> str | None:
25
+ return InteractiveMenu(title, choices).run()
26
+
27
+
28
+ class InteractiveMenu:
29
+ def __init__(self, title: str, choices: list[MenuChoice]) -> None:
30
+ self.title = title
31
+ self.choices = choices
32
+ self.selected_index = 0
33
+ self.selected_value: str | None = None
34
+ self.status_message = ""
35
+ self.playback_process = None
36
+ self.key_bindings = KeyBindings()
37
+ self.application = self._build_application()
38
+
39
+ def run(self) -> str | None:
40
+ try:
41
+ self.application.run()
42
+ except (KeyboardInterrupt, EOFError):
43
+ return None
44
+ return self.selected_value
45
+
46
+ def _build_application(self) -> Application:
47
+ self._bind_navigation_keys()
48
+ self._bind_shortcut_keys()
49
+ control = FormattedTextControl(self._render_menu, focusable=True)
50
+ return Application(
51
+ layout=Layout(HSplit([Window(content=control, always_hide_cursor=True)])),
52
+ key_bindings=self.key_bindings,
53
+ full_screen=False,
54
+ style=None,
55
+ )
56
+
57
+ def _bind_navigation_keys(self) -> None:
58
+ @self.key_bindings.add("up")
59
+ def _up(_event) -> None:
60
+ self._move(-1)
61
+
62
+ @self.key_bindings.add("down")
63
+ def _down(_event) -> None:
64
+ self._move(1)
65
+
66
+ @self.key_bindings.add("enter")
67
+ def _enter(_event) -> None:
68
+ if self._playback_is_running():
69
+ self._stop_playback()
70
+ return
71
+ self._finish(self.choices[self.selected_index])
72
+
73
+ @self.key_bindings.add("escape")
74
+ @self.key_bindings.add("c-c")
75
+ def _cancel(_event) -> None:
76
+ self._cancel()
77
+
78
+ def _bind_shortcut_keys(self) -> None:
79
+ bound_shortcuts: set[str] = set()
80
+ for choice in self.choices:
81
+ if choice.shortcut is None or choice.shortcut in bound_shortcuts:
82
+ continue
83
+ bound_shortcuts.add(choice.shortcut)
84
+ self.key_bindings.add(choice.shortcut)(lambda _event, selected=choice: self._finish(selected))
85
+
86
+ def _finish(self, choice: MenuChoice) -> None:
87
+ if choice.playback_path is not None:
88
+ self._toggle_playback(choice.playback_path)
89
+ return
90
+ self._stop_playback()
91
+ self.selected_value = choice.value
92
+ self.application.exit()
93
+
94
+ def _cancel(self) -> None:
95
+ self._stop_playback()
96
+ self.selected_value = None
97
+ self.application.exit()
98
+
99
+ def _toggle_playback(self, audio_path: Path) -> None:
100
+ if self._playback_is_running():
101
+ self._stop_playback()
102
+ return
103
+ try:
104
+ self.playback_process = start_audio_playback(audio_path)
105
+ except AudioError as exc:
106
+ self.status_message = f"Could not play clip: {exc}"
107
+ self.application.invalidate()
108
+ return
109
+ self.status_message = "Playing. Press Enter to stop."
110
+ self._watch_playback_completion(self.playback_process)
111
+ self.application.invalidate()
112
+
113
+ def _stop_playback(self) -> None:
114
+ if self.playback_process is not None:
115
+ stop_audio_playback(self.playback_process)
116
+ self.playback_process = None
117
+ self.status_message = ""
118
+ self.application.invalidate()
119
+
120
+ def _move(self, offset: int) -> None:
121
+ self.selected_index = (self.selected_index + offset) % len(self.choices)
122
+ self.application.invalidate()
123
+
124
+ def _render_menu(self):
125
+ self._clear_finished_playback()
126
+ lines = [("class:title", f"{self.title}\n")]
127
+ lines.extend(self._choice_lines())
128
+ if self.status_message:
129
+ lines.append(("class:status", f"\n{self.status_message}\n"))
130
+ lines.append(("class:help", "\nUse arrows, Enter, shortcut key, or Esc to cancel."))
131
+ return lines
132
+
133
+ def _choice_lines(self) -> list[tuple[str, str]]:
134
+ lines = []
135
+ for index, choice in enumerate(self.choices):
136
+ prefix = ">" if index == self.selected_index else " "
137
+ style = "class:selected" if index == self.selected_index else ""
138
+ shortcut = f"[{choice.shortcut}] " if choice.shortcut is not None else ""
139
+ lines.append((style, f"{prefix} {shortcut}{choice.label}\n"))
140
+ return lines
141
+
142
+ def _playback_is_running(self) -> bool:
143
+ return self.playback_process is not None and self.playback_process.poll() is None
144
+
145
+ def _watch_playback_completion(self, process: subprocess.Popen) -> None:
146
+ thread = threading.Thread(target=self._wait_for_playback_completion, args=(process,), daemon=True)
147
+ thread.start()
148
+
149
+ def _wait_for_playback_completion(self, process: subprocess.Popen) -> None:
150
+ process.wait()
151
+ if self._clear_finished_playback(process):
152
+ self.application.invalidate()
153
+
154
+ def _clear_finished_playback(self, process: subprocess.Popen | None = None) -> bool:
155
+ if self.playback_process is None:
156
+ return False
157
+ if process is not None and self.playback_process is not process:
158
+ return False
159
+ if self.playback_process.poll() is None:
160
+ return False
161
+ self.playback_process = None
162
+ self.status_message = ""
163
+ return True
@@ -0,0 +1,141 @@
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.db import database
11
+ from fly_on_the_wall.publishing import (
12
+ add_publish_target,
13
+ list_publish_targets,
14
+ publish_all_meetings,
15
+ publish_meeting,
16
+ remove_publish_target,
17
+ set_publish_target_enabled,
18
+ )
19
+
20
+ console = Console()
21
+ publish_app = typer.Typer(help="Publish meetings to external targets.", no_args_is_help=True)
22
+ publish_targets_app = typer.Typer(help="Manage publish targets.", no_args_is_help=True)
23
+ publish_app.add_typer(publish_targets_app, name="targets")
24
+
25
+
26
+ @publish_app.command("meeting")
27
+ def publish_meeting_command(
28
+ meeting: str,
29
+ target: Annotated[str, typer.Option("--target", "-t", help="Publish target name or id.")],
30
+ ) -> None:
31
+ """Publish one meeting to a configured target."""
32
+ with database() as connection:
33
+ try:
34
+ result = publish_meeting(connection, meeting, target)
35
+ except ValueError as exc:
36
+ console.print(str(exc))
37
+ raise typer.Exit(code=1) from exc
38
+ console.print(f"Published {meeting} to {result.target.name}")
39
+ console.print(f"Output: {result.output_path}")
40
+
41
+
42
+ @publish_app.command("all")
43
+ def publish_all_command(
44
+ target: Annotated[str, typer.Option("--target", "-t", help="Publish target name or id.")],
45
+ only_unpublished: Annotated[
46
+ bool,
47
+ typer.Option("--only-unpublished", help="Skip meetings already published to this target."),
48
+ ] = False,
49
+ ) -> None:
50
+ """Publish all exported meetings to a configured target."""
51
+ with database() as connection:
52
+ try:
53
+ results = publish_all_meetings(connection, target, only_unpublished)
54
+ except ValueError as exc:
55
+ console.print(str(exc))
56
+ raise typer.Exit(code=1) from exc
57
+
58
+ if not results:
59
+ console.print("No meetings to publish.")
60
+ return
61
+ for result in results:
62
+ console.print(f"Published to {result.target.name}: {result.output_path}")
63
+ console.print(f"Published {len(results)} meeting(s).")
64
+
65
+
66
+ @publish_targets_app.command("add")
67
+ def publish_targets_add(
68
+ target_type: str,
69
+ path: Annotated[Path, typer.Argument(file_okay=False, dir_okay=True)],
70
+ name: Annotated[str, typer.Option("--name", "-n", help="Target name.")],
71
+ auto_publish: Annotated[
72
+ bool, typer.Option("--auto-publish", help="Publish processed meetings automatically.")
73
+ ] = False,
74
+ ) -> None:
75
+ """Add an external publish target."""
76
+ with database() as connection:
77
+ try:
78
+ target = add_publish_target(connection, target_type, path, name, auto_publish)
79
+ except Exception as exc:
80
+ console.print(str(exc))
81
+ raise typer.Exit(code=1) from exc
82
+ console.print(f"Added {target.target_type} publish target {target.name}")
83
+ console.print(f"Path: {target.path}")
84
+
85
+
86
+ @publish_targets_app.command("list")
87
+ def publish_targets_list() -> None:
88
+ """List publish targets."""
89
+ with database() as connection:
90
+ targets = list_publish_targets(connection)
91
+ if not targets:
92
+ console.print("No publish targets configured.")
93
+ return
94
+ table = Table(title="Publish Targets")
95
+ table.add_column("Name")
96
+ table.add_column("Type")
97
+ table.add_column("Auto")
98
+ table.add_column("Enabled")
99
+ table.add_column("Path")
100
+ for target in targets:
101
+ table.add_row(
102
+ target.name,
103
+ target.target_type,
104
+ "yes" if target.auto_publish else "no",
105
+ "yes" if target.enabled else "no",
106
+ str(target.path),
107
+ )
108
+ console.print(table)
109
+
110
+
111
+ @publish_targets_app.command("remove")
112
+ def publish_targets_remove(identifier: str) -> None:
113
+ """Remove a publish target by id or name."""
114
+ with database() as connection:
115
+ target = remove_publish_target(connection, identifier)
116
+ if target is None:
117
+ console.print(f"Publish target not found: {identifier}")
118
+ raise typer.Exit(code=1)
119
+ console.print(f"Removed publish target {target.name}")
120
+
121
+
122
+ @publish_targets_app.command("enable")
123
+ def publish_targets_enable(identifier: str) -> None:
124
+ """Enable a publish target by id or name."""
125
+ _set_publish_target_enabled_command(identifier, True)
126
+
127
+
128
+ @publish_targets_app.command("disable")
129
+ def publish_targets_disable(identifier: str) -> None:
130
+ """Disable a publish target by id or name."""
131
+ _set_publish_target_enabled_command(identifier, False)
132
+
133
+
134
+ def _set_publish_target_enabled_command(identifier: str, enabled: bool) -> None:
135
+ with database() as connection:
136
+ target = set_publish_target_enabled(connection, identifier, enabled)
137
+ if target is None:
138
+ console.print(f"Publish target not found: {identifier}")
139
+ raise typer.Exit(code=1)
140
+ state = "Enabled" if enabled else "Disabled"
141
+ console.print(f"{state} publish target {target.name}")