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,172 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from sqlite3 import Connection
5
+
6
+ from fly_on_the_wall.meetings import get_meeting
7
+ from fly_on_the_wall.pipeline import STALE, set_stage_status
8
+ from fly_on_the_wall.speaker_identity import match_provider_run_speakers
9
+
10
+ SPEAKER_DEPENDENT_STAGES = ("speaker_matching", "render", "cleanup", "export")
11
+
12
+
13
+ def mark_speaker_reanalysis_stale(connection: Connection, meeting_id_or_slug: str) -> list[str]:
14
+ meeting = get_meeting(connection, meeting_id_or_slug)
15
+ if meeting is None:
16
+ raise ValueError(f"Meeting not found: {meeting_id_or_slug}")
17
+ for stage in SPEAKER_DEPENDENT_STAGES:
18
+ set_stage_status(connection, meeting["id"], stage, STALE)
19
+ return list(SPEAKER_DEPENDENT_STAGES)
20
+
21
+
22
+ def list_stale_stages(connection: Connection) -> list[dict]:
23
+ rows = connection.execute(
24
+ """
25
+ SELECT meetings.slug AS meeting_slug,
26
+ pipeline_stages.meeting_id,
27
+ pipeline_stages.stage_name,
28
+ pipeline_stages.updated_at
29
+ FROM pipeline_stages
30
+ JOIN meetings ON meetings.id = pipeline_stages.meeting_id
31
+ WHERE pipeline_stages.status = 'stale'
32
+ ORDER BY pipeline_stages.updated_at DESC
33
+ """
34
+ ).fetchall()
35
+ return [dict(row) for row in rows]
36
+
37
+
38
+ def list_stale_meetings(connection: Connection) -> list[dict]:
39
+ stages = list_stale_stages(connection)
40
+ seen: set[str] = set()
41
+ meetings: list[dict] = []
42
+ for stage in stages:
43
+ if stage["meeting_id"] in seen:
44
+ continue
45
+ seen.add(stage["meeting_id"])
46
+ meetings.append(
47
+ {
48
+ "meeting_id": stage["meeting_id"],
49
+ "meeting_slug": stage["meeting_slug"],
50
+ }
51
+ )
52
+ return meetings
53
+
54
+
55
+ ProgressCallback = Callable[[str], None]
56
+
57
+
58
+ def rerun_speaker_matching(
59
+ connection: Connection,
60
+ meeting_id_or_slug: str,
61
+ progress: ProgressCallback | None = None,
62
+ ) -> int:
63
+ meeting = get_meeting(connection, meeting_id_or_slug)
64
+ if meeting is None:
65
+ raise ValueError(f"Meeting not found: {meeting_id_or_slug}")
66
+
67
+ provider_run = connection.execute(
68
+ """
69
+ SELECT id FROM provider_runs
70
+ WHERE meeting_id = ? AND status = 'done'
71
+ ORDER BY completed_at DESC, created_at DESC
72
+ LIMIT 1
73
+ """,
74
+ (meeting["id"],),
75
+ ).fetchone()
76
+ if provider_run is None:
77
+ raise ValueError(f"No completed provider run found for meeting: {meeting_id_or_slug}")
78
+
79
+ if progress is not None:
80
+ progress(f"Embedding and matching speakers for {meeting['slug']}")
81
+ before = _speaker_assignment_snapshot(connection, provider_run["id"])
82
+ match_provider_run_speakers(connection, provider_run["id"])
83
+ after = _speaker_assignment_snapshot(connection, provider_run["id"])
84
+ return _changed_assignment_count(before, after)
85
+
86
+
87
+ def rerun_speaker_matching_for_meetings(
88
+ connection: Connection,
89
+ include_known_speakers: bool = False,
90
+ progress: ProgressCallback | None = None,
91
+ ) -> list[dict]:
92
+ results: list[dict] = []
93
+ meetings = _speaker_reanalysis_meetings(connection, include_known_speakers)
94
+ if progress is not None:
95
+ progress(f"Found {len(meetings)} meeting(s) for speaker refresh")
96
+ for index, meeting in enumerate(meetings, start=1):
97
+ if progress is not None:
98
+ progress(f"Refreshing speaker matching for {meeting['slug']} ({index}/{len(meetings)})")
99
+ changed_count = rerun_speaker_matching(connection, meeting["id"], progress)
100
+ stages = mark_speaker_reanalysis_stale(connection, meeting["id"]) if changed_count else []
101
+ if progress is not None:
102
+ progress(f"{meeting['slug']}: {changed_count} speaker assignment change(s)")
103
+ results.append(
104
+ {
105
+ "meeting_id": meeting["id"],
106
+ "meeting_slug": meeting["slug"],
107
+ "match_count": changed_count,
108
+ "marked_stale": stages,
109
+ }
110
+ )
111
+ return results
112
+
113
+
114
+ def _speaker_reanalysis_meetings(connection: Connection, include_known_speakers: bool) -> list[dict]:
115
+ if include_known_speakers:
116
+ rows = connection.execute(
117
+ """
118
+ SELECT DISTINCT meetings.id, meetings.slug
119
+ FROM meetings
120
+ JOIN provider_runs ON provider_runs.meeting_id = meetings.id
121
+ WHERE provider_runs.status = 'done'
122
+ ORDER BY meetings.created_at DESC
123
+ """
124
+ ).fetchall()
125
+ else:
126
+ rows = connection.execute(
127
+ """
128
+ SELECT DISTINCT meetings.id, meetings.slug
129
+ FROM meetings
130
+ JOIN provider_runs ON provider_runs.meeting_id = meetings.id
131
+ JOIN local_speakers ON local_speakers.meeting_id = meetings.id
132
+ LEFT JOIN speaker_assignments
133
+ ON speaker_assignments.local_speaker_id = local_speakers.id
134
+ WHERE provider_runs.status = 'done'
135
+ AND (speaker_assignments.id IS NULL OR speaker_assignments.status = 'unknown')
136
+ ORDER BY meetings.created_at DESC
137
+ """
138
+ ).fetchall()
139
+ return [dict(row) for row in rows]
140
+
141
+
142
+ def _speaker_assignment_snapshot(connection: Connection, provider_run_id: str) -> dict[str, tuple]:
143
+ rows = connection.execute(
144
+ """
145
+ SELECT local_speakers.id AS local_speaker_id,
146
+ speaker_assignments.person_id,
147
+ speaker_assignments.status,
148
+ speaker_assignments.confidence,
149
+ speaker_assignments.margin,
150
+ speaker_assignments.evidence_json
151
+ FROM local_speakers
152
+ LEFT JOIN speaker_assignments
153
+ ON speaker_assignments.local_speaker_id = local_speakers.id
154
+ WHERE local_speakers.provider_run_id = ?
155
+ ORDER BY local_speakers.id
156
+ """,
157
+ (provider_run_id,),
158
+ ).fetchall()
159
+ return {
160
+ row["local_speaker_id"]: (
161
+ row["person_id"],
162
+ row["status"],
163
+ row["confidence"],
164
+ row["margin"],
165
+ row["evidence_json"],
166
+ )
167
+ for row in rows
168
+ }
169
+
170
+
171
+ def _changed_assignment_count(before: dict[str, tuple], after: dict[str, tuple]) -> int:
172
+ return sum(1 for speaker_id, assignment in after.items() if before.get(speaker_id) != assignment)
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from sqlite3 import Connection
7
+
8
+ from fly_on_the_wall.meetings import Meeting
9
+ from fly_on_the_wall.normalization import NormalizedSegment
10
+
11
+ MIN_DURATION_SECONDS = 3.0
12
+ SUSPICIOUS_DURATION_SECONDS = 10.0
13
+ SPARSE_DURATION_SECONDS = 120.0
14
+ SPARSE_WORDS_PER_SECOND = 0.02
15
+ MIN_MEANINGFUL_WORDS = 3
16
+
17
+ FILLER_WORDS = {
18
+ "ah",
19
+ "eh",
20
+ "ehm",
21
+ "hm",
22
+ "hmm",
23
+ "ja",
24
+ "mm",
25
+ "mmm",
26
+ "nej",
27
+ "ok",
28
+ "okej",
29
+ "uh",
30
+ "um",
31
+ "yes",
32
+ "no",
33
+ }
34
+ HALLUCINATION_PHRASES = {
35
+ "tack för att du tittade",
36
+ "thanks for watching",
37
+ "thank you for watching",
38
+ }
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class RecordingQuality:
43
+ status: str
44
+ reason: str
45
+ details: dict
46
+
47
+
48
+ class RecordingIgnoredError(RuntimeError):
49
+ def __init__(self, meeting: Meeting, quality: RecordingQuality) -> None:
50
+ super().__init__(quality.reason)
51
+ self.meeting = meeting
52
+ self.quality = quality
53
+
54
+
55
+ def assess_before_transcription(connection: Connection, meeting: Meeting) -> RecordingQuality | None:
56
+ duration = _duration_seconds(connection, meeting.id)
57
+ if duration is None:
58
+ return None
59
+ if duration < MIN_DURATION_SECONDS:
60
+ return RecordingQuality(
61
+ "empty",
62
+ "audio_too_short",
63
+ {"duration_seconds": duration, "threshold_seconds": MIN_DURATION_SECONDS},
64
+ )
65
+ if duration < SUSPICIOUS_DURATION_SECONDS:
66
+ return RecordingQuality(
67
+ "suspicious",
68
+ "audio_very_short",
69
+ {"duration_seconds": duration, "threshold_seconds": SUSPICIOUS_DURATION_SECONDS},
70
+ )
71
+ return None
72
+
73
+
74
+ def assess_after_transcription(
75
+ connection: Connection, meeting: Meeting, segments: list[NormalizedSegment]
76
+ ) -> RecordingQuality:
77
+ duration = _duration_seconds(connection, meeting.id)
78
+ texts = [segment.text for segment in segments]
79
+ words = _words(" ".join(texts))
80
+ meaningful_words = [word for word in words if word not in FILLER_WORDS]
81
+ details = {
82
+ "segment_count": len(segments),
83
+ "word_count": len(words),
84
+ "meaningful_word_count": len(meaningful_words),
85
+ "duration_seconds": duration,
86
+ }
87
+
88
+ if not segments:
89
+ return RecordingQuality("empty", "no_transcript_segments", details)
90
+ if words and not meaningful_words:
91
+ return RecordingQuality("empty", "only_filler_words", details)
92
+ if _looks_like_hallucinated_boilerplate(" ".join(words)):
93
+ return RecordingQuality("nonsense", "hallucinated_boilerplate", details)
94
+ if duration is not None and duration >= SPARSE_DURATION_SECONDS:
95
+ density = len(words) / duration
96
+ details["words_per_second"] = density
97
+ if density < SPARSE_WORDS_PER_SECOND:
98
+ return RecordingQuality("nonsense", "very_low_speech_density", details)
99
+ if len(meaningful_words) < MIN_MEANINGFUL_WORDS:
100
+ return RecordingQuality("suspicious", "too_few_meaningful_words", details)
101
+ return RecordingQuality("normal", "passed_quality_checks", details)
102
+
103
+
104
+ def store_recording_quality(connection: Connection, meeting_id: str, quality: RecordingQuality) -> None:
105
+ with connection:
106
+ connection.execute(
107
+ """
108
+ INSERT OR REPLACE INTO recording_quality(
109
+ id, meeting_id, status, reason, details_json, updated_at
110
+ ) VALUES (
111
+ COALESCE((SELECT id FROM recording_quality WHERE meeting_id = ?), ?),
112
+ ?, ?, ?, ?, CURRENT_TIMESTAMP
113
+ )
114
+ """,
115
+ (
116
+ meeting_id,
117
+ meeting_id,
118
+ meeting_id,
119
+ quality.status,
120
+ quality.reason,
121
+ json.dumps(quality.details, sort_keys=True),
122
+ ),
123
+ )
124
+
125
+
126
+ def _duration_seconds(connection: Connection, meeting_id: str) -> float | None:
127
+ row = connection.execute(
128
+ "SELECT duration_seconds FROM audio_metadata WHERE meeting_id = ?", (meeting_id,)
129
+ ).fetchone()
130
+ if row is None or row["duration_seconds"] is None:
131
+ return None
132
+ return float(row["duration_seconds"])
133
+
134
+
135
+ def _words(text: str) -> list[str]:
136
+ return [word.lower() for word in re.findall(r"[\wåäöÅÄÖ]+", text)]
137
+
138
+
139
+ def _looks_like_hallucinated_boilerplate(text: str) -> bool:
140
+ normalized = " ".join(text.lower().split())
141
+ return any(phrase in normalized for phrase in HALLUCINATION_PHRASES)
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from sqlite3 import Connection
5
+
6
+ from fly_on_the_wall.storage import StoragePaths, storage_paths
7
+
8
+
9
+ def render_diarized_transcript(
10
+ connection: Connection,
11
+ provider_run_id: str,
12
+ output_path: Path | None = None,
13
+ storage: StoragePaths | None = None,
14
+ ) -> str:
15
+ rows = connection.execute(
16
+ """
17
+ SELECT segments.text,
18
+ segments.language,
19
+ local_speakers.label AS speaker_label
20
+ FROM segments
21
+ LEFT JOIN local_speakers ON local_speakers.id = segments.local_speaker_id
22
+ WHERE segments.provider_run_id = ?
23
+ ORDER BY segments.sequence
24
+ """,
25
+ (provider_run_id,),
26
+ ).fetchall()
27
+ transcript = "\n\n".join(
28
+ _format_turn(row["speaker_label"] or "Unknown", row["language"], row["text"]) for row in rows
29
+ )
30
+
31
+ if output_path is None:
32
+ provider_run = connection.execute(
33
+ "SELECT meeting_id FROM provider_runs WHERE id = ?", (provider_run_id,)
34
+ ).fetchone()
35
+ if provider_run is not None:
36
+ paths = storage or storage_paths()
37
+ output_path = paths.artifacts / provider_run["meeting_id"] / "diarized-transcript.txt"
38
+
39
+ if output_path is not None:
40
+ output_path.parent.mkdir(parents=True, exist_ok=True)
41
+ output_path.write_text(transcript + "\n")
42
+
43
+ return transcript
44
+
45
+
46
+ def render_named_transcript(
47
+ connection: Connection,
48
+ provider_run_id: str,
49
+ output_path: Path | None = None,
50
+ storage: StoragePaths | None = None,
51
+ ) -> str:
52
+ rows = connection.execute(
53
+ """
54
+ SELECT segments.text,
55
+ segments.language,
56
+ local_speakers.label AS speaker_label,
57
+ speaker_assignments.status AS assignment_status,
58
+ people.display_name
59
+ FROM segments
60
+ LEFT JOIN local_speakers ON local_speakers.id = segments.local_speaker_id
61
+ LEFT JOIN speaker_assignments
62
+ ON speaker_assignments.local_speaker_id = local_speakers.id
63
+ LEFT JOIN people ON people.id = speaker_assignments.person_id
64
+ WHERE segments.provider_run_id = ?
65
+ ORDER BY segments.sequence
66
+ """,
67
+ (provider_run_id,),
68
+ ).fetchall()
69
+ transcript = "\n\n".join(
70
+ _format_named_turn(
71
+ row["display_name"],
72
+ row["assignment_status"],
73
+ row["speaker_label"] or "Unknown",
74
+ row["language"],
75
+ row["text"],
76
+ )
77
+ for row in rows
78
+ )
79
+
80
+ if output_path is None:
81
+ provider_run = connection.execute(
82
+ "SELECT meeting_id FROM provider_runs WHERE id = ?", (provider_run_id,)
83
+ ).fetchone()
84
+ if provider_run is not None:
85
+ paths = storage or storage_paths()
86
+ output_path = paths.artifacts / provider_run["meeting_id"] / "named-transcript.txt"
87
+
88
+ if output_path is not None:
89
+ output_path.parent.mkdir(parents=True, exist_ok=True)
90
+ output_path.write_text(transcript + "\n")
91
+
92
+ return transcript
93
+
94
+
95
+ def _format_turn(speaker_label: str, language: str | None, text: str) -> str:
96
+ language_marker = f" [{language}]" if language else ""
97
+ return f"{speaker_label}{language_marker}: {text}"
98
+
99
+
100
+ def _format_named_turn(
101
+ display_name: str | None,
102
+ assignment_status: str | None,
103
+ speaker_label: str,
104
+ language: str | None,
105
+ text: str,
106
+ ) -> str:
107
+ if assignment_status == "known" and display_name:
108
+ name = display_name
109
+ elif assignment_status == "uncertain" and display_name:
110
+ name = f"{display_name}?"
111
+ else:
112
+ name = "Unknown"
113
+
114
+ language_marker = f" [{language}]" if language else ""
115
+ return f"{name}{language_marker} ({speaker_label}): {text}"
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from typing import Literal
6
+
7
+ import keyring
8
+ from keyring.errors import KeyringError
9
+
10
+ from fly_on_the_wall.config import API_KEY_ENV_VARS
11
+
12
+ KEYRING_SERVICE = "fly-on-the-wall"
13
+ SecretSource = Literal["env", "keyring", "missing", "unknown"]
14
+
15
+
16
+ class SecretError(RuntimeError):
17
+ """Raised when a secret cannot be stored or removed."""
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class SecretStatus:
22
+ provider: str
23
+ env_var: str | None
24
+ source: SecretSource
25
+
26
+ @property
27
+ def available(self) -> bool:
28
+ return self.source in {"env", "keyring"}
29
+
30
+
31
+ def get_api_key(provider: str) -> str | None:
32
+ env_value = _get_env_key(provider)
33
+ if env_value:
34
+ return env_value
35
+ return _get_keyring_key(provider)
36
+
37
+
38
+ def get_api_key_status(provider: str) -> SecretStatus:
39
+ normalized = provider.lower()
40
+ env_var = API_KEY_ENV_VARS.get(normalized)
41
+ if env_var is None:
42
+ return SecretStatus(provider=normalized, env_var=None, source="unknown")
43
+ if os.environ.get(env_var):
44
+ return SecretStatus(provider=normalized, env_var=env_var, source="env")
45
+ if _get_keyring_key(normalized):
46
+ return SecretStatus(provider=normalized, env_var=env_var, source="keyring")
47
+ return SecretStatus(provider=normalized, env_var=env_var, source="missing")
48
+
49
+
50
+ def set_api_key(provider: str, value: str) -> None:
51
+ normalized = _require_known_provider(provider)
52
+ try:
53
+ keyring.set_password(KEYRING_SERVICE, normalized, value)
54
+ except KeyringError as exc:
55
+ raise SecretError(f"Could not store {normalized} API key in OS keyring: {exc}") from exc
56
+
57
+
58
+ def remove_api_key(provider: str) -> None:
59
+ normalized = _require_known_provider(provider)
60
+ try:
61
+ keyring.delete_password(KEYRING_SERVICE, normalized)
62
+ except keyring.errors.PasswordDeleteError:
63
+ return
64
+ except KeyringError as exc:
65
+ raise SecretError(f"Could not remove {normalized} API key from OS keyring: {exc}") from exc
66
+
67
+
68
+ def known_providers() -> list[str]:
69
+ return sorted(API_KEY_ENV_VARS)
70
+
71
+
72
+ def _get_env_key(provider: str) -> str | None:
73
+ env_var = API_KEY_ENV_VARS.get(provider.lower())
74
+ if env_var is None:
75
+ return None
76
+ return os.environ.get(env_var) or None
77
+
78
+
79
+ def _get_keyring_key(provider: str) -> str | None:
80
+ normalized = provider.lower()
81
+ if normalized not in API_KEY_ENV_VARS:
82
+ return None
83
+ try:
84
+ return keyring.get_password(KEYRING_SERVICE, normalized) or None
85
+ except KeyringError:
86
+ return None
87
+
88
+
89
+ def _require_known_provider(provider: str) -> str:
90
+ normalized = provider.lower()
91
+ if normalized not in API_KEY_ENV_VARS:
92
+ raise SecretError(f"Unknown provider: {provider}")
93
+ return normalized
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from sqlite3 import Connection
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ServicePrice:
10
+ id: str
11
+ provider: str
12
+ model: str
13
+ service: str
14
+ unit: str
15
+ input_unit_price_usd: float | None
16
+ output_unit_price_usd: float | None
17
+ cached_input_unit_price_usd: float | None
18
+ currency: str
19
+ source_name: str
20
+ source_url: str | None
21
+ pricing: dict
22
+ active: bool
23
+
24
+
25
+ def list_service_prices(connection: Connection, active_only: bool = True) -> list[ServicePrice]:
26
+ where = "WHERE active = 1" if active_only else ""
27
+ rows = connection.execute(
28
+ f"""
29
+ SELECT * FROM service_prices
30
+ {where}
31
+ ORDER BY provider, model, service, unit
32
+ """
33
+ ).fetchall()
34
+ return [_service_price_from_row(row) for row in rows]
35
+
36
+
37
+ def get_service_price(
38
+ connection: Connection,
39
+ provider: str,
40
+ model: str,
41
+ service: str,
42
+ unit: str,
43
+ ) -> ServicePrice | None:
44
+ row = connection.execute(
45
+ """
46
+ SELECT * FROM service_prices
47
+ WHERE provider = ?
48
+ AND model = ?
49
+ AND service = ?
50
+ AND unit = ?
51
+ AND active = 1
52
+ ORDER BY updated_at DESC
53
+ LIMIT 1
54
+ """,
55
+ (provider, model, service, unit),
56
+ ).fetchone()
57
+ return None if row is None else _service_price_from_row(row)
58
+
59
+
60
+ def _service_price_from_row(row) -> ServicePrice:
61
+ return ServicePrice(
62
+ id=row["id"],
63
+ provider=row["provider"],
64
+ model=row["model"],
65
+ service=row["service"],
66
+ unit=row["unit"],
67
+ input_unit_price_usd=row["input_unit_price_usd"],
68
+ output_unit_price_usd=row["output_unit_price_usd"],
69
+ cached_input_unit_price_usd=row["cached_input_unit_price_usd"],
70
+ currency=row["currency"],
71
+ source_name=row["source_name"],
72
+ source_url=row["source_url"],
73
+ pricing=json.loads(row["pricing_json"]),
74
+ active=bool(row["active"]),
75
+ )