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/costs.py
ADDED
|
@@ -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()
|