feat: 站点 JSON、语音终端 WebSocket 指派与客户端联调

- 用 OR_SITE_CONFIG_JSON_FILE 统一术间配置(video_rtsp_urls + voice_or_room_bindings)
- VoiceTerminalHub:assignment、WS 推送与 HTTP 查询;开录/停录后 notify
- 一键联调 orchestrate-and-start 与 /client/surgeries/start 共用指派逻辑,修复 demo 路径不发 WS
- 语音桌面端:SIGINT 退出、shutdown 清理、仅 WS 指派、固定 pending 轮询间隔、界面仅保留录音时长
- 新增/调整契约与绑定测试,文档与示例配置同步

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-27 11:21:16 +08:00
parent 4c3f9a367b
commit 6b3adb4ad8
36 changed files with 1194 additions and 162 deletions

View File

@@ -3,11 +3,10 @@
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
from PySide6.QtCore import Qt, Signal, QObject
from PySide6.QtGui import QCloseEvent
from PySide6.QtGui import QCloseEvent, QShowEvent
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
@@ -18,7 +17,6 @@ from PySide6.QtWidgets import (
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPushButton,
QPlainTextEdit,
QSplitter,
@@ -26,13 +24,25 @@ from PySide6.QtWidgets import (
QWidget,
)
from loguru import logger
from voice_confirmation_client.core.assignment_listener import (
VoiceAssignmentListener,
default_terminal_id_from_env,
)
from voice_confirmation_client.core.monitor_worker import MonitorWorker
from voice_confirmation_client.logging_config import setup_voice_client_logging
# 待确认接口仍为轮询;界面不再暴露,固定默认间隔。
_DEFAULT_PENDING_POLL_INTERVAL_SEC = 5.0
class _Bridge(QObject):
log_line = Signal(str)
state_text = Signal(str)
pending_payload = Signal(object)
voice_assign_start = Signal(str)
voice_assign_end = Signal(str)
class MainWindow(QMainWindow):
@@ -40,13 +50,25 @@ class MainWindow(QMainWindow):
super().__init__()
self.setWindowTitle("手术室耗材语音确认客户端")
self.resize(920, 640)
self._assignment_listener: VoiceAssignmentListener | None = None
self._assigned_surgery_id: str = ""
self._bridge = _Bridge()
self._bridge.log_line.connect(self._append_log)
self._bridge.log_line.connect(self._append_log_plain)
self._bridge.pending_payload.connect(self._show_pending)
self._bridge.voice_assign_start.connect(self._on_server_assign_start)
self._bridge.voice_assign_end.connect(self._on_server_assign_end)
setup_voice_client_logging(
gui_line_sink=lambda m: self._bridge.log_line.emit(m.rstrip("\n")),
)
logger.info(
"语音确认客户端已启动;请填写「本机语音终端 ID」与 OR_SITE_CONFIG 一致,"
"开录后由服务端自动填入手术号并监控"
)
self._worker = MonitorWorker(
on_log=lambda m: self._bridge.log_line.emit(m),
on_log=None,
on_state=lambda s: self._bridge.state_text.emit(s),
on_pending=lambda p: self._bridge.pending_payload.emit(p),
)
@@ -56,23 +78,30 @@ class MainWindow(QMainWindow):
self.setCentralWidget(central)
root = QVBoxLayout(central)
form_box = QGroupBox("连接与手术")
form_box = QGroupBox("连接与当前手术")
form = QFormLayout(form_box)
self._base_url = QLineEdit("http://127.0.0.1:38080")
self._surgery_id = QLineEdit("")
self._surgery_id.setPlaceholderText("6 位数字,如 123456")
self._interval = QDoubleSpinBox()
self._interval.setRange(1.0, 120.0)
self._interval.setValue(5.0)
self._interval.setSuffix(" s")
self._surgery_id_display = QLabel("—(等待服务端开录指派)")
self._surgery_id_display.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
self._record_sec = QDoubleSpinBox()
self._record_sec.setRange(2.0, 60.0)
self._record_sec.setValue(8.0)
self._record_sec.setSuffix(" s")
form.addRow("服务端 Base URL", self._base_url)
form.addRow("手术号 surgery_id", self._surgery_id)
form.addRow("轮询间隔", self._interval)
self._terminal_id = QLineEdit(default_terminal_id_from_env())
self._terminal_id.setPlaceholderText("与 OR_SITE_CONFIG 中 voice_terminal_id 一致")
self._auto_assign = QCheckBox("启用服务端自动指派(开录后自动监控该手术)")
self._auto_assign.setChecked(True)
form.addRow("本机语音终端 ID", self._terminal_id)
form.addRow(self._auto_assign)
self._terminal_id.editingFinished.connect(self._sync_assignment_listener)
self._base_url.editingFinished.connect(self._sync_assignment_listener)
self._auto_assign.toggled.connect(self._on_auto_assign_toggled)
form.addRow("当前手术号(仅展示,由服务端指派)", self._surgery_id_display)
form.addRow("录音时长", self._record_sec)
self._record_sec.valueChanged.connect(lambda _: self._apply_settings_silent())
root.addWidget(form_box)
adv = QGroupBox("音频 / 调试")
@@ -91,12 +120,10 @@ class MainWindow(QMainWindow):
root.addWidget(adv)
btn_row = QHBoxLayout()
self._btn_start = QPushButton("开始监控")
self._btn_stop = QPushButton("停止监控")
self._btn_stop = QPushButton("停止监控(本机)")
self._btn_stop.setEnabled(False)
self._btn_retry = QPushButton("重试本轮(播放+录音+上传)")
self._btn_replay = QPushButton("仅重播话术")
btn_row.addWidget(self._btn_start)
btn_row.addWidget(self._btn_stop)
btn_row.addWidget(self._btn_retry)
btn_row.addWidget(self._btn_replay)
@@ -119,13 +146,76 @@ class MainWindow(QMainWindow):
split.setSizes([360, 520])
root.addWidget(split, stretch=1)
self._btn_start.clicked.connect(self._start_monitoring)
self._btn_stop.clicked.connect(self._stop_monitoring)
self._btn_retry.clicked.connect(self._worker.retry_failed)
self._btn_replay.clicked.connect(self._worker.replay_prompt_only)
self._apply_settings_silent()
def showEvent(self, event: QShowEvent) -> None:
super().showEvent(event)
self._sync_assignment_listener()
def _on_auto_assign_toggled(self, _checked: bool) -> None:
if not self._auto_assign.isChecked():
self._worker.set_monitoring(False)
self._assigned_surgery_id = ""
self._surgery_id_display.setText("—(未启用自动指派)")
self._btn_stop.setEnabled(False)
self._status_label.setText("已关闭自动指派")
self._apply_settings_silent()
self._sync_assignment_listener()
def _sync_assignment_listener(self) -> None:
if self._assignment_listener:
self._assignment_listener.stop()
self._assignment_listener = None
if not self._auto_assign.isChecked():
logger.info("未勾选「启用服务端自动指派」,不连接 WebSocket")
self._worker.set_monitoring(False)
self._assigned_surgery_id = ""
self._surgery_id_display.setText("—(未启用自动指派)")
self._btn_stop.setEnabled(False)
self._apply_settings_silent()
return
tid = self._terminal_id.text().strip()
base = self._base_url.text().strip()
if not tid:
logger.warning(
"「本机语音终端 ID」为空无法接收开录指派请在环境变量 VOICE_TERMINAL_ID 或界面中填写(须与 OR_SITE_CONFIG 中 voice_terminal_id 一致)"
)
return
if not base:
logger.warning("服务端 Base URL 为空,无法连接指派接口")
return
self._assignment_listener = VoiceAssignmentListener(
http_base_url=base,
terminal_id=tid,
on_start=lambda s: self._bridge.voice_assign_start.emit(s),
on_end=lambda s: self._bridge.voice_assign_end.emit(s),
)
self._assignment_listener.start()
def _on_server_assign_start(self, sid: str) -> None:
if len(sid) != 6 or not sid.isdigit():
logger.warning("服务端指派无效手术号: {!r}(须为 6 位数字)", sid)
return
self._assigned_surgery_id = sid
self._surgery_id_display.setText(sid)
self._apply_settings_silent()
self._worker.set_monitoring(True)
self._btn_stop.setEnabled(True)
logger.info("服务端已指派手术 {}已自动开始监控WebSocket 指派)", sid)
def _on_server_assign_end(self, sid: str) -> None:
self._worker.set_monitoring(False)
self._assigned_surgery_id = ""
self._surgery_id_display.setText("—(等待服务端开录指派)")
self._btn_stop.setEnabled(False)
self._apply_settings_silent()
self._status_label.setText("已停止(服务端结束)")
logger.info("服务端已结束手术 {},已自动停止监控", sid)
def _show_pending(self, payload: object) -> None:
if payload is None:
self._pending_view.clear()
@@ -160,8 +250,8 @@ class MainWindow(QMainWindow):
dev_data = self._device_combo.currentData()
self._worker.set_settings(
base_url=self._base_url.text().strip(),
surgery_id=self._surgery_id.text().strip(),
interval_sec=float(self._interval.value()),
surgery_id=self._assigned_surgery_id,
interval_sec=_DEFAULT_PENDING_POLL_INTERVAL_SEC,
record_seconds=float(self._record_sec.value()),
dry_run=self._dry_run.isChecked(),
hide_404_logs=self._hide_404.isChecked(),
@@ -169,30 +259,28 @@ class MainWindow(QMainWindow):
sounddevice_device=dev_data,
)
def _start_monitoring(self) -> None:
sid = self._surgery_id.text().strip()
if len(sid) != 6 or not sid.isdigit():
QMessageBox.warning(self, "校验失败", "手术号必须为 6 位数字。")
return
self._apply_settings_silent()
self._worker.set_monitoring(True)
self._btn_start.setEnabled(False)
self._btn_stop.setEnabled(True)
self._append_log("—— 开始监控 ——")
def _stop_monitoring(self) -> None:
self._worker.set_monitoring(False)
self._btn_start.setEnabled(True)
self._assigned_surgery_id = ""
self._surgery_id_display.setText("—(等待服务端开录指派)")
self._btn_stop.setEnabled(False)
self._append_log("—— 已停止监控 ——")
self._status_label.setText("已停止")
self._apply_settings_silent()
logger.info("—— 本地已停止监控;服务端结束手术或再次开录后将自动恢复指派 ——")
self._status_label.setText("已停止(本地)")
def _append_log(self, line: str) -> None:
ts = datetime.now().strftime("%H:%M:%S")
self._log.appendPlainText(f"[{ts}] {line}")
def _append_log_plain(self, line: str) -> None:
"""由 loguru GUI sink 写入,已含时间与级别,不再加前缀。"""
self._log.appendPlainText(line)
sb = self._log.verticalScrollBar()
sb.setValue(sb.maximum())
def closeEvent(self, event: QCloseEvent) -> None:
def shutdown(self) -> None:
"""停止后台线程;窗口关闭与 Ctrl+CaboutToQuit共用。"""
if self._assignment_listener:
self._assignment_listener.stop()
self._assignment_listener = None
self._worker.stop_thread()
def closeEvent(self, event: QCloseEvent) -> None:
self.shutdown()
event.accept()