shadow-clerk 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.
Files changed (44) hide show
  1. shadow_clerk/__init__.py +46 -0
  2. shadow_clerk/_daemon_audio.py +477 -0
  3. shadow_clerk/_daemon_config.py +58 -0
  4. shadow_clerk/_daemon_constants.py +187 -0
  5. shadow_clerk/_daemon_dashboard.py +5 -0
  6. shadow_clerk/_daemon_dashboard_base.py +358 -0
  7. shadow_clerk/_daemon_dashboard_css.py +253 -0
  8. shadow_clerk/_daemon_dashboard_handler.py +8 -0
  9. shadow_clerk/_daemon_dashboard_html.py +298 -0
  10. shadow_clerk/_daemon_dashboard_js.py +6 -0
  11. shadow_clerk/_daemon_dashboard_js_core.py +558 -0
  12. shadow_clerk/_daemon_dashboard_js_panels.py +589 -0
  13. shadow_clerk/_daemon_dashboard_ops.py +1001 -0
  14. shadow_clerk/_daemon_log_buffer.py +195 -0
  15. shadow_clerk/_daemon_main.py +206 -0
  16. shadow_clerk/_daemon_recorder.py +9 -0
  17. shadow_clerk/_daemon_recorder_capture.py +335 -0
  18. shadow_clerk/_daemon_recorder_command.py +581 -0
  19. shadow_clerk/_daemon_recorder_transcribe.py +725 -0
  20. shadow_clerk/_daemon_transcriber.py +208 -0
  21. shadow_clerk/_daemon_vad.py +99 -0
  22. shadow_clerk/_llm_config.py +151 -0
  23. shadow_clerk/_llm_glossary.py +260 -0
  24. shadow_clerk/_llm_summarize.py +359 -0
  25. shadow_clerk/_llm_translate.py +362 -0
  26. shadow_clerk/_transcript_name.py +198 -0
  27. shadow_clerk/clerk_daemon.py +25 -0
  28. shadow_clerk/clerk_util.py +510 -0
  29. shadow_clerk/domain/__init__.py +18 -0
  30. shadow_clerk/domain/language.py +34 -0
  31. shadow_clerk/domain/meeting_session.py +48 -0
  32. shadow_clerk/domain/speaker.py +24 -0
  33. shadow_clerk/domain/summary.py +24 -0
  34. shadow_clerk/domain/transcript_line.py +55 -0
  35. shadow_clerk/domain/translation.py +24 -0
  36. shadow_clerk/gcal_monitor.py +318 -0
  37. shadow_clerk/i18n.py +928 -0
  38. shadow_clerk/llm_client.py +234 -0
  39. shadow_clerk-0.2.0.dist-info/METADATA +650 -0
  40. shadow_clerk-0.2.0.dist-info/RECORD +44 -0
  41. shadow_clerk-0.2.0.dist-info/WHEEL +5 -0
  42. shadow_clerk-0.2.0.dist-info/entry_points.txt +3 -0
  43. shadow_clerk-0.2.0.dist-info/licenses/LICENSE +21 -0
  44. shadow_clerk-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,46 @@
