Files
operating-room-monitor-server/app/services/surgery_pipeline.py
Kevin 6b3adb4ad8 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
2026-04-27 11:21:16 +08:00

150 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""手术录制与实时算法流水线(待接入真实子系统)。"""
from __future__ import annotations
import base64
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.database import AsyncSessionLocal
from app.domain.consumption import SurgeryConsumptionStored
from app.repositories.surgery_results import SurgeryResultRepository
from app.schemas import (
PendingConfirmationOption,
SurgeryConsumptionDetail,
SurgeryPendingConfirmationResponse,
)
from app.services.video.session_manager import CameraSessionManager
from fastapi.concurrency import run_in_threadpool
from app.services.voice_resolution import VoiceConfirmationService, VoiceResolveResult
from app.surgery_errors import SurgeryPipelineError
def _stored_to_response(rows: list[SurgeryConsumptionStored]) -> list[SurgeryConsumptionDetail]:
"""领域对象 → HTTP DTO 的单向转换,仅在返回给客户端的边界调用。"""
return [
SurgeryConsumptionDetail(
item_id=r.item_id,
item_name=r.item_name,
qty=r.qty,
doctor_id=r.doctor_id,
timestamp=r.timestamp,
)
for r in rows
]
class SurgeryPipeline:
"""协调开录、停录与算法产出。路由仅在子系统确认后返回 HTTP 200。"""
def __init__(
self,
sessions: CameraSessionManager,
*,
result_repository: SurgeryResultRepository,
voice_confirmation: VoiceConfirmationService,
session_factory: async_sessionmaker | None = None,
) -> None:
self._sessions = sessions
self._repo = result_repository
self._voice = voice_confirmation
self._session_factory: async_sessionmaker = session_factory or AsyncSessionLocal
async def start_recording(
self,
surgery_id: str,
camera_ids: list[str],
candidate_consumables: list[str],
) -> None:
"""启动关联摄像头录制。仅在确认已开录时返回;否则抛出 SurgeryPipelineError。"""
try:
await self._sessions.start_surgery(
surgery_id,
camera_ids,
candidate_consumables,
)
except SurgeryPipelineError:
raise
except ValueError as exc:
raise SurgeryPipelineError(
"RECORDING_CANNOT_START",
f"开录未能确认:{exc}",
) from exc
except RuntimeError as exc:
raise SurgeryPipelineError(
"RECORDING_CANNOT_START",
f"开录未能确认:{exc}",
) from exc
async def stop_recording(self, surgery_id: str) -> str | None:
"""停止该手术关联的摄像头录制。仅在确认已全部停录时返回。返回绑定的语音终端 ID若有"""
try:
return await self._sessions.stop_surgery(surgery_id, require_active=True)
except SurgeryPipelineError:
raise
def set_voice_terminal_id(self, surgery_id: str, terminal_id: str | None) -> None:
self._sessions.set_voice_terminal_id(surgery_id, terminal_id)
async def get_consumption_details_for_client(
self,
surgery_id: str,
) -> list[SurgeryConsumptionDetail] | None:
"""进行中:返回内存明细;已结束:返回数据库最终结果;持久化失败时回退内存归档。"""
live = self._sessions.live_consumption_if_active(surgery_id)
if live is not None:
return _stored_to_response(live)
async with self._session_factory() as session:
async with session.begin():
persisted = await self._repo.load_final_details(session, surgery_id)
if persisted is not None:
return _stored_to_response(persisted)
archived = self._sessions.archived_consumption_fallback(surgery_id)
if archived is not None:
return _stored_to_response(archived)
return None
async def get_pending_confirmation_for_client(
self, surgery_id: str
) -> SurgeryPendingConfirmationResponse | None:
pending = self._sessions.next_pending_confirmation(surgery_id)
if pending is None:
return None
mp3 = await run_in_threadpool(
self._voice.synthesize_prompt_to_mp3,
pending.prompt_text,
)
b64 = base64.b64encode(mp3).decode("ascii")
return SurgeryPendingConfirmationResponse(
surgery_id=surgery_id,
confirmation_id=pending.id,
prompt_text=pending.prompt_text,
prompt_audio_mp3_base64=b64,
options=[
PendingConfirmationOption(label=a, confidence=b)
for a, b in pending.options
],
model_top1_label=pending.model_top1_label,
model_top1_confidence=pending.model_top1_confidence,
created_at=pending.created_at,
)
async def resolve_pending_confirmation_from_audio(
self,
surgery_id: str,
confirmation_id: str,
wav_bytes: bytes,
filename: str,
content_type: str | None,
) -> VoiceResolveResult:
"""上传医生语音 WAVMinIO 追溯 + 百度 ASR + 解析候选项并完成确认。"""
return await self._voice.resolve_from_wav(
surgery_id=surgery_id,
confirmation_id=confirmation_id,
wav_bytes=wav_bytes,
filename=filename,
content_type=content_type,
)