PayPerTranscript 0.2.7__tar.gz → 0.2.9__tar.gz

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 (56) hide show
  1. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/PKG-INFO +1 -1
  2. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/PayPerTranscript.egg-info/PKG-INFO +1 -1
  3. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/PayPerTranscript.egg-info/SOURCES.txt +2 -0
  4. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/__init__.py +1 -1
  5. paypertranscript-0.2.9/paypertranscript/assets/icons/tray_green.png +0 -0
  6. paypertranscript-0.2.9/paypertranscript/assets/icons/tray_orange.png +0 -0
  7. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/core/config.py +14 -1
  8. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/core/cost_tracker.py +18 -7
  9. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/pipeline/transcription.py +1 -0
  10. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/providers/groq_provider.py +15 -4
  11. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/ui/app.py +1 -0
  12. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/ui/constants.py +9 -0
  13. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/ui/main_window.py +64 -16
  14. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/ui/pages/home_page.py +2 -2
  15. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/ui/pages/settings_page.py +70 -0
  16. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/ui/pages/window_mapping_page.py +65 -39
  17. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/ui/pages/word_list_page.py +29 -20
  18. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/ui/tray.py +23 -68
  19. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/pyproject.toml +1 -1
  20. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/LICENSE +0 -0
  21. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/PayPerTranscript.egg-info/dependency_links.txt +0 -0
  22. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/PayPerTranscript.egg-info/entry_points.txt +0 -0
  23. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/PayPerTranscript.egg-info/requires.txt +0 -0
  24. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/PayPerTranscript.egg-info/top_level.txt +0 -0
  25. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/README.md +0 -0
  26. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/__main__.py +0 -0
  27. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/assets/icons/app.ico +0 -0
  28. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/assets/icons/app.png +0 -0
  29. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/assets/icons/app_big.png +0 -0
  30. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/assets/icons/arrow_down.svg +0 -0
  31. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/assets/icons/tray.png +0 -0
  32. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/assets/sounds/start.wav +0 -0
  33. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/assets/sounds/stop.wav +0 -0
  34. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/assets/styles/dark.qss +0 -0
  35. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/core/__init__.py +0 -0
  36. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/core/audio_manager.py +0 -0
  37. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/core/hotkey.py +0 -0
  38. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/core/logging.py +0 -0
  39. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/core/paths.py +0 -0
  40. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/core/recorder.py +0 -0
  41. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/core/session_logger.py +0 -0
  42. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/core/text_inserter.py +0 -0
  43. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/core/updater.py +0 -0
  44. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/core/window_detector.py +0 -0
  45. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/pipeline/__init__.py +0 -0
  46. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/providers/__init__.py +0 -0
  47. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/providers/base.py +0 -0
  48. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/ui/__init__.py +0 -0
  49. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/ui/animated.py +0 -0
  50. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/ui/overlay.py +0 -0
  51. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/ui/pages/__init__.py +0 -0
  52. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/ui/pages/statistics_page.py +0 -0
  53. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/ui/setup_wizard.py +0 -0
  54. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/ui/sidebar.py +0 -0
  55. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/paypertranscript/ui/widgets.py +0 -0
  56. {paypertranscript-0.2.7 → paypertranscript-0.2.9}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PayPerTranscript
3
- Version: 0.2.7
3
+ Version: 0.2.9
4
4
  Summary: Open-Source Voice-to-Text mit Pay-per-Use Pricing
5
5
  Author: PayPerTranscript Contributors
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PayPerTranscript
3
- Version: 0.2.7
3
+ Version: 0.2.9
4
4
  Summary: Open-Source Voice-to-Text mit Pay-per-Use Pricing
5
5
  Author: PayPerTranscript Contributors
6
6
  License-Expression: MIT
@@ -14,6 +14,8 @@ paypertranscript/assets/icons/app.png
14
14
  paypertranscript/assets/icons/app_big.png
15
15
  paypertranscript/assets/icons/arrow_down.svg
16
16
  paypertranscript/assets/icons/tray.png
17
+ paypertranscript/assets/icons/tray_green.png
18
+ paypertranscript/assets/icons/tray_orange.png
17
19
  paypertranscript/assets/sounds/start.wav
18
20
  paypertranscript/assets/sounds/stop.wav
19
21
  paypertranscript/assets/styles/dark.qss
@@ -1,3 +1,3 @@
1
1
  """PayPerTranscript - Voice-to-Text mit Pay-per-Use Pricing."""
2
2
 
3
- __version__ = "0.2.7"
3
+ __version__ = "0.2.9"
@@ -33,6 +33,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
33
33
  "provider": "groq",
34
34
  "stt_model": "whisper-large-v3-turbo",
35
35
  "llm_model": "openai/gpt-oss-20b",
36
+ "llm_temperature": 1.0,
36
37
  },
