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,169 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from sqlite3 import Connection
6
+ from uuid import uuid4
7
+
8
+ from fly_on_the_wall.service_pricing import get_service_price
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ServiceUsageRecord:
13
+ id: str
14
+ meeting_id: str | None
15
+ provider_run_id: str | None
16
+ provider: str
17
+ model: str
18
+ service: str
19
+ unit: str
20
+ input_quantity: float
21
+ output_quantity: float
22
+ estimated_cost_usd: float | None
23
+ currency: str
24
+
25
+
26
+ def record_service_usage(
27
+ connection: Connection,
28
+ *,
29
+ provider: str,
30
+ model: str,
31
+ service: str,
32
+ unit: str,
33
+ input_quantity: float = 0.0,
34
+ output_quantity: float = 0.0,
35
+ meeting_id: str | None = None,
36
+ provider_run_id: str | None = None,
37
+ cache_hit: bool = False,
38
+ billable: bool = True,
39
+ usage: dict | None = None,
40
+ ) -> ServiceUsageRecord:
41
+ price = get_service_price(connection, provider, model, service, unit)
42
+ if price is None and provider == "openai":
43
+ price = get_service_price(connection, provider, model, "chat", unit)
44
+ input_unit_price = None if price is None else price.input_unit_price_usd
45
+ output_unit_price = None if price is None else price.output_unit_price_usd
46
+ pricing = {} if price is None else price.pricing | {"service_price_id": price.id}
47
+ estimated_cost = None
48
+ if billable and input_unit_price is not None:
49
+ estimated_cost = input_quantity * input_unit_price
50
+ if output_unit_price is not None:
51
+ estimated_cost += output_quantity * output_unit_price
52
+
53
+ record_id = str(uuid4())
54
+ with connection:
55
+ connection.execute(
56
+ """
57
+ INSERT INTO service_usage(
58
+ id,
59
+ meeting_id,
60
+ provider_run_id,
61
+ provider,
62
+ model,
63
+ service,
64
+ unit,
65
+ input_quantity,
66
+ output_quantity,
67
+ cache_hit,
68
+ billable,
69
+ input_unit_price_usd,
70
+ output_unit_price_usd,
71
+ estimated_cost_usd,
72
+ currency,
73
+ usage_json,
74
+ pricing_json
75
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
76
+ """,
77
+ (
78
+ record_id,
79
+ meeting_id,
80
+ provider_run_id,
81
+ provider,
82
+ model,
83
+ service,
84
+ unit,
85
+ input_quantity,
86
+ output_quantity,
87
+ 1 if cache_hit else 0,
88
+ 1 if billable else 0,
89
+ input_unit_price,
90
+ output_unit_price,
91
+ estimated_cost,
92
+ "USD",
93
+ json.dumps(usage or {}, sort_keys=True),
94
+ json.dumps(pricing, sort_keys=True),
95
+ ),
96
+ )
97
+ return ServiceUsageRecord(
98
+ id=record_id,
99
+ meeting_id=meeting_id,
100
+ provider_run_id=provider_run_id,
101
+ provider=provider,
102
+ model=model,
103
+ service=service,
104
+ unit=unit,
105
+ input_quantity=input_quantity,
106
+ output_quantity=output_quantity,
107
+ estimated_cost_usd=estimated_cost,
108
+ currency="USD",
109
+ )
110
+
111
+
112
+ def record_openai_usage(
113
+ connection: Connection,
114
+ *,
115
+ meeting_id: str,
116
+ model: str,
117
+ service: str,
118
+ response: dict,
119
+ ) -> ServiceUsageRecord:
120
+ usage = response.get("usage") or {}
121
+ return record_service_usage(
122
+ connection,
123
+ meeting_id=meeting_id,
124
+ provider="openai",
125
+ model=model,
126
+ service=service,
127
+ unit="token",
128
+ input_quantity=float(usage.get("prompt_tokens") or usage.get("input_tokens") or 0),
129
+ output_quantity=float(usage.get("completion_tokens") or usage.get("output_tokens") or 0),
130
+ usage=usage,
131
+ )
132
+
133
+
134
+ def cost_summary(connection: Connection) -> list[dict]:
135
+ rows = connection.execute(
136
+ """
137
+ SELECT provider,
138
+ service,
139
+ COUNT(*) AS calls,
140
+ SUM(input_quantity) AS input_quantity,
141
+ SUM(output_quantity) AS output_quantity,
142
+ SUM(COALESCE(estimated_cost_usd, 0)) AS estimated_cost_usd
143
+ FROM service_usage
144
+ GROUP BY provider, service
145
+ ORDER BY provider, service
146
+ """
147
+ ).fetchall()
148
+ return [dict(row) for row in rows]
149
+
150
+
151
+ def meeting_cost_summary(connection: Connection, meeting_id_or_slug: str) -> list[dict]:
152
+ rows = connection.execute(
153
+ """
154
+ SELECT service_usage.provider,
155
+ service_usage.service,
156
+ service_usage.model,
157
+ COUNT(*) AS calls,
158
+ SUM(service_usage.input_quantity) AS input_quantity,
159
+ SUM(service_usage.output_quantity) AS output_quantity,
160
+ SUM(COALESCE(service_usage.estimated_cost_usd, 0)) AS estimated_cost_usd
161
+ FROM service_usage
162
+ JOIN meetings ON meetings.id = service_usage.meeting_id
163
+ WHERE meetings.id = ? OR meetings.slug = ?
164
+ GROUP BY service_usage.provider, service_usage.service, service_usage.model
165
+ ORDER BY service_usage.provider, service_usage.service, service_usage.model
166
+ """,
167
+ (meeting_id_or_slug, meeting_id_or_slug),
168
+ ).fetchall()
169
+ return [dict(row) for row in rows]
fly_on_the_wall/db.py ADDED
@@ -0,0 +1,508 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+ from pathlib import Path
8
+
9
+ from fly_on_the_wall.storage import ensure_storage_layout, storage_paths
10
+
11
+ SCHEMA_VERSION = 16
12
+
13
+ SCHEMA_STATEMENTS = (
14
+ """
15
+ CREATE TABLE IF NOT EXISTS schema_migrations (
16
+ version INTEGER PRIMARY KEY,
17
+ applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
18
+ )
19
+ """,
20
+ """
21
+ CREATE TABLE IF NOT EXISTS meetings (
22
+ id TEXT PRIMARY KEY,
23
+ slug TEXT NOT NULL UNIQUE,
24
+ title TEXT NOT NULL,
25
+ title_source TEXT NOT NULL DEFAULT 'manual',
26
+ generated_title TEXT,
27
+ description TEXT,
28
+ language TEXT NOT NULL,
29
+ original_audio_path TEXT,
30
+ imported_audio_path TEXT,
31
+ audio_sha256 TEXT UNIQUE,
32
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
33
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
34
+ )
35
+ """,
36
+ """
37
+ CREATE TABLE IF NOT EXISTS people (
38
+ id TEXT PRIMARY KEY,
39
+ display_name TEXT NOT NULL UNIQUE,
40
+ is_user INTEGER NOT NULL DEFAULT 0,
41
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
42
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
43
+ )
44
+ """,
45
+ """
46
+ CREATE TABLE IF NOT EXISTS pipeline_stages (
47
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
48
+ meeting_id TEXT NOT NULL,
49
+ stage_name TEXT NOT NULL,
50
+ status TEXT NOT NULL,
51
+ error_message TEXT,
52
+ started_at TEXT,
53
+ completed_at TEXT,
54
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
55
+ UNIQUE(meeting_id, stage_name),
56
+ FOREIGN KEY(meeting_id) REFERENCES meetings(id) ON DELETE CASCADE
57
+ )
58
+ """,
59
+ """
60
+ CREATE TABLE IF NOT EXISTS provider_runs (
61
+ id TEXT PRIMARY KEY,
62
+ meeting_id TEXT NOT NULL,
63
+ provider TEXT NOT NULL,
64
+ model TEXT NOT NULL,
65
+ settings_json TEXT NOT NULL DEFAULT '{}',
66
+ raw_response_path TEXT,
67
+ status TEXT NOT NULL,
68
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
69
+ completed_at TEXT,
70
+ FOREIGN KEY(meeting_id) REFERENCES meetings(id) ON DELETE CASCADE
71
+ )
72
+ """,
73
+ """
74
+ CREATE TABLE IF NOT EXISTS local_speakers (
75
+ id TEXT PRIMARY KEY,
76
+ meeting_id TEXT NOT NULL,
77
+ provider_run_id TEXT NOT NULL,
78
+ label TEXT NOT NULL,
79
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
80
+ UNIQUE(provider_run_id, label),
81
+ FOREIGN KEY(meeting_id) REFERENCES meetings(id) ON DELETE CASCADE,
82
+ FOREIGN KEY(provider_run_id) REFERENCES provider_runs(id) ON DELETE CASCADE
83
+ )
84
+ """,
85
+ """
86
+ CREATE TABLE IF NOT EXISTS segments (
87
+ id TEXT PRIMARY KEY,
88
+ meeting_id TEXT NOT NULL,
89
+ provider_run_id TEXT NOT NULL,
90
+ local_speaker_id TEXT,
91
+ sequence INTEGER NOT NULL,
92
+ start_time REAL,
93
+ end_time REAL,
94
+ text TEXT NOT NULL,
95
+ language TEXT,
96
+ source_json TEXT NOT NULL DEFAULT '{}',
97
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
98
+ UNIQUE(provider_run_id, sequence),
99
+ FOREIGN KEY(meeting_id) REFERENCES meetings(id) ON DELETE CASCADE,
100
+ FOREIGN KEY(provider_run_id) REFERENCES provider_runs(id) ON DELETE CASCADE,
101
+ FOREIGN KEY(local_speaker_id) REFERENCES local_speakers(id) ON DELETE SET NULL
102
+ )
103
+ """,
104
+ """
105
+ CREATE TABLE IF NOT EXISTS voice_samples (
106
+ id TEXT PRIMARY KEY,
107
+ person_id TEXT NOT NULL,
108
+ source_meeting_id TEXT,
109
+ source_local_speaker_id TEXT,
110
+ start_time REAL,
111
+ end_time REAL,
112
+ audio_path TEXT NOT NULL,
113
+ embedding_model TEXT,
114
+ embedding_path TEXT,
115
+ quality_score REAL,
116
+ confirmed_by_user INTEGER NOT NULL DEFAULT 1,
117
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
118
+ FOREIGN KEY(person_id) REFERENCES people(id) ON DELETE CASCADE,
119
+ FOREIGN KEY(source_meeting_id) REFERENCES meetings(id) ON DELETE SET NULL,
120
+ FOREIGN KEY(source_local_speaker_id) REFERENCES local_speakers(id) ON DELETE SET NULL
121
+ )
122
+ """,
123
+ """
124
+ CREATE TABLE IF NOT EXISTS local_speaker_embeddings (
125
+ id TEXT PRIMARY KEY,
126
+ local_speaker_id TEXT NOT NULL,
127
+ audio_path TEXT NOT NULL,
128
+ embedding_model TEXT NOT NULL,
129
+ embedding_path TEXT NOT NULL,
130
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
131
+ UNIQUE(local_speaker_id, embedding_model),
132
+ FOREIGN KEY(local_speaker_id) REFERENCES local_speakers(id) ON DELETE CASCADE
133
+ )
134
+ """,
135
+ """
136
+ CREATE TABLE IF NOT EXISTS speaker_assignments (
137
+ id TEXT PRIMARY KEY,
138
+ local_speaker_id TEXT NOT NULL,
139
+ person_id TEXT,
140
+ status TEXT NOT NULL,
141
+ confidence REAL,
142
+ margin REAL,
143
+ evidence_json TEXT NOT NULL DEFAULT '{}',
144
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
145
+ UNIQUE(local_speaker_id),
146
+ FOREIGN KEY(local_speaker_id) REFERENCES local_speakers(id) ON DELETE CASCADE,
147
+ FOREIGN KEY(person_id) REFERENCES people(id) ON DELETE SET NULL
148
+ )
149
+ """,
150
+ """
151
+ CREATE TABLE IF NOT EXISTS exports (
152
+ id TEXT PRIMARY KEY,
153
+ meeting_id TEXT NOT NULL,
154
+ format TEXT NOT NULL,
155
+ output_dir TEXT NOT NULL,
156
+ manifest_path TEXT NOT NULL,
157
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
158
+ FOREIGN KEY(meeting_id) REFERENCES meetings(id) ON DELETE CASCADE
159
+ )
160
+ """,
161
+ """
162
+ CREATE TABLE IF NOT EXISTS corrections (
163
+ id TEXT PRIMARY KEY,
164
+ correction_type TEXT NOT NULL,
165
+ meeting_id TEXT,
166
+ local_speaker_id TEXT,
167
+ person_id TEXT,
168
+ details_json TEXT NOT NULL DEFAULT '{}',
169
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
170
+ FOREIGN KEY(meeting_id) REFERENCES meetings(id) ON DELETE SET NULL,
171
+ FOREIGN KEY(local_speaker_id) REFERENCES local_speakers(id) ON DELETE SET NULL,
172
+ FOREIGN KEY(person_id) REFERENCES people(id) ON DELETE SET NULL
173
+ )
174
+ """,
175
+ """
176
+ CREATE TABLE IF NOT EXISTS audio_metadata (
177
+ id TEXT PRIMARY KEY,
178
+ meeting_id TEXT NOT NULL UNIQUE,
179
+ raw_metadata_path TEXT,
180
+ recorded_at TEXT,
181
+ recorded_at_source TEXT,
182
+ recorded_at_confidence TEXT,
183
+ duration_seconds REAL,
184
+ size_bytes INTEGER,
185
+ bit_rate INTEGER,
186
+ codec TEXT,
187
+ sample_rate INTEGER,
188
+ channels INTEGER,
189
+ channel_layout TEXT,
190
+ container_format TEXT,
191
+ metadata_title TEXT,
192
+ metadata_artist TEXT,
193
+ metadata_album TEXT,
194
+ metadata_genre TEXT,
195
+ metadata_comment TEXT,
196
+ metadata_encoder TEXT,
197
+ device_or_software TEXT,
198
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
199
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
200
+ FOREIGN KEY(meeting_id) REFERENCES meetings(id) ON DELETE CASCADE
201
+ )
202
+ """,
203
+ """
204
+ CREATE TABLE IF NOT EXISTS recording_quality (
205
+ id TEXT PRIMARY KEY,
206
+ meeting_id TEXT NOT NULL UNIQUE,
207
+ status TEXT NOT NULL,
208
+ reason TEXT NOT NULL,
209
+ details_json TEXT NOT NULL DEFAULT '{}',
210
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
211
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
212
+ FOREIGN KEY(meeting_id) REFERENCES meetings(id) ON DELETE CASCADE
213
+ )
214
+ """,
215
+ """
216
+ CREATE TABLE IF NOT EXISTS watch_folders (
217
+ id TEXT PRIMARY KEY,
218
+ name TEXT UNIQUE,
219
+ path TEXT NOT NULL UNIQUE,
220
+ enabled INTEGER NOT NULL DEFAULT 1,
221
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
222
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
223
+ )
224
+ """,
225
+ """
226
+ CREATE TABLE IF NOT EXISTS watch_items (
227
+ id TEXT PRIMARY KEY,
228
+ folder_id TEXT NOT NULL,
229
+ path TEXT NOT NULL UNIQUE,
230
+ file_sha256 TEXT,
231
+ size_bytes INTEGER,
232
+ mtime_ns INTEGER,
233
+ status TEXT NOT NULL,
234
+ meeting_id TEXT,
235
+ error_message TEXT,
236
+ first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
237
+ last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
238
+ processed_at TEXT,
239
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
240
+ FOREIGN KEY(folder_id) REFERENCES watch_folders(id) ON DELETE CASCADE,
241
+ FOREIGN KEY(meeting_id) REFERENCES meetings(id) ON DELETE SET NULL
242
+ )
243
+ """,
244
+ """
245
+ CREATE TABLE IF NOT EXISTS publish_targets (
246
+ id TEXT PRIMARY KEY,
247
+ name TEXT NOT NULL UNIQUE,
248
+ target_type TEXT NOT NULL,
249
+ path TEXT NOT NULL,
250
+ settings_json TEXT NOT NULL DEFAULT '{}',
251
+ auto_publish INTEGER NOT NULL DEFAULT 0,
252
+ enabled INTEGER NOT NULL DEFAULT 1,
253
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
254
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
255
+ )
256
+ """,
257
+ """
258
+ CREATE TABLE IF NOT EXISTS published_items (
259
+ id TEXT PRIMARY KEY,
260
+ meeting_id TEXT NOT NULL,
261
+ target_id TEXT NOT NULL,
262
+ output_path TEXT NOT NULL,
263
+ content_sha256 TEXT NOT NULL,
264
+ published_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
265
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
266
+ UNIQUE(meeting_id, target_id),
267
+ FOREIGN KEY(meeting_id) REFERENCES meetings(id) ON DELETE CASCADE,
268
+ FOREIGN KEY(target_id) REFERENCES publish_targets(id) ON DELETE CASCADE
269
+ )
270
+ """,
271
+ """
272
+ CREATE TABLE IF NOT EXISTS service_prices (
273
+ id TEXT PRIMARY KEY,
274
+ provider TEXT NOT NULL,
275
+ model TEXT NOT NULL,
276
+ service TEXT NOT NULL,
277
+ unit TEXT NOT NULL,
278
+ input_unit_price_usd REAL,
279
+ output_unit_price_usd REAL,
280
+ cached_input_unit_price_usd REAL,
281
+ currency TEXT NOT NULL DEFAULT 'USD',
282
+ source_name TEXT NOT NULL,
283
+ source_url TEXT,
284
+ pricing_json TEXT NOT NULL DEFAULT '{}',
285
+ active INTEGER NOT NULL DEFAULT 1,
286
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
287
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
288
+ UNIQUE(provider, model, service, unit, source_name)
289
+ )
290
+ """,
291
+ """
292
+ CREATE TABLE IF NOT EXISTS service_usage (
293
+ id TEXT PRIMARY KEY,
294
+ meeting_id TEXT,
295
+ provider_run_id TEXT,
296
+ provider TEXT NOT NULL,
297
+ model TEXT NOT NULL,
298
+ service TEXT NOT NULL,
299
+ unit TEXT NOT NULL,
300
+ input_quantity REAL NOT NULL DEFAULT 0,
301
+ output_quantity REAL NOT NULL DEFAULT 0,
302
+ cache_hit INTEGER NOT NULL DEFAULT 0,
303
+ billable INTEGER NOT NULL DEFAULT 1,
304
+ input_unit_price_usd REAL,
305
+ output_unit_price_usd REAL,
306
+ estimated_cost_usd REAL,
307
+ currency TEXT NOT NULL DEFAULT 'USD',
308
+ usage_json TEXT NOT NULL DEFAULT '{}',
309
+ pricing_json TEXT NOT NULL DEFAULT '{}',
310
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
311
+ FOREIGN KEY(meeting_id) REFERENCES meetings(id) ON DELETE CASCADE,
312
+ FOREIGN KEY(provider_run_id) REFERENCES provider_runs(id) ON DELETE SET NULL
313
+ )
314
+ """,
315
+ )
316
+
317
+
318
+ DEFAULT_SERVICE_PRICES = (
319
+ {
320
+ "id": "openai:gpt-5.4-mini:chat:token",
321
+ "provider": "openai",
322
+ "model": "gpt-5.4-mini",
323
+ "service": "chat",
324
+ "unit": "token",
325
+ "input_unit_price_usd": 0.00000075,
326
+ "output_unit_price_usd": 0.0000045,
327
+ "cached_input_unit_price_usd": 0.000000075,
328
+ "source_name": "openai-pricing",
329
+ "source_url": "https://openai.com/api/pricing/",
330
+ "pricing_json": {
331
+ "input_1m_tokens_usd": 0.75,
332
+ "output_1m_tokens_usd": 4.50,
333
+ "cached_input_1m_tokens_usd": 0.075,
334
+ "litellm_key": "gpt-5.4-mini",
335
+ "litellm_price_fields": {
336
+ "input_cost_per_token": 0.00000075,
337
+ "output_cost_per_token": 0.0000045,
338
+ "cache_read_input_token_cost": 0.000000075,
339
+ },
340
+ },
341
+ },
342
+ {
343
+ "id": "openai:gpt-5.4-nano:chat:token",
344
+ "provider": "openai",
345
+ "model": "gpt-5.4-nano",
346
+ "service": "chat",
347
+ "unit": "token",
348
+ "input_unit_price_usd": 0.0000002,
349
+ "output_unit_price_usd": 0.00000125,
350
+ "cached_input_unit_price_usd": 0.00000002,
351
+ "source_name": "openai-pricing",
352
+ "source_url": "https://openai.com/api/pricing/",
353
+ "pricing_json": {
354
+ "input_1m_tokens_usd": 0.20,
355
+ "output_1m_tokens_usd": 1.25,
356
+ "cached_input_1m_tokens_usd": 0.02,
357
+ "litellm_key": "gpt-5.4-nano",
358
+ "litellm_price_fields": {
359
+ "input_cost_per_token": 0.0000002,
360
+ "output_cost_per_token": 0.00000125,
361
+ "cache_read_input_token_cost": 0.00000002,
362
+ },
363
+ },
364
+ },
365
+ {
366
+ "id": "elevenlabs:scribe_v1:transcription:audio_second",
367
+ "provider": "elevenlabs",
368
+ "model": "scribe_v1",
369
+ "service": "transcription",
370
+ "unit": "audio_second",
371
+ "input_unit_price_usd": 0.0000611,
372
+ "output_unit_price_usd": 0.0,
373
+ "cached_input_unit_price_usd": None,
374
+ "source_name": "elevenlabs-pricing",
375
+ "source_url": "https://elevenlabs.io/pricing",
376
+ "pricing_json": {
377
+ "input_audio_hour_usd": 0.22,
378
+ "input_audio_second_usd": 0.0000611,
379
+ "litellm_key": "elevenlabs/scribe_v1",
380
+ "litellm_price_fields": {"input_cost_per_second": 0.0000611},
381
+ "note": "LiteLLM describes this as enterprise pricing from ElevenLabs pricing.",
382
+ },
383
+ },
384
+ {
385
+ "id": "elevenlabs:scribe_v2:transcription:audio_second",
386
+ "provider": "elevenlabs",
387
+ "model": "scribe_v2",
388
+ "service": "transcription",
389
+ "unit": "audio_second",
390
+ "input_unit_price_usd": 0.0000611,
391
+ "output_unit_price_usd": 0.0,
392
+ "cached_input_unit_price_usd": None,
393
+ "source_name": "elevenlabs-pricing",
394
+ "source_url": "https://elevenlabs.io/pricing",
395
+ "pricing_json": {
396
+ "input_audio_hour_usd": 0.22,
397
+ "input_audio_second_usd": 0.0000611,
398
+ "litellm_fallback_key": "elevenlabs/scribe_v1",
399
+ "inferred_for_model": "scribe_v2",
400
+ "litellm_price_fields": {"input_cost_per_second": 0.0000611},
401
+ "note": ("Exact scribe_v2 entry was not present in LiteLLM; seeded from Scribe pricing fallback."),
402
+ },
403
+ },
404
+ )
405
+
406
+
407
+ def connect(database_path: Path | None = None) -> sqlite3.Connection:
408
+ if database_path is None:
409
+ database_path = storage_paths().database
410
+ ensure_storage_layout()
411
+
412
+ database_path.parent.mkdir(parents=True, exist_ok=True)
413
+ connection = sqlite3.connect(database_path)
414
+ connection.row_factory = sqlite3.Row
415
+ connection.execute("PRAGMA foreign_keys = ON")
416
+ return connection
417
+
418
+
419
+ def initialize_database(connection: sqlite3.Connection) -> None:
420
+ with connection:
421
+ for statement in SCHEMA_STATEMENTS:
422
+ connection.execute(statement)
423
+ _ensure_column(connection, "meetings", "audio_sha256", "TEXT")
424
+ _ensure_column(connection, "meetings", "title_source", "TEXT NOT NULL DEFAULT 'manual'")
425
+ _ensure_column(connection, "meetings", "generated_title", "TEXT")
426
+ _ensure_column(connection, "people", "is_user", "INTEGER NOT NULL DEFAULT 0")
427
+ connection.execute(
428
+ """
429
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_meetings_audio_sha256
430
+ ON meetings(audio_sha256)
431
+ WHERE audio_sha256 IS NOT NULL
432
+ """
433
+ )
434
+ connection.execute(
435
+ "INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)",
436
+ (SCHEMA_VERSION,),
437
+ )
438
+ _seed_default_service_prices(connection)
439
+
440
+
441
+ def _ensure_column(connection: sqlite3.Connection, table_name: str, column_name: str, definition: str) -> None:
442
+ columns = {row["name"] for row in connection.execute(f"PRAGMA table_info({table_name})").fetchall()}
443
+ if column_name not in columns:
444
+ connection.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {definition}")
445
+
446
+
447
+ def _seed_default_service_prices(connection: sqlite3.Connection) -> None:
448
+ for price in DEFAULT_SERVICE_PRICES:
449
+ connection.execute(
450
+ """
451
+ INSERT INTO service_prices(
452
+ id,
453
+ provider,
454
+ model,
455
+ service,
456
+ unit,
457
+ input_unit_price_usd,
458
+ output_unit_price_usd,
459
+ cached_input_unit_price_usd,
460
+ currency,
461
+ source_name,
462
+ source_url,
463
+ pricing_json,
464
+ active
465
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
466
+ ON CONFLICT(provider, model, service, unit, source_name) DO UPDATE SET
467
+ input_unit_price_usd = excluded.input_unit_price_usd,
468
+ output_unit_price_usd = excluded.output_unit_price_usd,
469
+ cached_input_unit_price_usd = excluded.cached_input_unit_price_usd,
470
+ currency = excluded.currency,
471
+ source_url = excluded.source_url,
472
+ pricing_json = excluded.pricing_json,
473
+ active = excluded.active,
474
+ updated_at = CURRENT_TIMESTAMP
475
+ """,
476
+ (
477
+ price["id"],
478
+ price["provider"],
479
+ price["model"],
480
+ price["service"],
481
+ price["unit"],
482
+ price["input_unit_price_usd"],
483
+ price["output_unit_price_usd"],
484
+ price["cached_input_unit_price_usd"],
485
+ "USD",
486
+ price["source_name"],
487
+ price["source_url"],
488
+ json.dumps(price["pricing_json"], sort_keys=True),
489
+ 1,
490
+ ),
491
+ )
492
+
493
+
494
+ def bootstrap_database(database_path: Path | None = None) -> Path:
495
+ path = database_path or storage_paths().database
496
+ with connect(path) as connection:
497
+ initialize_database(connection)
498
+ return path
499
+
500
+
501
+ @contextmanager
502
+ def database(database_path: Path | None = None) -> Iterator[sqlite3.Connection]:
503
+ connection = connect(database_path)
504
+ try:
505
+ initialize_database(connection)
506
+ yield connection
507
+ finally:
508
+ connection.close()