1
+ """shadow-clerk: Web会議 議事録アシスタント"""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+
7
+ __version__ = "0.2.0"
8
+
9
+
10
+ def is_microsoft_store_python() -> bool:
11
+ """Microsoft Store 版 Python(AppContainer サンドボックスあり)で動作中か判定。"""
12
+ if sys.platform != "win32":
13
+ return False
14
+ exe = (sys.executable or "").replace("\\", "/").lower()
15
+ return (
16
+ "windowsapps/pythonsoftwarefoundation" in exe
17
+ or "/packages/pythonsoftwarefoundation" in exe
18
+ )
19
+
20
+
21
+ def get_data_dir() -> str:
22
+ """データディレクトリのパスを返す。
23
+
24
+ SHADOW_CLERK_DATA_DIR 環境変数で上書き可能。
25
+ デフォルト:
26
+ - Windows: %APPDATA%\\shadow-clerk
27
+ - Linux/その他: ~/.local/share/shadow-clerk
28
+
29
+ 注意: Microsoft Store 版 Python では %APPDATA% が AppContainer
30
+ サンドボックス(`%LOCALAPPDATA%\\Packages\\<pkg-id>\\LocalCache\\Roaming\\`)
31
+ にリダイレクトされるため、Python マイナーバージョンが変わるとデータが
32
+ 別パスに移ることになる。uv 管理 Python(`uv python install`)か
33
+ python.org 版 Python を推奨。
34
+ """
35
+ if env := os.environ.get("SHADOW_CLERK_DATA_DIR"):
36
+ return env
37
+ if sys.platform == "win32":
38
+ appdata = os.environ.get("APPDATA")
39
+ if appdata:
40
+ return os.path.join(appdata, "shadow-clerk")
41
+ return os.path.expanduser("~/AppData/Roaming/shadow-clerk")
42
+ return os.path.expanduser("~/.local/share/shadow-clerk")
43
+
44
+
45
+ DATA_DIR = get_data_dir()
46
+ CONFIG_FILE = os.path.join(DATA_DIR, "config.yaml")
@@ -0,0 +1,477 @@
1
+ """Shadow-clerk daemon: 音声バックエンド"""
2
+ from __future__ import annotations
3
+ import logging
4
+ import queue
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import threading
9
+ from typing import Any
10
+ from shadow_clerk.i18n import t
11
+ from shadow_clerk._daemon_constants import SAMPLE_RATE, CHANNELS, FRAME_SIZE
12
+
13
+ logger = logging.getLogger("shadow-clerk")
14
+
15
+
16
+ class AudioBackend:
17
+ """音声バックエンド基底クラス"""
18
+
19
+ def detect_monitor_source(self) -> str | None:
20
+ raise NotImplementedError
21
+
22
+ def list_devices(self) -> None:
23
+ raise NotImplementedError
24
+
25
+
26
+ class PipeWireBackend(AudioBackend):
27
+ """PipeWire バックエンド"""
28
+
29
+ @staticmethod
30
+ def is_available() -> bool:
31
+ return shutil.which("pw-record") is not None
32
+
33
+ def detect_monitor_source(self) -> str | None:
34
+ # wpctl でデフォルト Sink のノード ID を取得
35
+ if shutil.which("wpctl"):
36
+ try:
37
+ result = subprocess.run(
38
+ ["wpctl", "inspect", "@DEFAULT_AUDIO_SINK@"],
39
+ capture_output=True, text=True, timeout=5,
40
+ )
41
+ # 1行目: "id 74, type PipeWire:Interface:Node"
42
+ first = result.stdout.split("\n", 1)[0]
43
+ if first.startswith("id "):
44
+ node_id = first.split(",")[0].split()[1]
45
+ logger.info("PipeWire デフォルト Sink ノード ID: %s (wpctl)", node_id)
46
+ return node_id
47
+ except (subprocess.TimeoutExpired, FileNotFoundError, IndexError, ValueError):
48
+ pass
49
+ # wpctl でノード ID が取れなかった場合は pw-record では使えないため None を返す。
50
+ # 呼び出し側が PulseAudio バックエンドへフォールバックする。
51
+ logger.debug("PipeWire: wpctl からノード ID を取得できませんでした。PulseAudio にフォールバックします。")
52
+ return None
53
+
54
+ def list_devices(self) -> None:
55
+ print(t("rec.pipewire_devices"))
56
+ if shutil.which("wpctl"):
57
+ try:
58
+ result = subprocess.run(
59
+ ["wpctl", "status"],
60
+ capture_output=True, text=True, timeout=5,
61
+ )
62
+ if result.stdout.strip():
63
+ print(result.stdout)
64
+ else:
65
+ print(t("rec.no_devices"))
66
+ return
67
+ except (subprocess.TimeoutExpired, FileNotFoundError):
68
+ pass
69
+ if shutil.which("pactl"):
70
+ try:
71
+ result = subprocess.run(
72
+ ["pactl", "list", "short", "sinks"],
73
+ capture_output=True, text=True, timeout=5,
74
+ )
75
+ if result.stdout.strip():
76
+ print(result.stdout)
77
+ else:
78
+ print(t("rec.no_devices"))
79
+ return
80
+ except (subprocess.TimeoutExpired, FileNotFoundError):
81
+ pass
82
+ print(t("rec.pw_unavailable"))
83
+
84
+ def start_monitor_capture(self, target: str, audio_queue: queue.Queue,
85
+ stop_event: threading.Event) -> None:
86
+ """pw-record でモニターソースをキャプチャ"""
87
+ cmd = [
88
+ "pw-record", "--target", target,
89
+ "--rate", str(SAMPLE_RATE),
90
+ "--channels", str(CHANNELS),
91
+ "--format", "s16",
92
+ "-",
93
+ ]
94
+ logger.info("PipeWire monitor capture: %s", " ".join(cmd))
95
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
96
+ assert proc.stdout is not None and proc.stderr is not None
97
+ try:
98
+ while not stop_event.is_set():
99
+ data = proc.stdout.read(FRAME_SIZE * 2)
100
+ if not data:
101
+ break
102
+ if len(data) == FRAME_SIZE * 2:
103
+ import numpy as np
104
+ samples = np.frombuffer(data, dtype=np.int16)
105
+ audio_queue.put(samples)
106
+ finally:
107
+ proc.terminate()
108
+ proc.wait()
109
+ err = proc.stderr.read()
110
+ if err:
111
+ logger.warning("pw-record stderr: %s", err.decode("utf-8", errors="replace").strip())
112
+
113
+
114
+ class PulseAudioBackend(AudioBackend):
115
+ """PulseAudio バックエンド"""
116
+
117
+ @staticmethod
118
+ def is_available() -> bool:
119
+ return shutil.which("pactl") is not None
120
+
121
+ def detect_monitor_source(self) -> str | None:
122
+ try:
123
+ result = subprocess.run(
124
+ ["pactl", "list", "short", "sources"],
125
+ capture_output=True, text=True, timeout=5,
126
+ )
127
+ for line in result.stdout.splitlines():
128
+ if ".monitor" in line:
129
+ parts = line.split("\t")
130
+ if len(parts) >= 2:
131
+ return parts[1]
132
+ except (subprocess.TimeoutExpired, FileNotFoundError):
133
+ pass
134
+ return None
135
+
136
+ def list_devices(self) -> None:
137
+ print(t("rec.pulseaudio_sources"))
138
+ try:
139
+ result = subprocess.run(
140
+ ["pactl", "list", "short", "sources"],
141
+ capture_output=True, text=True, timeout=5,
142
+ )
143
+ if result.stdout.strip():
144
+ print(result.stdout)
145
+ else:
146
+ print(t("rec.no_sources"))
147
+ except (subprocess.TimeoutExpired, FileNotFoundError):
148
+ print(t("rec.pa_unavailable"))
149
+
150
+ def start_monitor_capture(self, source: str, audio_queue: queue.Queue,
151
+ stop_event: threading.Event) -> None:
152
+ """parec でモニターソースをキャプチャ"""
153
+ cmd = [
154
+ "parec",
155
+ f"--device={source}",
156
+ f"--rate={SAMPLE_RATE}",
157
+ "--channels=1",
158
+ "--format=s16le",
159
+ ]
160
+ logger.info("PulseAudio monitor capture: %s", " ".join(cmd))
161
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
162
+ assert proc.stdout is not None and proc.stderr is not None
163
+ try:
164
+ while not stop_event.is_set():
165
+ data = proc.stdout.read(FRAME_SIZE * 2)
166
+ if not data:
167
+ break
168
+ if len(data) == FRAME_SIZE * 2:
169
+ import numpy as np
170
+ samples = np.frombuffer(data, dtype=np.int16)
171
+ audio_queue.put(samples)
172
+ finally:
173
+ proc.terminate()
174
+ proc.wait()
175
+ err = proc.stderr.read()
176
+ if err:
177
+ logger.warning("parec stderr: %s", err.decode("utf-8", errors="replace").strip())
178
+
179
+
180
+ def detect_backend(preferred: str = "auto") -> tuple[str, AudioBackend | None]:
181
+ """音声バックエンドを検出"""
182
+ if preferred == "pipewire":
183
+ if PipeWireBackend.is_available():
184
+ return "pipewire", PipeWireBackend()
185
+ logger.warning("PipeWire が利用できません、sounddevice にフォールバック")
186
+ return "sounddevice", None
187
+
188
+ if preferred == "pulseaudio":
189
+ if PulseAudioBackend.is_available():
190
+ return "pulseaudio", PulseAudioBackend()
191
+ logger.warning("PulseAudio が利用できません、sounddevice にフォールバック")
192
+ return "sounddevice", None
193
+
194
+ if preferred == "wasapi":
195
+ if WasapiBackend.is_available():
196
+ return "wasapi", WasapiBackend()
197
+ logger.warning("PyAudioWPatch が利用できません、sounddevice にフォールバック")
198
+ return "sounddevice", None
199
+
200
+ if preferred == "sounddevice":
201
+ return "sounddevice", None
202
+
203
+ # auto: Windows → WasapiBackend / Linux → PipeWire → PulseAudio → sounddevice
204
+ if sys.platform == "win32":
205
+ if WasapiBackend.is_available():
206
+ return "wasapi", WasapiBackend()
207
+ return "sounddevice", None
208
+ if PipeWireBackend.is_available():
209
+ return "pipewire", PipeWireBackend()
210
+ if PulseAudioBackend.is_available():
211
+ return "pulseaudio", PulseAudioBackend()
212
+ return "sounddevice", None
213
+
214
+
215
+ def _get_default_sink_name() -> str | None:
216
+ """wpctl/pactl でデフォルト Sink の名前を取得"""
217
+ # wpctl (PipeWire)
218
+ if shutil.which("wpctl"):
219
+ try:
220
+ result = subprocess.run(
221
+ ["wpctl", "inspect", "@DEFAULT_AUDIO_SINK@"],
222
+ capture_output=True, text=True, timeout=5,
223
+ )
224
+ for line in result.stdout.splitlines():
225
+ line = line.strip().lstrip("* ")
226
+ if line.startswith("node.name"):
227
+ # node.name = "alsa_output.usb-Shokz..."
228
+ parts = line.split("=", 1)
229
+ if len(parts) == 2:
230
+ name = parts[1].strip().strip('"')
231
+ logger.debug("デフォルト Sink (wpctl): %s", name)
232
+ return name
233
+ except (subprocess.TimeoutExpired, FileNotFoundError):
234
+ pass
235
+
236
+ # pactl (PulseAudio)
237
+ if shutil.which("pactl"):
238
+ try:
239
+ result = subprocess.run(
240
+ ["pactl", "get-default-sink"],
241
+ capture_output=True, text=True, timeout=5,
242
+ )
243
+ name = result.stdout.strip()
244
+ if name:
245
+ logger.debug("デフォルト Sink (pactl): %s", name)
246
+ return name
247
+ except (subprocess.TimeoutExpired, FileNotFoundError):
248
+ pass
249
+
250
+ return None
251
+
252
+
253
+ def _is_rdp_audio(name: str) -> bool:
254
+ """RDP の virtual audio device か判定。loopback 候補から除外する。"""
255
+ if not name:
256
+ return False
257
+ n = name.lower()
258
+ return (
259
+ "リモート オーディオ" in name # ja (full-width space)
260
+ or "リモート デスクトップ" in name # ja (RDP redirected device)
261
+ or "remote audio" in n # en
262
+ or "remote desktop" in n # en
263
+ )
264
+
265
+
266
+ class WasapiBackend(AudioBackend):
267
+ """Windows WASAPI ループバックバックエンド (PyAudioWPatch)"""
268
+
269
+ @staticmethod
270
+ def is_available() -> bool:
271
+ if sys.platform != "win32":
272
+ return False
273
+ try:
274
+ import pyaudiowpatch as pyaudio
275
+ except ImportError as e:
276
+ logger.warning("pyaudiowpatch のインポート失敗: %s", e)
277
+ return False
278
+ if not hasattr(pyaudio, "paWASAPI"):
279
+ logger.warning("pyaudiowpatch に paWASAPI が無い")
280
+ return False
281
+ if not hasattr(pyaudio.PyAudio, "get_loopback_device_info_generator"):
282
+ logger.warning("pyaudiowpatch に get_loopback_device_info_generator が無い")
283
+ return False
284
+ return True
285
+
286
+ @staticmethod
287
+ def _find_loopback_info(p: Any, prefer_name: str = "") -> dict | None:
288
+ """RDP 以外の WASAPI loopback デバイス情報を返す。
289
+
290
+ prefer_name が指定されていれば部分一致で優先する。
291
+ 指定がなければ既定の出力デバイスに対応する loopback を優先する。
292
+ """
293
+ import pyaudiowpatch as pyaudio
294
+ try:
295
+ host_info = p.get_host_api_info_by_type(pyaudio.paWASAPI)
296
+ except OSError:
297
+ return None
298
+ default_idx = host_info.get("defaultOutputDevice")
299
+ default_name = ""
300
+ if default_idx is not None and default_idx >= 0:
301
+ try:
302
+ default_name = p.get_device_info_by_index(default_idx)["name"]
303
+ except OSError:
304
+ pass
305
+ if _is_rdp_audio(default_name):
306
+ logger.info("既定の WASAPI 出力 (%s) は RDP デバイス、別を探す",
307
+ default_name)
308
+ default_name = ""
309
+
310
+ target = None
311
+ fallback = None
312
+ for info in p.get_loopback_device_info_generator():
313
+ name = info["name"]
314
+ if _is_rdp_audio(name):
315
+ logger.debug("RDP デバイススキップ: %s", name)
316
+ continue
317
+ if prefer_name and prefer_name in name:
318
+ return info
319
+ if default_name and (default_name in name or name in default_name):
320
+ if target is None:
321
+ target = info
322
+ if fallback is None:
323
+ fallback = info
324
+ return target or fallback
325
+
326
+ def detect_monitor_source(self) -> str | None:
327
+ try:
328
+ import pyaudiowpatch as pyaudio
329
+ p = pyaudio.PyAudio()
330
+ try:
331
+ info = self._find_loopback_info(p)
332
+ return info["name"] if info else None
333
+ finally:
334
+ p.terminate()
335
+ except Exception as e:
336
+ logger.warning("PyAudio loopback デバイス取得失敗: %s", e)
337
+ return None
338
+
339
+ def list_devices(self) -> None:
340
+ try:
341
+ import pyaudiowpatch as pyaudio
342
+ p = pyaudio.PyAudio()
343
+ try:
344
+ print(t("rec.wasapi_loopback_mics"))
345
+ for info in p.get_loopback_device_info_generator():
346
+ name = info["name"]
347
+ marker = " (RDP — skipped)" if _is_rdp_audio(name) else ""
348
+ print(f" {name}{marker}")
349
+ finally:
350
+ p.terminate()
351
+ except ImportError:
352
+ print(t("rec.wasapi_soundcard_unavailable"))
353
+
354
+ def start_monitor_capture(self, source: str, audio_queue: queue.Queue,
355
+ stop_event: threading.Event) -> None:
356
+ """PyAudioWPatch の WASAPI loopback でキャプチャ (polling)。
357
+
358
+ デバイスの native rate / channels で開き、Python 側で 16kHz mono に
359
+ 間引き + ミックスダウンしてから既存パイプラインに流す。
360
+ """
361
+ import pyaudiowpatch as pyaudio
362
+ import numpy as np
363
+ p = pyaudio.PyAudio()
364
+ stream = None
365
+ try:
366
+ info = self._find_loopback_info(p, prefer_name=source or "")
367
+ if info is None:
368
+ logger.error("WASAPI loopback デバイスが見つかりません(RDP 除外後)")
369
+ return
370
+ if _is_rdp_audio(info["name"]):
371
+ logger.error("RDP デバイス (%s) ではキャプチャしない", info["name"])
372
+ return
373
+ native_rate = int(info["defaultSampleRate"])
374
+ channels = int(info["maxInputChannels"]) or 1
375
+ decimate = max(1, native_rate // SAMPLE_RATE)
376
+ native_block = FRAME_SIZE * decimate
377
+ logger.info("WASAPI loopback キャプチャ開始: %s "
378
+ "(index=%d, native=%dHz/%dch → %dHz mono)",
379
+ info["name"], info["index"], native_rate, channels, SAMPLE_RATE)
380
+ stream = p.open(
381
+ format=pyaudio.paInt16,
382
+ channels=channels,
383
+ rate=native_rate,
384
+ input=True,
385
+ input_device_index=info["index"],
386
+ frames_per_buffer=native_block,
387
+ )
388
+ while not stop_event.is_set():
389
+ raw = stream.read(native_block, exception_on_overflow=False)
390
+ arr = np.frombuffer(raw, dtype=np.int16)
391
+ if channels > 1:
392
+ # ステレオ以上 → モノラルにミックスダウン
393
+ arr = arr.reshape(-1, channels).mean(axis=1).astype(np.int16)
394
+ # native_rate → SAMPLE_RATE に間引き(整数比のみ、エイリアスは
395
+ # 音声認識帯域には影響しない)
396
+ if decimate > 1:
397
+ arr = arr[::decimate]
398
+ audio_queue.put(arr.copy())
399
+ except Exception as e:
400
+ logger.error("WASAPI loopback キャプチャエラー: %s", e)
401
+ finally:
402
+ if stream is not None:
403
+ try:
404
+ stream.stop_stream()
405
+ stream.close()
406
+ except Exception:
407
+ pass
408
+ p.terminate()
409
+
410
+
411
+ def find_monitor_device_sd() -> tuple[int, dict[str, Any]] | None:
412
+ """sounddevice でモニターデバイスを検索 (Linux のみ)
413
+
414
+ 戻り値: (デバイスID, sd.InputStream に追加で渡す kwargs) または None。
415
+ Windows は WasapiBackend を使うため None を返す。
416
+ """
417
+ if sys.platform == "win32":
418
+ return None
419
+ return _find_monitor_device_linux()
420
+
421
+
422
+ def _find_monitor_device_linux() -> tuple[int, dict[str, Any]] | None:
423
+ """Linux (PipeWire/PulseAudio) でモニターデバイスを検索
424
+
425
+ PipeWire: `.monitor` サフィックスを持つ入力デバイス
426
+ PulseAudio: "Monitor of " プレフィックスを持つ入力デバイス
427
+ デフォルト Sink に対応するモニターを優先する。
428
+ """
429
+ import sounddevice as sd
430
+ devices = sd.query_devices()
431
+ candidates = []
432
+ for i, dev in enumerate(devices):
433
+ name = dev["name"]
434
+ is_monitor = (
435
+ name.endswith(".monitor")
436
+ or name.lower().startswith("monitor of ")
437
+ )
438
+ if is_monitor and dev["max_input_channels"] > 0:
439
+ candidates.append((i, name))
440
+ logger.debug("monitor 候補: #%d %s", i, name)
441
+
442
+ if not candidates:
443
+ logger.debug("monitor 候補なし")
444
+ return None
445
+
446
+ # デフォルト Sink に対応するモニターを優先
447
+ default_sink = _get_default_sink_name()
448
+ if default_sink:
449
+ expected_monitor = default_sink + ".monitor"
450
+ for idx, name in candidates:
451
+ if name == expected_monitor:
452
+ logger.debug("デフォルト Sink のモニター選択: #%d %s", idx, name)
453
+ return idx, {}
454
+
455
+ # 見つからなければ最初の候補
456
+ logger.debug("デフォルト Sink 不明、最初の候補を選択: #%d %s", *candidates[0])
457
+ return candidates[0][0], {}
458
+
459
+
460
+ def list_all_devices(backend_name: str, backend: AudioBackend | None) -> None:
461
+ """全デバイス一覧表示"""
462
+ import sounddevice as sd
463
+ print(t("rec.sounddevice_devices"))
464
+ print(sd.query_devices())
465
+
466
+ if backend:
467
+ backend.list_devices()
468
+
469
+ monitor_sd = find_monitor_device_sd()
470
+ if monitor_sd is not None:
471
+ device_idx, _ = monitor_sd
472
+ print(t("rec.auto_detect_sd", device=device_idx))
473
+
474
+ if backend:
475
+ monitor = backend.detect_monitor_source()
476
+ if monitor:
477
+ print(t("rec.auto_detect_backend", backend=backend_name, source=monitor))
@@ -0,0 +1,58 @@
1
+ """Shadow-clerk daemon: 設定管理"""
2
+
3
+ import logging
4
+ import os
5
+ import yaml
6
+ from shadow_clerk import CONFIG_FILE
7
+ from shadow_clerk.i18n import t
8
+ from shadow_clerk._daemon_constants import DEFAULT_CONFIG
9
+
10
+ logger = logging.getLogger("shadow-clerk")
11
+
12
+ _config_cache: dict | None = None
13
+ _config_mtime: float = 0.0
14
+
15
+
16
+ def load_config() -> dict:
17
+ """config.yaml を読み込む。ファイルがなければデフォルト値を返す。
18
+
19
+ mtime ベースのキャッシュにより、ファイルが変更されていなければ再パースしない。
20
+ """
21
+ global _config_cache, _config_mtime
22
+ try:
23
+ st = os.stat(CONFIG_FILE)
24
+ except OSError:
25
+ return dict(DEFAULT_CONFIG)
26
+ if _config_cache is not None and st.st_mtime == _config_mtime:
27
+ return dict(_config_cache)
28
+ try:
29
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
30
+ user_config = yaml.safe_load(f)
31
+ if isinstance(user_config, dict):
32
+ merged = dict(DEFAULT_CONFIG)
33
+ merged.update(user_config)
34
+ _config_cache = merged
35
+ _config_mtime = st.st_mtime
36
+ return dict(merged)
37
+ except Exception as e:
38
+ logger.warning("config.yaml の読み込みに失敗: %s", e)
39
+ return dict(DEFAULT_CONFIG)
40
+
41
+
42
+ def get_translation_provider(config: dict) -> str:
43
+ """翻訳プロバイダーを返す。translation_provider が未設定なら llm_provider にフォールバック。"""
44
+ provider = config.get("translation_provider")
45
+ if provider:
46
+ return provider
47
+ return config.get("llm_provider", "claude")
48
+
49
+ def _builtin_command_descs():
50
+ return [
51
+ {"command": "start_meeting", "description": t("vcmd.start_meeting")},
52
+ {"command": "end_meeting", "description": t("vcmd.end_meeting")},
53
+ {"command": "translate_start", "description": t("vcmd.translate_start")},
54
+ {"command": "translate_stop", "description": t("vcmd.translate_stop")},
55
+ {"command": "set_language ja", "description": t("vcmd.set_language_ja")},
56
+ {"command": "set_language en", "description": t("vcmd.set_language_en")},
57
+ {"command": "unset_language", "description": t("vcmd.unset_language")},
58
+ ]