37
38
  "words": {
38
39
  "misspelled_words": [],
@@ -87,6 +88,7 @@ _SCHEMA: dict[str, type | tuple[type, ...]] = {
87
88
  "api.provider": str,
88
89
  "api.stt_model": str,
89
90
  "api.llm_model": str,
91
+ "api.llm_temperature": (int, float),
90
92
  "words.misspelled_words": list,
91
93
  "formatting.window_mappings": dict,
92
94
  "formatting.categories": dict,
@@ -97,11 +99,22 @@ _SCHEMA: dict[str, type | tuple[type, ...]] = {
97
99
  }
98
100
 
99
101
 
102
+ # Keys whose dict values represent user data collections (not config structure).
103
+ # These are replaced entirely by the user's saved value, not recursively merged,
104
+ # so that deletions of default entries persist across restarts.
105
+ _REPLACE_KEYS = frozenset({"window_mappings", "categories"})
106
+
107
+
100
108
  def _deep_merge(base: dict, override: dict) -> dict:
101
109
  """Merge override in base (rekursiv). Gibt neues Dict zurück."""
102
110
  result = copy.deepcopy(base)
103
111
  for key, value in override.items():
104
- if key in result and isinstance(result[key], dict) and isinstance(value, dict):
112
+ if (
113
+ key in result
114
+ and isinstance(result[key], dict)
115
+ and isinstance(value, dict)
116
+ and key not in _REPLACE_KEYS
117
+ ):
105
118
  result[key] = _deep_merge(result[key], value)
106
119
  else:
107
120
  result[key] = copy.deepcopy(value)
@@ -6,12 +6,18 @@ Keine I/O, keine Seiteneffekte - einfach testbar.
6
6
 
7
7
  from dataclasses import dataclass
8
8
 
9
- # STT/LLM API-Preise (Stand: 2026-02)
9
+ # STT API-Preise (Stand: 2026-02)
10
10
  STT_PRICE_PER_HOUR_USD = 0.04
11
11
  STT_MIN_BILLED_SECONDS = 10 # API-seitiges Minimum-Billing
12
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
13
+ # LLM-Preise pro Modell: (Input USD/M Tokens, Output USD/M Tokens)
14
+ LLM_PRICES: dict[str, tuple[float, float]] = {
15
+ "openai/gpt-oss-20b": (0.075, 0.30),
16
+ "openai/gpt-oss-120b": (0.15, 0.60),
17
+ "moonshotai/kimi-k2-instruct-0905": (1.00, 3.00),
18
+ }
19
+
20
+ _DEFAULT_LLM_PRICES = (0.075, 0.30) # Fallback
15
21
 
16
22
 
17
23
  @dataclass(frozen=True)
@@ -41,19 +47,22 @@ def calculate_stt_cost(audio_duration_seconds: float) -> tuple[float, float]:
41
47
  return billed, cost
42
48
 
43
49
 
44
- def calculate_llm_cost(input_tokens: int, output_tokens: int) -> float:
50
+ def calculate_llm_cost(
51
+ input_tokens: int, output_tokens: int, model: str = "",
52
+ ) -> float:
45
53
  """Berechnet LLM-Kosten.
46
54
 
47
55
  Args:
48
56
  input_tokens: Anzahl Input-Tokens.
49
57
  output_tokens: Anzahl Output-Tokens.
58
+ model: LLM-Modellname fuer modellspezifische Preise.
50
59
 
51
60
  Returns:
52
61
  Kosten in USD.
53
62
  """
63
+ input_price, output_price = LLM_PRICES.get(model, _DEFAULT_LLM_PRICES)
54
64
  return (
55
- input_tokens * LLM_INPUT_PRICE_PER_M_TOKENS
56
- + output_tokens * LLM_OUTPUT_PRICE_PER_M_TOKENS
65
+ input_tokens * input_price + output_tokens * output_price
57
66
  ) / 1_000_000
58
67
 
59
68
 
@@ -61,6 +70,7 @@ def calculate_total_cost(
61
70
  audio_duration_seconds: float,
62
71
  llm_input_tokens: int = 0,
63
72
  llm_output_tokens: int = 0,
73
+ llm_model: str = "",
64
74
  ) -> CostResult:
65
75
  """Berechnet Gesamtkosten einer Transkription.
66
76
 
@@ -68,12 +78,13 @@ def calculate_total_cost(
68
78
  audio_duration_seconds: Audio-Dauer in Sekunden.
69
79
  llm_input_tokens: LLM Input-Tokens (0 wenn kein LLM).
70
80
  llm_output_tokens: LLM Output-Tokens (0 wenn kein LLM).
81
+ llm_model: LLM-Modellname fuer modellspezifische Preise.
71
82
 
72
83
  Returns:
73
84
  CostResult mit allen Kosten-Details.
74
85
  """
75
86
  billed, stt_cost = calculate_stt_cost(audio_duration_seconds)
76
- llm_cost = calculate_llm_cost(llm_input_tokens, llm_output_tokens)
87
+ llm_cost = calculate_llm_cost(llm_input_tokens, llm_output_tokens, llm_model)
77
88
  return CostResult(
78
89
  audio_duration_seconds=audio_duration_seconds,
79
90
  billed_seconds=billed,
@@ -165,6 +165,7 @@ class TranscriptionPipeline:
165
165
  audio_duration_seconds=audio_duration,
166
166
  llm_input_tokens=llm_input_tokens,
167
167
  llm_output_tokens=llm_output_tokens,
168
+ llm_model=self._config.get("api.llm_model", ""),
168
169
  )
169
170
 
170
171
  session_data = {
@@ -87,14 +87,16 @@ class GroqLLMProvider(AbstractLLMProvider):
87
87
  self,
88
88
  api_key: str | None = None,
89
89
  model: str = "openai/gpt-oss-20b",
90
+ temperature: float | None = None,
90
91
  ) -> None:
91
92
  self._model = model
93
+ self._temperature = temperature
92
94
  self._last_usage: dict[str, int] | None = None
93
95
  try:
94
96
  self._client = groq.Groq(api_key=api_key)
95
97
  except groq.GroqError as e:
96
98
  raise ProviderError(f"Groq-Client konnte nicht erstellt werden: {e}") from e
97
- log.info("GroqLLMProvider initialisiert (Modell: %s)", self._model)
99
+ log.info("GroqLLMProvider initialisiert (Modell: %s, Temperature: %s)", self._model, self._temperature)
98
100
 
99
101
  @property
100
102
  def last_usage(self) -> dict[str, int] | None:
@@ -106,17 +108,25 @@ class GroqLLMProvider(AbstractLLMProvider):
106
108
  ) -> list[dict[str, str]]:
107
109
  return [
108
110
  {"role": "system", "content": system_prompt},
109
- {"role": "user", "content": text},
111
+ {"role": "user", "content": f"<transcript>{text}</transcript>"},
110
112
  ]
111
113
 
114
+ def _completion_kwargs(self) -> dict:
115
+ """Baut gemeinsame kwargs für chat.completions.create."""
116
+ kwargs: dict = {}
117
+ if self._temperature is not None:
118
+ kwargs["temperature"] = self._temperature
119
+ return kwargs
120
+
112
121
  def format_text(self, system_prompt: str, text: str) -> str:
113
- log.info("LLM-Anfrage (non-streaming, Modell: %s)", self._model)
122
+ log.info("LLM-Anfrage (non-streaming, Modell: %s, Temperature: %s)", self._model, self._temperature)
114
123
  self._last_usage = None
115
124
  try:
116
125
  response = self._client.chat.completions.create(
117
126
  model=self._model,
118
127
  messages=self._build_messages(system_prompt, text),
119
128
  stream=False,
129
+ **self._completion_kwargs(),
120
130
  )
121
131
  except groq.AuthenticationError as e:
122
132
  raise ProviderError(f"API-Key ungültig: {e}") from e
@@ -142,13 +152,14 @@ class GroqLLMProvider(AbstractLLMProvider):
142
152
  return result
143
153
 
144
154
  def format_text_stream(self, system_prompt: str, text: str) -> Iterator[str]:
145
- log.info("LLM-Anfrage (streaming, Modell: %s)", self._model)
155
+ log.info("LLM-Anfrage (streaming, Modell: %s, Temperature: %s)", self._model, self._temperature)
146
156
  self._last_usage = None
147
157
  try:
148
158
  stream = self._client.chat.completions.create(
149
159
  model=self._model,
150
160
  messages=self._build_messages(system_prompt, text),
151
161
  stream=True,
162
+ **self._completion_kwargs(),
152
163
  )
153
164
  except groq.AuthenticationError as e:
154
165
  raise ProviderError(f"API-Key ungültig: {e}") from e
@@ -241,6 +241,7 @@ class PayPerTranscriptApp:
241
241
  config.get("api.provider", "groq"),
242
242
  model=config.get("api.llm_model", "openai/gpt-oss-20b"),
243
243
  api_key=api_key,
244
+ temperature=config.get("api.llm_temperature"),
244
245
  )
