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
|
@@ -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
|
+
)
|