PayPerTranscript 0.2.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.
- paypertranscript/__init__.py +3 -0
- paypertranscript/__main__.py +51 -0
- paypertranscript/assets/icons/app.ico +0 -0
- paypertranscript/assets/icons/app.png +0 -0
- paypertranscript/assets/icons/arrow_down.svg +3 -0
- paypertranscript/assets/sounds/start.wav +0 -0
- paypertranscript/assets/sounds/stop.wav +0 -0
- paypertranscript/assets/styles/dark.qss +388 -0
- paypertranscript/core/__init__.py +0 -0
- paypertranscript/core/audio_manager.py +142 -0
- paypertranscript/core/config.py +360 -0
- paypertranscript/core/cost_tracker.py +87 -0
- paypertranscript/core/hotkey.py +294 -0
- paypertranscript/core/logging.py +65 -0
- paypertranscript/core/paths.py +28 -0
- paypertranscript/core/recorder.py +167 -0
- paypertranscript/core/session_logger.py +138 -0
- paypertranscript/core/text_inserter.py +131 -0
- paypertranscript/core/window_detector.py +58 -0
- paypertranscript/pipeline/__init__.py +0 -0
- paypertranscript/pipeline/transcription.py +361 -0
- paypertranscript/providers/__init__.py +85 -0
- paypertranscript/providers/base.py +78 -0
- paypertranscript/providers/groq_provider.py +182 -0
- paypertranscript/ui/__init__.py +0 -0
- paypertranscript/ui/app.py +370 -0
- paypertranscript/ui/dashboard.py +92 -0
- paypertranscript/ui/overlay.py +396 -0
- paypertranscript/ui/settings.py +550 -0
- paypertranscript/ui/setup_wizard.py +690 -0
- paypertranscript/ui/statistics.py +412 -0
- paypertranscript/ui/tray.py +256 -0
- paypertranscript/ui/window_mapping.py +460 -0
- paypertranscript/ui/word_list.py +183 -0
- paypertranscript-0.2.0.dist-info/METADATA +159 -0
- paypertranscript-0.2.0.dist-info/RECORD +40 -0
- paypertranscript-0.2.0.dist-info/WHEEL +5 -0
- paypertranscript-0.2.0.dist-info/entry_points.txt +2 -0
- paypertranscript-0.2.0.dist-info/licenses/LICENSE +21 -0
- paypertranscript-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Konfigurationsmanagement für PayPerTranscript.
|
|
2
|
+
|
|
3
|
+
JSON-basiert, Merge-on-Write, Schema-Validierung mit Fallback auf Defaults.
|
|
4
|
+
Alle Laufzeit-Daten liegen unter %APPDATA%\\PayPerTranscript\\.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import copy
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from paypertranscript.core.logging import APPDATA_DIR, get_logger
|
|
15
|
+
|
|
16
|
+
log = get_logger("core.config")
|
|
17
|
+
|
|
18
|
+
CONFIG_FILE = APPDATA_DIR / "config.json"
|
|
19
|
+
AUDIO_DIR = APPDATA_DIR / "audio"
|
|
20
|
+
TRACKING_FILE = APPDATA_DIR / "tracking.json"
|
|
21
|
+
|
|
22
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
23
|
+
"general": {
|
|
24
|
+
"language": "de",
|
|
25
|
+
"autostart": False,
|
|
26
|
+
"sound_enabled": False,
|
|
27
|
+
"hold_hotkey": ["ctrl", "cmd"],
|
|
28
|
+
"toggle_hotkey": None,
|
|
29
|
+
"streaming_typing": False,
|
|
30
|
+
"overlay_position": "mouse_cursor",
|
|
31
|
+
},
|
|
32
|
+
"api": {
|
|
33
|
+
"provider": "groq",
|
|
34
|
+
"stt_model": "whisper-large-v3-turbo",
|
|
35
|
+
"llm_model": "openai/gpt-oss-20b",
|
|
36
|
+
},
|
|
37
|
+
"words": {
|
|
38
|
+
"misspelled_words": [],
|
|
39
|
+
},
|
|
40
|
+
"formatting": {
|
|
41
|
+
"window_mappings": {
|
|
42
|
+
"WhatsApp.Root.exe": "casual",
|
|
43
|
+
"Telegram.exe": "casual",
|
|
44
|
+
"Discord.exe": "casual",
|
|
45
|
+
"Outlook": "professional",
|
|
46
|
+
},
|
|
47
|
+
"categories": {
|
|
48
|
+
"casual": {
|
|
49
|
+
"name": "Persönlich",
|
|
50
|
+
"prompt": (
|
|
51
|
+
"Formatiere den folgenden transkribierten Text als lockere "
|
|
52
|
+
"Chat-Nachricht. Alles kleingeschrieben, minimale Interpunktion, "
|
|
53
|
+
"Kommas zur Trennung von Gedanken. Kein Punkt am Ende. "
|
|
54
|
+
"Gib NUR den formatierten Text aus, keine Erklärungen."
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
"professional": {
|
|
58
|
+
"name": "Professionell",
|
|
59
|
+
"prompt": (
|
|
60
|
+
"Formatiere den folgenden transkribierten Text als professionelle "
|
|
61
|
+
"Nachricht. Korrekte Groß-/Kleinschreibung, saubere Interpunktion, "
|
|
62
|
+
"entferne Füllwörter und Wiederholungen. Sachlicher Stil, kurze "
|
|
63
|
+
"Absätze. Gib NUR den formatierten Text aus, keine Erklärungen."
|
|
64
|
+
),
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
"data": {
|
|
69
|
+
"audio_retention_hours": 24,
|
|
70
|
+
"save_transcripts": False,
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Schema: Erlaubte Typen pro Pfad für Validierung
|
|
75
|
+
_SCHEMA: dict[str, type | tuple[type, ...]] = {
|
|
76
|
+
"general.language": str,
|
|
77
|
+
"general.autostart": bool,
|
|
78
|
+
"general.sound_enabled": bool,
|
|
79
|
+
"general.hold_hotkey": list,
|
|
80
|
+
"general.toggle_hotkey": (list, type(None)),
|
|
81
|
+
"general.streaming_typing": bool,
|
|
82
|
+
"general.overlay_position": str,
|
|
83
|
+
"api.provider": str,
|
|
84
|
+
"api.stt_model": str,
|
|
85
|
+
"api.llm_model": str,
|
|
86
|
+
"words.misspelled_words": list,
|
|
87
|
+
"formatting.window_mappings": dict,
|
|
88
|
+
"formatting.categories": dict,
|
|
89
|
+
"data.audio_retention_hours": (int, float),
|
|
90
|
+
"data.save_transcripts": bool,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
95
|
+
"""Merge override in base (rekursiv). Gibt neues Dict zurück."""
|
|
96
|
+
result = copy.deepcopy(base)
|
|
97
|
+
for key, value in override.items():
|
|
98
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
99
|
+
result[key] = _deep_merge(result[key], value)
|
|
100
|
+
else:
|
|
101
|
+
result[key] = copy.deepcopy(value)
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _validate_config(config: dict) -> dict:
|
|
106
|
+
"""Validiert Config gegen Schema. Ungültige Werte werden durch Defaults ersetzt."""
|
|
107
|
+
validated = copy.deepcopy(config)
|
|
108
|
+
|
|
109
|
+
for path, expected_type in _SCHEMA.items():
|
|
110
|
+
parts = path.split(".")
|
|
111
|
+
# Wert aus Config holen
|
|
112
|
+
node = validated
|
|
113
|
+
default_node = DEFAULT_CONFIG
|
|
114
|
+
valid = True
|
|
115
|
+
for part in parts[:-1]:
|
|
116
|
+
if isinstance(node, dict) and part in node:
|
|
117
|
+
node = node[part]
|
|
118
|
+
default_node = default_node[part]
|
|
119
|
+
else:
|
|
120
|
+
valid = False
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
if not valid:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
key = parts[-1]
|
|
127
|
+
if key not in node:
|
|
128
|
+
# Fehlender Wert → Default einsetzen
|
|
129
|
+
node[key] = copy.deepcopy(default_node[key])
|
|
130
|
+
log.warning("Config: Fehlender Wert '%s' → Default verwendet", path)
|
|
131
|
+
elif not isinstance(node[key], expected_type):
|
|
132
|
+
old_val = node[key]
|
|
133
|
+
node[key] = copy.deepcopy(default_node[key])
|
|
134
|
+
log.warning(
|
|
135
|
+
"Config: Ungültiger Typ für '%s' (%s statt %s) → Default verwendet",
|
|
136
|
+
path,
|
|
137
|
+
type(old_val).__name__,
|
|
138
|
+
expected_type,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return validated
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _ensure_dirs() -> None:
|
|
145
|
+
"""Erstellt alle nötigen Verzeichnisse."""
|
|
146
|
+
APPDATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ConfigManager:
|
|
151
|
+
"""Verwaltet die App-Konfiguration.
|
|
152
|
+
|
|
153
|
+
- Lädt Config aus JSON (mit Fallback auf Defaults)
|
|
154
|
+
- Merge-on-Write: bestehende Config lesen, neue Werte mergen, schreiben
|
|
155
|
+
- Schema-Validierung bei Load
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(self) -> None:
|
|
159
|
+
_ensure_dirs()
|
|
160
|
+
self._config: dict[str, Any] = self._load()
|
|
161
|
+
|
|
162
|
+
def _load(self) -> dict[str, Any]:
|
|
163
|
+
"""Lädt Config aus Datei, merged mit Defaults, validiert."""
|
|
164
|
+
if CONFIG_FILE.exists():
|
|
165
|
+
try:
|
|
166
|
+
raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
167
|
+
if not isinstance(raw, dict):
|
|
168
|
+
log.warning("Config-Datei enthält kein Dict → Defaults verwendet")
|
|
169
|
+
raw = {}
|
|
170
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
171
|
+
log.warning("Config-Datei konnte nicht gelesen werden: %s → Defaults verwendet", e)
|
|
172
|
+
raw = {}
|
|
173
|
+
else:
|
|
174
|
+
log.info("Keine Config-Datei gefunden → Defaults werden verwendet")
|
|
175
|
+
raw = {}
|
|
176
|
+
|
|
177
|
+
merged = _deep_merge(DEFAULT_CONFIG, raw)
|
|
178
|
+
validated = _validate_config(merged)
|
|
179
|
+
return validated
|
|
180
|
+
|
|
181
|
+
def _save(self) -> None:
|
|
182
|
+
"""Speichert aktuelle Config in Datei."""
|
|
183
|
+
_ensure_dirs()
|
|
184
|
+
try:
|
|
185
|
+
CONFIG_FILE.write_text(
|
|
186
|
+
json.dumps(self._config, indent=2, ensure_ascii=False),
|
|
187
|
+
encoding="utf-8",
|
|
188
|
+
)
|
|
189
|
+
log.debug("Config gespeichert: %s", CONFIG_FILE)
|
|
190
|
+
except OSError as e:
|
|
191
|
+
log.error("Config konnte nicht gespeichert werden: %s", e)
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def config(self) -> dict[str, Any]:
|
|
195
|
+
"""Gibt die komplette Config als Dict zurück (Read-only Kopie)."""
|
|
196
|
+
return copy.deepcopy(self._config)
|
|
197
|
+
|
|
198
|
+
def get(self, path: str, default: Any = None) -> Any:
|
|
199
|
+
"""Holt einen Wert per Punkt-Pfad (z.B. 'general.language').
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
path: Punkt-separierter Pfad zum Wert.
|
|
203
|
+
default: Fallback, wenn Pfad nicht existiert.
|
|
204
|
+
"""
|
|
205
|
+
node = self._config
|
|
206
|
+
for part in path.split("."):
|
|
207
|
+
if isinstance(node, dict) and part in node:
|
|
208
|
+
node = node[part]
|
|
209
|
+
else:
|
|
210
|
+
return default
|
|
211
|
+
return copy.deepcopy(node)
|
|
212
|
+
|
|
213
|
+
def set(self, path: str, value: Any) -> None:
|
|
214
|
+
"""Setzt einen Wert per Punkt-Pfad und speichert.
|
|
215
|
+
|
|
216
|
+
Merge-on-Write: Liest aktuelle Datei, merged, speichert.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
path: Punkt-separierter Pfad (z.B. 'general.language').
|
|
220
|
+
value: Neuer Wert.
|
|
221
|
+
"""
|
|
222
|
+
# Aktuellen Stand von Disk lesen (falls von außen geändert)
|
|
223
|
+
if CONFIG_FILE.exists():
|
|
224
|
+
try:
|
|
225
|
+
disk_config = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
226
|
+
if isinstance(disk_config, dict):
|
|
227
|
+
self._config = _deep_merge(DEFAULT_CONFIG, disk_config)
|
|
228
|
+
self._config = _validate_config(self._config)
|
|
229
|
+
except (json.JSONDecodeError, OSError):
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
# Wert setzen
|
|
233
|
+
parts = path.split(".")
|
|
234
|
+
node = self._config
|
|
235
|
+
for part in parts[:-1]:
|
|
236
|
+
if part not in node or not isinstance(node[part], dict):
|
|
237
|
+
node[part] = {}
|
|
238
|
+
node = node[part]
|
|
239
|
+
node[parts[-1]] = value
|
|
240
|
+
|
|
241
|
+
log.info("Config geändert: %s = %s", path, value)
|
|
242
|
+
self._save()
|
|
243
|
+
|
|
244
|
+
def update(self, updates: dict[str, Any]) -> None:
|
|
245
|
+
"""Merged ein Dict in die Config und speichert.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
updates: Dict mit Werten zum Mergen (gleiche Struktur wie Config).
|
|
249
|
+
"""
|
|
250
|
+
self._config = _deep_merge(self._config, updates)
|
|
251
|
+
self._config = _validate_config(self._config)
|
|
252
|
+
self._save()
|
|
253
|
+
|
|
254
|
+
def reload(self) -> None:
|
|
255
|
+
"""Lädt Config erneut von Disk."""
|
|
256
|
+
self._config = self._load()
|
|
257
|
+
log.info("Config neu geladen")
|
|
258
|
+
|
|
259
|
+
def is_first_run(self) -> bool:
|
|
260
|
+
"""Prüft ob dies der erste Start ist (keine Config-Datei vorhanden)."""
|
|
261
|
+
return not CONFIG_FILE.exists()
|
|
262
|
+
|
|
263
|
+
def save_initial(self) -> None:
|
|
264
|
+
"""Speichert die initiale Config (nach Setup-Wizard)."""
|
|
265
|
+
self._save()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# -- API-Key-Speicherung via Windows Credential Manager --
|
|
269
|
+
|
|
270
|
+
KEYRING_SERVICE = "PayPerTranscript"
|
|
271
|
+
KEYRING_ACCOUNT = "groq_api_key"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def save_api_key(api_key: str) -> None:
|
|
275
|
+
"""Speichert den API-Key im Windows Credential Manager (keyring)."""
|
|
276
|
+
import keyring
|
|
277
|
+
|
|
278
|
+
keyring.set_password(KEYRING_SERVICE, KEYRING_ACCOUNT, api_key)
|
|
279
|
+
log.info("API-Key im Credential Manager gespeichert")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def load_api_key() -> str | None:
|
|
283
|
+
"""Lädt den API-Key aus dem Windows Credential Manager (keyring).
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Den API-Key oder None wenn keiner gespeichert ist.
|
|
287
|
+
"""
|
|
288
|
+
import keyring
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
key = keyring.get_password(KEYRING_SERVICE, KEYRING_ACCOUNT)
|
|
292
|
+
if key:
|
|
293
|
+
log.debug("API-Key aus Credential Manager geladen")
|
|
294
|
+
return key
|
|
295
|
+
except Exception as e:
|
|
296
|
+
log.warning("Keyring-Zugriff fehlgeschlagen: %s", e)
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# -- Autostart via Windows Startup-Folder --
|
|
301
|
+
|
|
302
|
+
_STARTUP_DIR = Path(os.environ.get("APPDATA", "")) / (
|
|
303
|
+
r"Microsoft\Windows\Start Menu\Programs\Startup"
|
|
304
|
+
)
|
|
305
|
+
_SHORTCUT_NAME = "PayPerTranscript.lnk"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def enable_autostart() -> bool:
|
|
309
|
+
"""Erstellt Windows-Startup-Shortcut. Returns True bei Erfolg."""
|
|
310
|
+
try:
|
|
311
|
+
import shutil
|
|
312
|
+
|
|
313
|
+
import win32com.client # type: ignore[import-untyped]
|
|
314
|
+
|
|
315
|
+
shortcut_path = _STARTUP_DIR / _SHORTCUT_NAME
|
|
316
|
+
shell = win32com.client.Dispatch("WScript.Shell")
|
|
317
|
+
shortcut = shell.CreateShortCut(str(shortcut_path))
|
|
318
|
+
|
|
319
|
+
# pip-installiertes GUI-Script suchen
|
|
320
|
+
entry_point = shutil.which("paypertranscript")
|
|
321
|
+
if entry_point:
|
|
322
|
+
shortcut.Targetpath = str(Path(entry_point))
|
|
323
|
+
shortcut.WorkingDirectory = str(Path.home())
|
|
324
|
+
shortcut.Arguments = ""
|
|
325
|
+
else:
|
|
326
|
+
# Fallback: pythonw.exe -m paypertranscript (kein CMD-Fenster)
|
|
327
|
+
python_dir = Path(sys.executable).parent
|
|
328
|
+
pythonw = python_dir / "pythonw.exe"
|
|
329
|
+
if not pythonw.exists():
|
|
330
|
+
log.warning("pythonw.exe nicht gefunden — Fallback auf python.exe")
|
|
331
|
+
pythonw = Path(sys.executable)
|
|
332
|
+
shortcut.Targetpath = str(pythonw)
|
|
333
|
+
shortcut.WorkingDirectory = str(Path.home())
|
|
334
|
+
shortcut.Arguments = "-m paypertranscript"
|
|
335
|
+
|
|
336
|
+
shortcut.Description = "PayPerTranscript — Voice-to-Text"
|
|
337
|
+
shortcut.save()
|
|
338
|
+
log.info("Autostart-Shortcut erstellt: %s", shortcut_path)
|
|
339
|
+
return True
|
|
340
|
+
except Exception as e:
|
|
341
|
+
log.error("Autostart konnte nicht aktiviert werden: %s", e)
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def disable_autostart() -> bool:
|
|
346
|
+
"""Entfernt Windows-Startup-Shortcut. Returns True bei Erfolg."""
|
|
347
|
+
try:
|
|
348
|
+
shortcut_path = _STARTUP_DIR / _SHORTCUT_NAME
|
|
349
|
+
if shortcut_path.exists():
|
|
350
|
+
shortcut_path.unlink()
|
|
351
|
+
log.info("Autostart-Shortcut entfernt: %s", shortcut_path)
|
|
352
|
+
return True
|
|
353
|
+
except Exception as e:
|
|
354
|
+
log.error("Autostart konnte nicht deaktiviert werden: %s", e)
|
|
355
|
+
return False
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def is_autostart_enabled() -> bool:
|
|
359
|
+
"""Prüft ob der Autostart-Shortcut existiert."""
|
|
360
|
+
return (_STARTUP_DIR / _SHORTCUT_NAME).exists()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Kostenberechnung fuer PayPerTranscript.
|
|
2
|
+
|
|
3
|
+
Reine Berechnungsfunktionen fuer STT- und LLM-Kosten.
|
|
4
|
+
Keine I/O, keine Seiteneffekte — einfach testbar.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
# STT/LLM API-Preise (Stand: 2026-02)
|
|
10
|
+
STT_PRICE_PER_HOUR_USD = 0.04
|
|
11
|
+
STT_MIN_BILLED_SECONDS = 10 # API-seitiges Minimum-Billing
|
|
12
|
+
|
|
13
|
+
LLM_INPUT_PRICE_PER_M_TOKENS = 0.075 # USD per million input tokens
|
|
14
|
+
LLM_OUTPUT_PRICE_PER_M_TOKENS = 0.30 # USD per million output tokens
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class CostResult:
|
|
19
|
+
"""Ergebnis einer Kostenberechnung."""
|
|
20
|
+
|
|
21
|
+
audio_duration_seconds: float
|
|
22
|
+
billed_seconds: float
|
|
23
|
+
stt_cost_usd: float
|
|
24
|
+
llm_input_tokens: int
|
|
25
|
+
llm_output_tokens: int
|
|
26
|
+
llm_cost_usd: float
|
|
27
|
+
total_cost_usd: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def calculate_stt_cost(audio_duration_seconds: float) -> tuple[float, float]:
|
|
31
|
+
"""Berechnet STT-Kosten.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
audio_duration_seconds: Tatsaechliche Audio-Dauer in Sekunden.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Tuple (billed_seconds, cost_usd).
|
|
38
|
+
"""
|
|
39
|
+
billed = max(audio_duration_seconds, STT_MIN_BILLED_SECONDS)
|
|
40
|
+
cost = billed / 3600.0 * STT_PRICE_PER_HOUR_USD
|
|
41
|
+
return billed, cost
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def calculate_llm_cost(input_tokens: int, output_tokens: int) -> float:
|
|
45
|
+
"""Berechnet LLM-Kosten.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
input_tokens: Anzahl Input-Tokens.
|
|
49
|
+
output_tokens: Anzahl Output-Tokens.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Kosten in USD.
|
|
53
|
+
"""
|
|
54
|
+
return (
|
|
55
|
+
input_tokens * LLM_INPUT_PRICE_PER_M_TOKENS
|
|
56
|
+
+ output_tokens * LLM_OUTPUT_PRICE_PER_M_TOKENS
|
|
57
|
+
) / 1_000_000
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def calculate_total_cost(
|
|
61
|
+
audio_duration_seconds: float,
|
|
62
|
+
llm_input_tokens: int = 0,
|
|
63
|
+
llm_output_tokens: int = 0,
|
|
64
|
+
) -> CostResult:
|
|
65
|
+
"""Berechnet Gesamtkosten einer Transkription.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
audio_duration_seconds: Audio-Dauer in Sekunden.
|
|
69
|
+
llm_input_tokens: LLM Input-Tokens (0 wenn kein LLM).
|
|
70
|
+
llm_output_tokens: LLM Output-Tokens (0 wenn kein LLM).
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
CostResult mit allen Kosten-Details.
|
|
74
|
+
"""
|
|
75
|
+
billed, stt_cost = calculate_stt_cost(audio_duration_seconds)
|
|
76
|
+
llm_cost = calculate_llm_cost(llm_input_tokens, llm_output_tokens)
|
|
77
|
+
return CostResult(
|
|
78
|
+
audio_duration_seconds=audio_duration_seconds,
|
|
79
|
+
billed_seconds=billed,
|
|
80
|
+
stt_cost_usd=stt_cost,
|
|
81
|
+
llm_input_tokens=llm_input_tokens,
|
|
82
|
+
llm_output_tokens=llm_output_tokens,
|
|
83
|
+
llm_cost_usd=llm_cost,
|
|
84
|
+
total_cost_usd=stt_cost + llm_cost,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|