245
246
  except ProviderError as e:
246
247
  log.warning(
@@ -46,4 +46,13 @@ STT_MODELS: list[str] = [
46
46
  # LLM-Modelle
47
47
  LLM_MODELS: list[str] = [
48
48
  "openai/gpt-oss-20b",
49
+ "openai/gpt-oss-120b",
50
+ "moonshotai/kimi-k2-instruct-0905",
49
51
  ]
52
+
53
+ # LLM-Modell-Metadaten: (Standard-Temperature, Empfohlene Temperature)
54
+ LLM_MODEL_DEFAULTS: dict[str, tuple[float, float]] = {
55
+ "openai/gpt-oss-20b": (1.0, 0.6),
56
+ "openai/gpt-oss-120b": (1.0, 0.6),
57
+ "moonshotai/kimi-k2-instruct-0905": (0.6, 0.4),
58
+ }
@@ -16,10 +16,6 @@ from paypertranscript.core.logging import get_logger
16
16
  from paypertranscript.core.paths import get_icons_dir
17
17
  from paypertranscript.core.session_logger import SessionLogger
18
18
  from paypertranscript.ui.pages.home_page import HomePage
19
- from paypertranscript.ui.pages.settings_page import SettingsPage
20
- from paypertranscript.ui.pages.statistics_page import StatisticsPage
21
- from paypertranscript.ui.pages.window_mapping_page import WindowMappingPage
22
- from paypertranscript.ui.pages.word_list_page import WordListPage
23
19
  from paypertranscript.ui.sidebar import Sidebar
24
20
 
25
21
  log = get_logger("ui.main_window")
@@ -86,29 +82,81 @@ class MainWindow(QDialog):
86
82
  self._stack = QStackedWidget()
87
83
  self._stack.setStyleSheet("QStackedWidget { background-color: #121218; }")
88
84
 
89
- # Seiten erstellen (Reihenfolge muss mit Sidebar-Indizes uebereinstimmen)
90
- self._home_page = HomePage(config, session_logger, get_last_transcription)
91
- self._statistics_page = StatisticsPage(session_logger) if session_logger else QWidget()
92
- self._settings_page = SettingsPage(config, hotkey_listener)
93
- self._word_list_page = WordListPage(config)
94
- self._window_mapping_page = WindowMappingPage(config)
85
+ # Lazy Page Loading: Nur HomePage sofort erstellen, Rest on-demand.
86
+ self._pages: dict[int, QWidget] = {}
87
+ self._page_factories: dict[int, Callable[[], QWidget]] = {
88
+ 1: lambda: self._create_statistics_page(session_logger),
89
+ 2: lambda: self._create_settings_page(config, hotkey_listener),
90
+ 3: lambda: self._create_word_list_page(config),
91
+ 4: lambda: self._create_window_mapping_page(config),
92
+ }
95
93
 
96
- self._stack.addWidget(self._home_page) # 0
97
- self._stack.addWidget(self._statistics_page) # 1
98
- self._stack.addWidget(self._settings_page) # 2
99
- self._stack.addWidget(self._word_list_page) # 3
100
- self._stack.addWidget(self._window_mapping_page) # 4
94
+ # HomePage (Index 0) sofort — wird bei jedem Oeffnen angezeigt
95
+ home = HomePage(config, session_logger, get_last_transcription)
96
+ self._pages[0] = home
97
+ self._stack.addWidget(home) # 0
98
+
99
+ # Placeholders fuer restliche Seiten
100
+ for _ in range(1, 5):
101
+ self._stack.addWidget(QWidget())
101
102
 
102
103
  layout.addWidget(self._stack, 1)
103
104
 
105
+ # -- Lazy Page Factories --
106
+
107
+ @staticmethod
108
+ def _create_statistics_page(session_logger: SessionLogger | None) -> QWidget:
109
+ from paypertranscript.ui.pages.statistics_page import StatisticsPage
110
+
111
+ return StatisticsPage(session_logger) if session_logger else QWidget()
112
+
113
+ @staticmethod
114
+ def _create_settings_page(
115
+ config: ConfigManager, hotkey_listener: HotkeyListener | None
116
+ ) -> QWidget:
117
+ from paypertranscript.ui.pages.settings_page import SettingsPage
118
+
119
+ return SettingsPage(config, hotkey_listener)
120
+
121
+ @staticmethod
122
+ def _create_word_list_page(config: ConfigManager) -> QWidget:
123
+ from paypertranscript.ui.pages.word_list_page import WordListPage
124
+
125
+ return WordListPage(config)
126
+
127
+ @staticmethod
128
+ def _create_window_mapping_page(config: ConfigManager) -> QWidget:
129
+ from paypertranscript.ui.pages.window_mapping_page import WindowMappingPage
130
+
131
+ return WindowMappingPage(config)
132
+
133
+ def _ensure_page(self, index: int) -> None:
134
+ """Erstellt die Seite beim ersten Zugriff (lazy loading)."""
135
+ if index in self._pages:
136
+ return
137
+ factory = self._page_factories.pop(index, None)
138
+ if factory is None:
139
+ return
140
+ page = factory()
141
+ self._pages[index] = page
142
+ old = self._stack.widget(index)
143
+ self._stack.removeWidget(old)
144
+ self._stack.insertWidget(index, page)
145
+ old.deleteLater()
146
+ log.debug("Seite %d lazy erstellt: %s", index, type(page).__name__)
147
+
148
+ # -- Navigation --
149
+
104
150
  def _on_page_changed(self, index: int) -> None:
105
- """Wechselt zur angeforderten Seite."""
151
+ """Wechselt zur angeforderten Seite (lazy creation bei Erstaufruf)."""
152
+ self._ensure_page(index)
106
153
  self._stack.setCurrentIndex(index)
107
154
  self._sidebar.set_active(index)
108
155
 
109
156
  def navigate_to(self, page_index: int) -> None:
110
157
  """Navigiert zu einer bestimmten Seite und zeigt das Fenster."""
111
158
  if 0 <= page_index < self._stack.count():
159
+ self._ensure_page(page_index)
112
160
  self._stack.setCurrentIndex(page_index)
113
161
  self._sidebar.set_active(page_index)
114
162
  self.show()
@@ -209,8 +209,8 @@ class HomePage(QWidget):
209
209
  self._hotkey_value.setText(" · ".join(parts))
210
210
 
211
211
  # -- Modelle --
212
- stt_model = self._config.get("stt.model", "whisper-large-v3-turbo")
213
- llm_model = self._config.get("llm.model", "openai/gpt-oss-20b")
212
+ stt_model = self._config.get("api.stt_model", "whisper-large-v3-turbo")
213
+ llm_model = self._config.get("api.llm_model", "openai/gpt-oss-20b")
214
214
  self._model_value.setText(f"STT: {stt_model} · LLM: {llm_model}")
215
215
 
216
216
  # -- Sprache + API-Status --
@@ -18,6 +18,7 @@ from PySide6.QtWidgets import (
18
18
  QPushButton,
19
19
  QRadioButton,
20
20
  QScrollArea,
21
+ QSlider,
21
22
  QSpinBox,
22
23
  QVBoxLayout,
23
24
  QWidget,
@@ -29,6 +30,7 @@ from paypertranscript.core.logging import get_logger
29
30
  from paypertranscript.ui.constants import (
30
31
  HOLD_PRESETS,
31
32
  LANGUAGES,
33
+ LLM_MODEL_DEFAULTS,
32
34
  LLM_MODELS,
33
35
  STT_MODELS,
34
36
  TOGGLE_PRESETS,
@@ -221,6 +223,34 @@ class SettingsPage(QWidget):
221
223
  self._llm_combo.currentTextChanged.connect(self._on_llm_model_changed)
222
224
  api_layout.addWidget(self._llm_combo)
223
225
 
226
+ api_layout.addSpacing(8)
227
+
228
+ # Temperature
229
+ temp_header = QHBoxLayout()
230
+ temp_label = QLabel("LLM Temperature:")
231
+ temp_header.addWidget(temp_label)
232
+ temp_header.addStretch()
233
+ self._temp_value_label = QLabel("Standard")
234
+ self._temp_value_label.setProperty("subheading", True)
235
+ temp_header.addWidget(self._temp_value_label)
236
+ api_layout.addLayout(temp_header)
237
+
238
+ self._temp_slider = QSlider(Qt.Orientation.Horizontal)
239
+ self._temp_slider.setRange(0, 20) # 0..20 → 0.0..2.0 in 0.1-Schritten
240
+ self._temp_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
241
+ self._temp_slider.setTickInterval(2)
242
+ self._temp_slider.setSingleStep(1)
243
+ self._temp_slider.setPageStep(2)
244
+ self._temp_slider.valueChanged.connect(self._on_temperature_changed)
245
+ api_layout.addWidget(self._temp_slider)
246
+
247
+ self._temp_hint = QLabel("")
248
+ self._temp_hint.setProperty("subheading", True)
249
+ self._temp_hint.setWordWrap(True)
250
+ api_layout.addWidget(self._temp_hint)
251
+
252
+ api_layout.addSpacing(4)
253
+
224
254
  hint = QLabel("Modell-\u00c4nderungen werden nach Neustart wirksam.")
225
255
  hint.setProperty("subheading", True)
226
256
  api_layout.addWidget(hint)
@@ -337,6 +367,17 @@ class SettingsPage(QWidget):
337
367
  if idx >= 0:
338
368
  self._llm_combo.setCurrentIndex(idx)
339
369
 
370
+ llm_temp = self._config.get("api.llm_temperature", 1.0)
371
+ # Fallback fuer alte Configs mit None
372
+ if llm_temp is None:
373
+ model = self._llm_combo.currentText()
374
+ llm_temp = LLM_MODEL_DEFAULTS.get(model, (1.0, 1.0))[0]
375
+ slider_val = round(float(llm_temp) * 10)
376
+ self._temp_slider.setValue(max(0, min(20, slider_val)))
377
+ self._temp_value_label.setText(f"{llm_temp:.1f}")
378
+
379
+ self._update_temp_hint()
380
+
340
381
  self._retention_spin.setValue(self._config.get("data.audio_retention_hours", 24))
341
382
  self._chk_transcripts.setChecked(self._config.get("data.save_transcripts", False))
342
383
 
@@ -485,6 +526,35 @@ class SettingsPage(QWidget):
485
526
  if self._updating:
486
527
  return
487
528
  self._config.set("api.llm_model", text)
529
+ # Slider auf den Standard-Wert des neuen Modells setzen
530
+ default_temp = LLM_MODEL_DEFAULTS.get(text, (1.0, 1.0))[0]
531
+ self._updating = True
532
+ slider_val = round(default_temp * 10)
533
+ self._temp_slider.setValue(max(0, min(20, slider_val)))
534
+ self._temp_value_label.setText(f"{default_temp:.1f}")
535
+ self._updating = False
536
+ self._config.set("api.llm_temperature", default_temp)
537
+ self._update_temp_hint()
538
+
539
+ def _on_temperature_changed(self, value: int) -> None:
540
+ if self._updating:
541
+ return
542
+ temp = value / 10.0
543
+ self._temp_value_label.setText(f"{temp:.1f}")
544
+ self._config.set("api.llm_temperature", temp)
545
+
546
+ def _update_temp_hint(self) -> None:
547
+ """Zeigt modellspezifischen Temperature-Hinweis."""
548
+ model = self._llm_combo.currentText()
549
+ meta = LLM_MODEL_DEFAULTS.get(model)
550
+ if meta:
551
+ default_temp, recommended_temp = meta
552
+ self._temp_hint.setText(
553
+ f"Standard: {default_temp:.1f} | Empfohlen: {recommended_temp:.1f}"
554
+ )
555
+ self._temp_hint.setVisible(True)
556
+ else:
557
+ self._temp_hint.setVisible(False)
488
558
 
489
559
  # -- Callbacks: Daten & Updates --
490
560
 
@@ -166,30 +166,18 @@ class _MappingItemDelegate(QStyledItemDelegate):
166
166
  option: QStyleOptionViewItem,
167
167
  index: QModelIndex,
168
168
  ) -> bool:
169
- if event.type() in (QEvent.Type.MouseMove, QEvent.Type.MouseButtonRelease):
169
+ # Click handling is done via viewport event filter in WindowMappingPage
170
+ # (editorEvent does not receive MouseButtonRelease with default edit triggers)
171
+ if event.type() == QEvent.Type.MouseMove:
170
172
  mouse: QMouseEvent = event # type: ignore[assignment]
171
- x = mouse.position().x()
172
- delete_left = option.rect.right() - self._ICON_SIZE
173
- edit_left = option.rect.right() - self._ICONS_TOTAL
174
- over_icon = x >= edit_left
175
-
176
- if event.type() == QEvent.Type.MouseMove:
177
- view = option.widget
178
- if view is not None:
179
- if over_icon:
180
- view.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
181
- else:
182
- view.unsetCursor()
183
- return False
184
-
185
- # MouseButtonRelease
186
- if x >= delete_left:
187
- self._on_delete(index.row())
188
- return True
189
- if x >= edit_left:
190
- self._on_edit(index.row())
191
- return True
192
-
173
+ over_icon = mouse.position().x() >= option.rect.right() - self._ICONS_TOTAL
174
+ view = option.widget
175
+ if view is not None:
176
+ if over_icon:
177
+ view.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
178
+ else:
179
+ view.unsetCursor()
180
+ return False
193
181
  return super().editorEvent(event, model, option, index)
194
182
 
195
183
  def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
@@ -263,24 +251,17 @@ class _CategoryItemDelegate(QStyledItemDelegate):
263
251
  option: QStyleOptionViewItem,
264
252
  index: QModelIndex,
265
253
  ) -> bool:
266
- if event.type() in (QEvent.Type.MouseMove, QEvent.Type.MouseButtonRelease):
254
+ # Click handling is done via viewport event filter in WindowMappingPage
255
+ if event.type() == QEvent.Type.MouseMove:
267
256
  mouse: QMouseEvent = event # type: ignore[assignment]
268
257
  over_icon = mouse.position().x() >= option.rect.right() - self._ICON_SIZE
269
-
270
- if event.type() == QEvent.Type.MouseMove:
271
- view = option.widget
272
- if view is not None:
273
- if over_icon:
274
- view.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
275
- else:
276
- view.unsetCursor()
277
- return False
278
-
279
- # MouseButtonRelease
280
- if over_icon:
281
- self._on_delete(index.row())
282
- return True
283
-
258
+ view = option.widget
259
+ if view is not None:
260
+ if over_icon:
261
+ view.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
262
+ else:
263
+ view.unsetCursor()
264
+ return False
284
265
  return super().editorEvent(event, model, option, index)
285
266
 
286
267
  def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
@@ -321,6 +302,11 @@ class WindowMappingPage(QWidget):
321
302
  self._tabs.addTab(self._create_categories_tab(), "Kategorien")
322
303
  layout.addWidget(self._tabs, 1)
323
304
 
305
+ # Viewport event filters for reliable icon click handling
306
+ # (editorEvent does not receive MouseButtonRelease with default edit triggers)
307
+ self._mapping_list.viewport().installEventFilter(self)
308
+ self._cat_list.viewport().installEventFilter(self)
309
+
324
310
  # -- Tab: Zuordnungen --
325
311
 
326
312
  def _create_mappings_tab(self) -> QWidget:
@@ -438,6 +424,46 @@ class WindowMappingPage(QWidget):
438
424
 
439
425
  return tab
440
426
 
427
+ # -- Viewport event filter: icon clicks --
428
+
429
+ def eventFilter(self, watched: object, event: QEvent) -> bool:
430
+ """Intercept mouse clicks on delegate icons (edit/delete)."""
431
+ if event.type() == QEvent.Type.MouseButtonRelease:
432
+ mouse: QMouseEvent = event # type: ignore[assignment]
433
+ if watched is self._mapping_list.viewport():
434
+ return self._handle_mapping_icon_click(mouse)
435
+ if watched is self._cat_list.viewport():
436
+ return self._handle_category_icon_click(mouse)
437
+ return super().eventFilter(watched, event)
438
+
439
+ def _handle_mapping_icon_click(self, event: QMouseEvent) -> bool:
440
+ pos = event.position().toPoint()
441
+ index = self._mapping_list.indexAt(pos)
442
+ if not index.isValid():
443
+ return False
444
+ rect = self._mapping_list.visualRect(index)
445
+ x = event.position().x()
446
+ delete_left = rect.right() - _MappingItemDelegate._ICON_SIZE
447
+ edit_left = rect.right() - _MappingItemDelegate._ICONS_TOTAL
448
+ if x >= delete_left:
449
+ self._remove_mapping_at(index.row())
450
+ return True
451
+ if x >= edit_left:
452
+ self._edit_mapping_at(index.row())
453
+ return True
454
+ return False
455
+
456
+ def _handle_category_icon_click(self, event: QMouseEvent) -> bool:
457
+ pos = event.position().toPoint()
458
+ index = self._cat_list.indexAt(pos)
459
+ if not index.isValid():
460
+ return False
461
+ rect = self._cat_list.visualRect(index)
462
+ if event.position().x() >= rect.right() - _CategoryItemDelegate._ICON_SIZE:
463
+ self._delete_category_at(index.row())
464
+ return True
465
+ return False
466
+
441
467
  # -- Daten laden --
442
468
 
443
469
  def _reload_all(self) -> None:
@@ -110,27 +110,17 @@ class _WordItemDelegate(QStyledItemDelegate):
110
110
  option: QStyleOptionViewItem,
111
111
  index: QModelIndex,
112
112
  ) -> bool:
113
- """Start editing when the edit icon area is clicked."""
114
- if event.type() in (QEvent.Type.MouseMove, QEvent.Type.MouseButtonRelease):
113
+ """Cursor change on hover; click handling via viewport event filter."""
114
+ if event.type() == QEvent.Type.MouseMove:
115
115
  mouse: QMouseEvent = event # type: ignore[assignment]
116
- edit_left = option.rect.right() - self._EDIT_WIDTH
117
- over_icon = mouse.position().x() >= edit_left
118
-
119
- if event.type() == QEvent.Type.MouseMove:
120
- view = option.widget
121
- if view is not None:
122
- if over_icon:
123
- view.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
124
- else:
125
- view.unsetCursor()
126
- return False
127
-
128
- # MouseButtonRelease
129
- if over_icon:
130
- view = option.widget
131
- if view is not None:
132
- view.edit(index)
133
- return True
116
+ over_icon = mouse.position().x() >= option.rect.right() - self._EDIT_WIDTH
117
+ view = option.widget
118
+ if view is not None:
119
+ if over_icon:
120
+ view.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
121
+ else:
122
+ view.unsetCursor()
123
+ return False
134
124
  return super().editorEvent(event, model, option, index)
135
125
 
136
126
  def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
@@ -200,6 +190,9 @@ class WordListPage(QWidget):
200
190
  self._list.itemChanged.connect(self._on_word_edited)
201
191
  layout.addWidget(self._list, 1)
202
192
 
193
+ # Viewport event filter for reliable edit icon click handling
194
+ self._list.viewport().installEventFilter(self)
195
+
203
196
  # Entfernen + Token-Counter
204
197
  action_row = QHBoxLayout()
205
198
  btn_remove = QPushButton("Entfernen")
@@ -213,6 +206,22 @@ class WordListPage(QWidget):
213
206
  action_row.addWidget(self._counter_label)
214
207
  layout.addLayout(action_row)
215
208
 
209
+ def eventFilter(self, watched: object, event: QEvent) -> bool:
210
+ """Intercept mouse clicks on the edit (pencil) icon."""
211
+ if (
212
+ event.type() == QEvent.Type.MouseButtonRelease
213
+ and watched is self._list.viewport()
214
+ ):
215
+ mouse: QMouseEvent = event # type: ignore[assignment]
216
+ pos = mouse.position().toPoint()
217
+ index = self._list.indexAt(pos)
218
+ if index.isValid():
219
+ rect = self._list.visualRect(index)
220
+ if mouse.position().x() >= rect.right() - _WordItemDelegate._EDIT_WIDTH:
221
+ self._list.edit(index)
222
+ return True
223
+ return super().eventFilter(watched, event)
224
+
216
225
  def _load_words(self) -> None:
217
226
  self._updating = True
218
227
  words = self._config.get("words.misspelled_words", [])
@@ -5,8 +5,8 @@ QSystemTrayIcon mit Kontextmenue und Icon-Zustandswechsel.
5
5
 
6
6
  from collections.abc import Callable
7
7
 
8
- from PySide6.QtCore import QRectF, Qt, Slot
9
- from PySide6.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
8
+ from PySide6.QtCore import Qt, Slot
9
+ from PySide6.QtGui import QIcon, QPixmap
10
10
  from PySide6.QtWidgets import (
11
11
  QHBoxLayout,
12
12
  QLabel,
@@ -27,84 +27,39 @@ from paypertranscript.core.session_logger import SessionLogger
27
27
 
28
28
  log = get_logger("ui.tray")
29
29
 
30
- # Status-Dot Farben (konsistent mit Overlay-Design)
31
- _DOT_RECORDING = "#f87171" # Rot (universelle Aufnahme-Konvention)
32
- _DOT_PROCESSING = "#34d399" # Emerald (aktive Verarbeitung)
30
+ # Icon-Dateinamen fuer die drei Zustaende
31
+ _ICON_FILES: dict[str, str] = {
32
+ "idle": "tray.png",
33
+ "recording": "tray_orange.png",
34
+ "processing": "tray_green.png",
35
+ }
33
36
 
34
- # Dot-Groesse relativ zum Icon
35
- _DOT_RADIUS_RATIO = 0.30 # 30% der Icon-Groesse (~60% Durchmesser)
36
- _DOT_BORDER_RATIO = 0.04 # Dunkler Rand um den Dot
37
37
 
38
+ def _load_icon(filename: str, size: int = 64) -> QIcon:
39
+ """Laedt ein Tray-Icon aus den Assets.
38
40
 
39
- def _load_base_pixmap(size: int = 64) -> QPixmap:
40
- """Laedt das Basis-Logo fuer das Tray-Icon.
41
-
42
- Sucht nach tray.png in den Assets. Fallback: weisser Kreis.
41
+ Args:
42
+ filename: Dateiname im icons-Ordner.
43
+ size: Zielgroesse in Pixeln.
43
44
  """
44
- tray_path = get_icons_dir() / "tray.png"
45
- if tray_path.exists():
46
- source = QPixmap(str(tray_path))
47
- if not source.isNull():
48
- return source.scaled(
45
+ path = get_icons_dir() / filename
46
+ if path.exists():
47
+ pixmap = QPixmap(str(path))
48
+ if not pixmap.isNull():
49
+ scaled = pixmap.scaled(
49
50
  size, size,
50
51
  Qt.AspectRatioMode.KeepAspectRatio,
51
52
  Qt.TransformationMode.SmoothTransformation,
52
53
  )
54
+ return QIcon(scaled)
53
55
 
54
- # Fallback: weisser Kreis (wie bisher)
55
- pixmap = QPixmap(size, size)
56
- pixmap.fill(QColor(0, 0, 0, 0))
57
- painter = QPainter(pixmap)
58
- painter.setRenderHint(QPainter.RenderHint.Antialiasing)
59
- painter.setBrush(QColor("#ffffff"))
60
- painter.setPen(QColor(0, 0, 0, 0))
61
- painter.drawEllipse(4, 4, size - 8, size - 8)
62
- painter.end()
63
- return pixmap
64
-
65
-
66
- def _create_icon_with_dot(base: QPixmap, dot_color: str | None = None) -> QIcon:
67
- """Erstellt ein Tray-Icon aus dem Basis-Logo, optional mit Status-Dot.
68
-
69
- Args:
70
- base: Basis-Pixmap (Logo).
71
- dot_color: Hex-Farbe fuer den Status-Dot. None = kein Dot (idle).
72
- """
73
- pixmap = base.copy()
74
-
75
- if dot_color is not None:
76
- size = pixmap.width()
77
- dot_r = size * _DOT_RADIUS_RATIO
78
- border_w = max(1.0, size * _DOT_BORDER_RATIO)
79
-
80
- # Position: unten rechts mit kleinem Abstand zum Rand
81
- cx = size - dot_r - border_w - 1
82
- cy = size - dot_r - border_w - 1
83
-
84
- painter = QPainter(pixmap)
85
- painter.setRenderHint(QPainter.RenderHint.Antialiasing)
86
-
87
- # Dunkler Rand (hebt den Dot vom Logo ab)
88
- painter.setPen(QPen(QColor(18, 18, 24, 200), border_w))
89
- painter.setBrush(QColor(dot_color))
90
- painter.drawEllipse(QRectF(cx - dot_r, cy - dot_r, dot_r * 2, dot_r * 2))
91
-
92
- painter.end()
93
-
94
- return QIcon(pixmap)
56
+ log.warning("Tray-Icon nicht gefunden: %s", path)
57
+ return QIcon()
95
58
 
96
59
 
97
60
  def create_tray_icons() -> dict[str, QIcon]:
98
- """Erstellt alle Tray-Icons (idle, recording, processing).
99
-
100
- Idle: Logo pur. Recording: Logo + Emerald-Dot. Processing: Logo + Grau-Dot.
101
- """
102
- base = _load_base_pixmap(64)
103
- return {
104
- "idle": _create_icon_with_dot(base),
105
- "recording": _create_icon_with_dot(base, _DOT_RECORDING),
106
- "processing": _create_icon_with_dot(base, _DOT_PROCESSING),
107
- }
61
+ """Laedt alle Tray-Icons (idle, recording, processing) aus PNG-Dateien."""
62
+ return {state: _load_icon(fname) for state, fname in _ICON_FILES.items()}
108
63
 
109
64
 
110
65
  # ---------------------------------------------------------------------------
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "PayPerTranscript"
7
- version = "0.2.7"
7
+ version = "0.2.9"
8
8
  description = "Open-Source Voice-to-Text mit Pay-per-Use Pricing"
9
9
  license = "MIT"
10
10
  requires-python = ">=3.12"