This commit is contained in:
Your Name
2026-08-12 17:18:56 +08:00
parent 28cd110dae
commit f48a66b611
24 changed files with 3095 additions and 233 deletions
@@ -5,7 +5,7 @@ from __future__ import annotations
import hashlib
import struct
import zlib
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from copy import deepcopy
from datetime import date, datetime, timedelta
from os import PathLike
@@ -163,8 +163,27 @@ class DemoDoctorRepository:
"room_id": "demo-room-501",
"status": 2,
"status_text": "已结束",
"recording_status_text": "录制完成",
"recording_urls_list": ["https://media.example.invalid/demo/diagnosis-501.mp4"],
"recording_status_text": "录制完成",
"recording_urls_list": ["https://media.example.invalid/demo/diagnosis-501.mp4"],
"transcription_status": "completed",
"transcription_status_text": "文字已生成",
"transcript_text": "患者:最近睡眠比上周好一些。\n医生:继续记录睡眠和空腹血糖。",
"transcript_segments": [
{
"segment_id": "demo-1",
"speaker_role": "patient",
"speaker_user_id": "patient_301",
"timestamp": 1,
"text": "最近睡眠比上周好一些。",
},
{
"segment_id": "demo-2",
"speaker_role": "doctor",
"speaker_user_id": "doctor_1001",
"timestamp": 2,
"text": "继续记录睡眠和空腹血糖。",
},
],
"start_time_text": f"{self._today.isoformat()} 09:10:00",
"end_time_text": f"{self._today.isoformat()} 09:22:00",
"duration_text": "12分00秒",
@@ -2233,8 +2252,12 @@ class DemoDoctorRepository:
"room_id": "manual-upload",
"status": 2,
"status_text": "已结束",
"recording_status_text": "待上传",
"recording_urls_list": [],
"recording_status_text": "待上传",
"recording_urls_list": [],
"transcription_status": "not_started",
"transcription_status_text": "未生成文字",
"transcript_text": "",
"transcript_segments": [],
"start_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "),
"end_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "),
"duration_text": "0秒",
@@ -2531,8 +2554,12 @@ class DemoDoctorRepository:
replay = {
**record,
"status_text": "呼叫中",
"recording_status_text": "未录制",
"recording_urls_list": [],
"recording_status_text": "未录制",
"recording_urls_list": [],
"transcription_status": "not_started",
"transcription_status_text": "未生成文字",
"transcript_text": "",
"transcript_segments": [],
"start_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "),
"end_time_text": "",
"duration_text": "",
@@ -2567,7 +2594,7 @@ class DemoDoctorRepository:
)
return deepcopy(record)
def bind_call_room(self, diagnosis_id: int, room_id: str) -> dict[str, Any]:
def bind_call_room(self, diagnosis_id: int, room_id: str) -> dict[str, Any]:
"""Persist the room identifier on the active demo call."""
if not room_id.strip():
@@ -2594,11 +2621,133 @@ class DemoDoctorRepository:
"status_text": "通话中",
}
)
return {
"diagnosis_id": diagnosis_id,
"room_id": room_id.strip(),
"cloud_recording": {"started": False, "message": "演示模式不录制"},
}
return {
"diagnosis_id": diagnosis_id,
"room_id": room_id.strip(),
"cloud_recording": {"started": False, "message": "演示模式不录制"},
}
def _demo_call_record(
self, diagnosis_id: int, call_record_id: int | str
) -> dict[str, Any]:
record = next(
(
row
for row in self._call_records.get(diagnosis_id, [])
if str(row.get("id") or row.get("call_record_id") or "")
== str(call_record_id)
),
None,
)
if record is None:
raise RepositoryNotFoundError(f"call record {call_record_id} not found")
return record
def start_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
*,
language: str = "zh-CN",
) -> dict[str, Any]:
"""Start an in-memory transcript attached to one demo call record."""
clean_session = transcription_session_id.strip()
if not clean_session:
raise ValueError("transcription_session_id is required")
with self._lock:
self._find_consultation(diagnosis_id)
record = self._demo_call_record(diagnosis_id, call_record_id)
existing = str(record.get("transcription_session_id") or "")
if existing and existing != clean_session:
raise ValueError("another transcription session already exists")
record.update(
{
"transcription_session_id": clean_session,
"transcription_language": language.strip() or "zh-CN",
"transcription_status": "running",
"transcription_status_text": "录音转写中",
"transcript_text": "",
"transcript_segments": [],
}
)
return deepcopy(record)
def upsert_call_transcript_segments(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
segments: Sequence[Mapping[str, Any]],
) -> dict[str, Any]:
"""Idempotently append/update completed segments in demo mode."""
with self._lock:
record = self._demo_call_record(diagnosis_id, call_record_id)
if record.get("transcription_session_id") != transcription_session_id:
raise ValueError("transcription session does not match the call record")
stored = record.setdefault("transcript_segments", [])
for incoming in segments:
segment_id = str(incoming.get("segment_id") or "").strip()
text = str(incoming.get("text") or "").strip()
if not segment_id or not text:
raise ValueError("transcript segment_id and text are required")
normalized = {
"segment_id": segment_id,
"speaker_role": str(incoming.get("speaker_role") or "unknown"),
"speaker_user_id": str(incoming.get("speaker_user_id") or ""),
"timestamp": int(incoming.get("timestamp") or 0),
"text": text,
}
current = next(
(row for row in stored if row.get("segment_id") == segment_id), None
)
if current is None:
stored.append(normalized)
else:
current.update(normalized)
stored.sort(key=lambda row: (int(row.get("timestamp") or 0), row["segment_id"]))
labels = {"doctor": "医生", "patient": "患者", "unknown": "未知说话人"}
record["transcript_text"] = "\n".join(
f"{labels.get(str(row.get('speaker_role')), '未知说话人')}{row['text']}"
for row in stored
)
return deepcopy(record)
def finish_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
expected_segment_count: int,
*,
status: str = "completed",
) -> dict[str, Any]:
"""Finalize demo transcript fields on the same call record."""
clean_status = status.strip().lower()
if clean_status not in {"completed", "partial", "failed"}:
raise ValueError("transcription status is invalid")
with self._lock:
record = self._demo_call_record(diagnosis_id, call_record_id)
if record.get("transcription_session_id") != transcription_session_id:
raise ValueError("transcription session does not match the call record")
actual_count = len(record.get("transcript_segments") or [])
final_status = clean_status
if clean_status == "completed" and actual_count < expected_segment_count:
final_status = "partial"
record["transcription_status"] = final_status
record["transcription_status_text"] = {
"completed": "文字已生成",
"partial": "文字部分保存",
"failed": "文字生成失败",
}[final_status]
record["transcription_segment_count"] = actual_count
record["transcription_finished_at"] = datetime.now().replace(
microsecond=0
).isoformat(sep=" ")
return deepcopy(record)
def my_self(self) -> Session:
"""Compatibility alias for :meth:`get_session`."""
+175 -20
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import mimetypes
import re
import time
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from contextlib import suppress
from datetime import date
from io import BytesIO
@@ -619,8 +619,38 @@ class DoctorRepository(Protocol):
def end_call(self, diagnosis_id: int) -> Any:
"""End the active diagnosis call."""
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind a TRTC room to the active call."""
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind a TRTC room to the active call."""
def start_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
*,
language: str = "zh-CN",
) -> Any:
"""Start a transcript stored on one exact call record."""
def upsert_call_transcript_segments(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
segments: Sequence[Mapping[str, Any]],
) -> Any:
"""Idempotently persist completed transcript segments."""
def finish_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
expected_segment_count: int,
*,
status: str = "completed",
) -> Any:
"""Finalize and materialize the transcript text on a call record."""
def my_self(self) -> Session:
"""Compatibility alias for :meth:`get_session`."""
@@ -2171,32 +2201,157 @@ class RemoteDoctorRepository:
ticket.diagnosis_id = diagnosis_id
return ticket
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any:
"""Create the server-side call record before ringing participants."""
return self.client.post(
"tcm.diagnosis/startCall",
{
"diagnosis_id": diagnosis_id,
"patient_id": patient_id,
"call_type": call_type,
},
)
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any:
"""Create the server-side call record before ringing participants."""
payload = self.client.post(
"tcm.diagnosis/startCall",
{
"diagnosis_id": diagnosis_id,
"patient_id": patient_id,
"call_type": call_type,
},
)
if not isinstance(payload, Mapping):
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id object", data=payload
)
raw_id = next(
(
payload[key]
for key in ("call_record_id", "callRecordId", "id")
if key in payload
),
None,
)
try:
if isinstance(raw_id, bool):
raise ValueError
call_record_id = int(raw_id)
except (TypeError, ValueError) as exc:
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id",
data=dict(payload),
) from exc
if call_record_id <= 0:
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id",
data=dict(payload),
)
return {"call_record_id": call_record_id}
def end_call(self, diagnosis_id: int) -> Any:
"""End the active call/recording associated with a diagnosis."""
return self.client.post("tcm.diagnosis/endCall", {"diagnosis_id": diagnosis_id})
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind the actual TRTC room to the active call record."""
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind the actual TRTC room to the active call record."""
if not room_id.strip():
raise ValueError("room_id is required")
return self.client.post(
"tcm.diagnosis/bindCallRoom",
{"diagnosis_id": diagnosis_id, "room_id": room_id.strip()},
)
return self.client.post(
"tcm.diagnosis/bindCallRoom",
{"diagnosis_id": diagnosis_id, "room_id": room_id.strip()},
)
@staticmethod
def _transcription_identity(
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
) -> dict[str, Any]:
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
if isinstance(call_record_id, bool) or not str(call_record_id).strip():
raise ValueError("call_record_id is required")
clean_session = transcription_session_id.strip()
if not clean_session or len(clean_session) > 128:
raise ValueError("transcription_session_id must contain 1 to 128 characters")
return {
"diagnosis_id": diagnosis_id,
"call_record_id": call_record_id,
"transcription_session_id": clean_session,
}
def start_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
*,
language: str = "zh-CN",
) -> Any:
"""Create the server transcript session for one call record."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
body["language"] = language.strip()[:32] or "zh-CN"
return self.client.post("tcm.diagnosis/startCallTranscription", body)
def upsert_call_transcript_segments(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
segments: Sequence[Mapping[str, Any]],
) -> Any:
"""Upsert a bounded batch using each segment_id as the idempotency key."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
normalized: list[dict[str, Any]] = []
if len(segments) > 50:
raise ValueError("at most 50 transcript segments may be submitted at once")
for segment in segments:
segment_id = str(segment.get("segment_id") or "").strip()
text = str(segment.get("text") or "").strip()
if not segment_id or len(segment_id) > 160:
raise ValueError("transcript segment_id is invalid")
if not text or len(text) > 4_000:
raise ValueError("transcript text is invalid")
normalized.append(
{
"segment_id": segment_id,
"speaker_user_id": str(segment.get("speaker_user_id") or "")[:160],
"speaker_role": str(segment.get("speaker_role") or "unknown")[:20],
"timestamp": max(int(segment.get("timestamp") or 0), 0),
"text": text,
}
)
if not normalized:
raise ValueError("at least one transcript segment is required")
body["segments"] = normalized
return self.client.post("tcm.diagnosis/upsertCallTranscriptSegments", body)
def finish_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
expected_segment_count: int,
*,
status: str = "completed",
) -> Any:
"""Finalize a transcript; repeated requests use the same session identity."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
clean_status = status.strip().lower()
if clean_status not in {"completed", "partial", "failed"}:
raise ValueError("transcription status is invalid")
if expected_segment_count < 0:
raise ValueError("expected_segment_count must not be negative")
body.update(
{
"expected_segment_count": expected_segment_count,
"status": clean_status,
}
)
return self.client.post("tcm.diagnosis/finishCallTranscription", body)
# Compatibility aliases keep UI naming independent from endpoint history.
def my_self(self) -> Session:
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
QInputDialog,
QLabel,
QMessageBox,
QPlainTextEdit,
QPushButton,
QScrollArea,
QSizePolicy,
@@ -1411,7 +1412,7 @@ class DiagnosisDialog(QDialog):
layout.setContentsMargins(16, 14, 16, 18)
layout.setSpacing(12)
toolbar = QHBoxLayout()
hint = QLabel("每条通话记录可包含多个回放地址,并可追加指定记录的视频")
hint = QLabel("每条通话记录可包含多个回放地址;录音转写完成后可查看本次面诊对话文字")
hint.setObjectName("DiagnosisDialogGuidance")
hint.setWordWrap(True)
toolbar.addWidget(hint, 1)
@@ -1553,7 +1554,7 @@ class DiagnosisDialog(QDialog):
("房间号", 180),
("时长", 110),
("状态", 90),
("录制", 100),
("录制 / 文字", 120),
("操作", 130),
),
"assign": (
@@ -3365,11 +3366,71 @@ class DiagnosisDialog(QDialog):
cell.stop()
self._inline_recording_cells.clear()
@staticmethod
def _call_transcript_text(row: Any) -> str:
raw = first_value(
row,
"transcript_text",
"call_transcript",
"conversation_text",
"transcript",
default="",
)
if isinstance(raw, Mapping):
raw = first_value(raw, "text", "full_text", "content", default="")
text = str(raw or "").strip()
if text:
return text
segments = first_value(row, "transcript_segments", "segments", default=[]) or []
if isinstance(segments, Sequence) and not isinstance(
segments, (str, bytes, bytearray)
):
labels = {"doctor": "医生", "patient": "患者", "unknown": "未知说话人"}
lines: list[str] = []
for segment in segments:
content = str(first_value(segment, "text", "sourceText", default="") or "").strip()
if not content:
continue
role = str(
first_value(segment, "speaker_role", "speakerRole", default="unknown")
or "unknown"
)
lines.append(f"{labels.get(role, '未知说话人')}{content}")
return "\n".join(lines)
return ""
def _view_call_transcript(self, call_record_id: int, transcript: str) -> None:
dialog = QDialog(self)
dialog.setObjectName("DiagnosisCallTranscriptDialog")
dialog.setWindowTitle(f"面诊对话文字 · 通话记录 #{call_record_id}")
dialog.resize(680, 520)
layout = QVBoxLayout(dialog)
layout.setContentsMargins(20, 18, 20, 18)
layout.setSpacing(12)
title = QLabel("录音转写对话")
title.setObjectName("DiagnosisDailySectionTitle")
layout.addWidget(title)
hint = QLabel("以下内容由语音自动转写,仅作为面诊记录辅助,请由医生核对。")
hint.setObjectName("DiagnosisDialogGuidance")
hint.setWordWrap(True)
layout.addWidget(hint)
content = QPlainTextEdit(dialog)
content.setObjectName("DiagnosisCallTranscriptText")
content.setReadOnly(True)
content.setPlainText(transcript)
layout.addWidget(content, 1)
close_button = QPushButton("关闭")
close_button.setProperty("variant", "primary")
close_button.clicked.connect(dialog.accept)
layout.addWidget(close_button, 0, Qt.AlignmentFlag.AlignRight)
dialog.exec()
def _fill_video(self, rows: Sequence[Any]) -> None:
self._stop_inline_recordings()
matrix: list[tuple[Any, ...]] = []
row_urls: list[list[str]] = []
record_ids: list[int] = []
transcripts: list[str] = []
for row in rows:
raw_urls = first_value(row, "recording_urls_list", "recording_urls", default=[]) or []
if isinstance(raw_urls, str) or not isinstance(raw_urls, Sequence):
@@ -3382,6 +3443,8 @@ class DiagnosisDialog(QDialog):
normalized.append(url)
row_urls.append(normalized)
record_ids.append(_int(first_value(row, "id", "call_record_id"), 0))
transcript = self._call_transcript_text(row)
transcripts.append(transcript)
call_type = _raw_value(row, "call_type_text")
if call_type is _MISSING:
raw_call_type = _raw_value(row, "call_type")
@@ -3398,6 +3461,15 @@ class DiagnosisDialog(QDialog):
3: "未接听",
4: "已取消",
}.get(_int(raw_status, -1), "")
recording_status = first_value(
row, "recording_status_text", "record_status_text", "record_status"
)
transcript_status = first_value(
row,
"transcription_status_text",
"transcript_status_text",
default="文字已生成" if transcript else "未生成文字",
)
matrix.append(
(
"" if normalized else "暂无录制回放",
@@ -3407,9 +3479,7 @@ class DiagnosisDialog(QDialog):
first_value(row, "room_id", "room_no"),
first_value(row, "duration_text", "duration"),
status,
first_value(
row, "recording_status_text", "record_status_text", "record_status"
),
f"{display_text(recording_status)}\n{display_text(transcript_status)}",
"",
)
)
@@ -3434,6 +3504,20 @@ class DiagnosisDialog(QDialog):
if item is not None:
item.setText("")
call_record_id = record_ids[row_index]
actions: list[QPushButton] = []
transcript = transcripts[row_index]
if transcript and call_record_id > 0:
view_transcript = self._action_button(
"查看文字", f"查看通话记录 #{call_record_id} 的录音转写"
)
view_transcript.setObjectName("DiagnosisVideoTranscriptView")
view_transcript.setProperty("callRecordId", call_record_id)
view_transcript.clicked.connect(
lambda _checked=False, selected=call_record_id, text=transcript: self._view_call_transcript(
selected, text
)
)
actions.append(view_transcript)
if self._editable and self._can_video_upload and call_record_id > 0:
upload = self._action_button(
"追加回放", f"上传并绑定通话记录 #{call_record_id}"
@@ -3452,12 +3536,16 @@ class DiagnosisDialog(QDialog):
selected
)
)
upload_host = QWidget()
upload_host.setObjectName("DiagnosisVideoRowUploadCell")
upload_layout = QVBoxLayout(upload_host)
upload_layout.setContentsMargins(0, 0, 0, 0)
upload_layout.addWidget(upload, 0, Qt.AlignmentFlag.AlignCenter)
table.setCellWidget(row_index, 8, upload_host)
actions.append(upload)
if actions:
action_host = QWidget()
action_host.setObjectName("DiagnosisVideoRowUploadCell")
action_layout = QVBoxLayout(action_host)
action_layout.setContentsMargins(0, 0, 0, 0)
action_layout.setSpacing(2)
for action in actions:
action_layout.addWidget(action, 0, Qt.AlignmentFlag.AlignCenter)
table.setCellWidget(row_index, 8, action_host)
upload_item = table.item(row_index, 8)
if upload_item is not None:
upload_item.setText("")
@@ -130,6 +130,7 @@ def _ticket_mapping(ticket: Any) -> Mapping[str, Any]:
"patientUserId": "patient_user_id",
"diagnosisId": "diagnosis_id",
"patientId": "patient_id",
"callRecordId": "call_record_id",
}
adapted = {
json_name: getattr(ticket, attribute_name)
@@ -196,6 +197,7 @@ class VideoCallRequest:
target_user_id: str
diagnosis_id: Identifier
patient_id: Identifier | None = None
call_record_id: Identifier | None = None
backend_mode: BackendMode = BackendMode.EMBEDDED
def __post_init__(self) -> None:
@@ -218,6 +220,12 @@ class VideoCallRequest:
"patient_id",
_identifier(self.patient_id, "patientId"),
)
if self.call_record_id is not None:
object.__setattr__(
self,
"call_record_id",
_identifier(self.call_record_id, "callRecordId"),
)
object.__setattr__(self, "backend_mode", BackendMode.parse(self.backend_mode))
@classmethod
@@ -257,6 +265,7 @@ class VideoCallRequest:
return {
"diagnosis_id": self.diagnosis_id,
"patient_id": self.patient_id,
"call_record_id": self.call_record_id,
"backend_mode": self.backend_mode.value,
}
@@ -285,6 +294,13 @@ def normalize_backend_ticket(
_identifier,
required=False,
)
payload_call_record = _read_aliases(
payload,
("callRecordId", "call_record_id"),
"callRecordId",
_identifier,
required=False,
)
normalized_diagnosis = _merge_identifier(
payload_diagnosis,
@@ -323,6 +339,7 @@ def normalize_backend_ticket(
),
diagnosis_id=normalized_diagnosis,
patient_id=normalized_patient,
call_record_id=payload_call_record,
backend_mode=BackendMode.parse(backend_mode),
)
+237 -1
View File
@@ -74,6 +74,71 @@ def _call_repository_method(method: Callable[..., Any], payload: Mapping[str, An
return _resolve_result(result)
def _mapping_candidate(value: Any) -> Mapping[str, Any] | None:
if isinstance(value, Mapping):
return value
for attribute in ("raw", "data"):
candidate = getattr(value, attribute, None)
if isinstance(candidate, Mapping):
return candidate
return None
def _extract_call_record_id(result: Any) -> int | str | None:
"""Read a positive call-record identity from common backend envelopes."""
pending = [result]
visited: set[int] = set()
while pending:
candidate = pending.pop(0)
mapping = _mapping_candidate(candidate)
if mapping is None or id(mapping) in visited:
continue
visited.add(id(mapping))
for key in ("call_record_id", "callRecordId", "id"):
value = mapping.get(key)
if isinstance(value, bool) or value is None:
continue
if isinstance(value, int) and value > 0:
return value
if isinstance(value, str) and value.strip():
return value.strip()
for key in ("data", "result", "record", "call_record", "callRecord"):
nested = mapping.get(key)
if isinstance(nested, Mapping):
pending.append(nested)
return None
def _clean_transcript_segment(segment: Mapping[str, Any], session_id: str) -> dict[str, Any]:
segment_id = str(segment.get("segment_id", segment.get("segmentId", ""))).strip()
text = str(segment.get("text", segment.get("sourceText", ""))).strip()
speaker_user_id = str(
segment.get("speaker_user_id", segment.get("speakerUserId", ""))
).strip()
speaker_role = str(segment.get("speaker_role", segment.get("speakerRole", "unknown"))).strip()
if not segment_id or len(segment_id) > 160:
raise ValueError("transcript segment_id must contain 1 to 160 characters")
if not text or len(text) > 4_000:
raise ValueError("transcript text must contain 1 to 4000 characters")
if len(speaker_user_id) > 160:
raise ValueError("transcript speaker_user_id is too long")
if speaker_role not in {"doctor", "patient", "unknown"}:
speaker_role = "unknown"
try:
timestamp = int(segment.get("timestamp") or 0)
except (TypeError, ValueError):
timestamp = 0
return {
"segment_id": segment_id,
"transcription_session_id": session_id,
"speaker_user_id": speaker_user_id,
"speaker_role": speaker_role,
"timestamp": max(timestamp, 0),
"text": text,
}
@dataclass(slots=True)
class _WorkItem:
operation: str
@@ -169,11 +234,17 @@ class OrderedCallLifecycle:
self.logger = logger
self.started = False
self.ended = False
self.call_record_id: int | str | None = request.call_record_id
self.bound_room_id: str | None = None
self._claimed_room_id: str | None = None
self._start_future: Future[bool] | None = None
self._bind_future: Future[bool] | None = None
self._end_future: Future[bool] | None = None
self._transcription_start_future: Future[bool] | None = None
self._transcription_finish_future: Future[bool] | None = None
self._transcription_session_id: str | None = None
self._transcription_active = False
self._segment_futures: dict[str, Future[bool]] = {}
self._lock = threading.RLock()
self._worker = _OrderedDaemonWorker(logger, request.safe_log_context())
@@ -181,6 +252,10 @@ class OrderedCallLifecycle:
def worker_is_daemon(self) -> bool:
return self._worker.is_daemon
@property
def transcription_session_id(self) -> str | None:
return self._transcription_session_id
def start(self) -> Future[bool]:
with self._lock:
if self._start_future is not None:
@@ -196,9 +271,13 @@ class OrderedCallLifecycle:
payload["patient_id"] = self.request.patient_id
def operation() -> bool:
_call_repository_method(method, payload)
result = _call_repository_method(method, payload)
record_id = _extract_call_record_id(result)
if record_id is None:
raise ValueError("startCall response did not include the current call_record_id")
with self._lock:
self.started = True
self.call_record_id = record_id
self.logger.info(
"video call record started",
extra={"video_call": self.request.safe_log_context()},
@@ -309,10 +388,167 @@ class OrderedCallLifecycle:
return self._worker.submit("screenshot", operation)
def start_transcription(self, session_id: str, *, language: str = "zh-CN") -> Future[bool]:
"""Start persisted realtime transcription for this exact call record."""
clean_session = str(session_id or "").strip()
clean_language = str(language or "zh-CN").strip()[:32] or "zh-CN"
if not clean_session or len(clean_session) > 128:
raise ValueError("transcription session id must contain 1 to 128 characters")
with self._lock:
if self._end_future is not None:
raise RuntimeError("video call has already ended")
if self._transcription_start_future is not None:
if self._transcription_session_id != clean_session:
raise RuntimeError("another transcription session already exists")
return self._transcription_start_future
if self._start_future is None:
self.start()
method = getattr(self.repository, "start_call_transcription", None)
if not callable(method):
raise ValueError("video repository does not implement call transcription storage")
self._transcription_session_id = clean_session
def operation() -> bool:
with self._lock:
started = self.started
record_id = self.call_record_id
if not started:
return False
if record_id is None:
raise ValueError("server did not return the current call_record_id")
_call_repository_method(
method,
{
"diagnosis_id": self.request.diagnosis_id,
"call_record_id": record_id,
"transcription_session_id": clean_session,
"language": clean_language,
},
)
with self._lock:
self._transcription_active = True
self.logger.info(
"video call transcription started",
extra={"video_call": self.request.safe_log_context()},
)
return True
self._transcription_start_future = self._worker.submit(
"transcription-start", operation
)
return self._transcription_start_future
def save_transcript_segment(self, segment: Mapping[str, Any]) -> Future[bool]:
"""Upsert one completed, bounded transcript segment without logging its text."""
with self._lock:
session_id = self._transcription_session_id
if not session_id or self._transcription_start_future is None:
raise RuntimeError("call transcription has not started")
if self._transcription_finish_future is not None:
raise RuntimeError("call transcription has already stopped")
cleaned = _clean_transcript_segment(segment, session_id)
segment_id = cleaned["segment_id"]
existing = self._segment_futures.get(segment_id)
if existing is not None:
return existing
method = getattr(self.repository, "upsert_call_transcript_segments", None)
if not callable(method):
raise ValueError("video repository does not implement transcript segment storage")
def operation() -> bool:
with self._lock:
active = self._transcription_active
record_id = self.call_record_id
if not active:
return False
if record_id is None:
raise ValueError("server did not return the current call_record_id")
_call_repository_method(
method,
{
"diagnosis_id": self.request.diagnosis_id,
"call_record_id": record_id,
"transcription_session_id": session_id,
"segments": [cleaned],
},
)
return True
future = self._worker.submit("transcription-segment", operation)
self._segment_futures[segment_id] = future
def release_failed(completed: Future[bool]) -> None:
if completed.cancelled() or completed.exception() is not None:
with self._lock:
if self._segment_futures.get(segment_id) is completed:
self._segment_futures.pop(segment_id, None)
future.add_done_callback(release_failed)
return future
def finish_transcription(self, *, status: str = "completed") -> Future[bool]:
"""Flush and finalize the current transcript exactly once."""
clean_status = str(status or "completed").strip().lower()
if clean_status not in {"completed", "partial", "failed"}:
raise ValueError("transcription status is invalid")
with self._lock:
if self._transcription_finish_future is not None:
return self._transcription_finish_future
if self._transcription_start_future is None or not self._transcription_session_id:
return _settled_future(False)
method = getattr(self.repository, "finish_call_transcription", None)
if not callable(method):
raise ValueError("video repository does not implement transcription finalization")
session_id = self._transcription_session_id
def operation() -> bool:
with self._lock:
active = self._transcription_active
record_id = self.call_record_id
expected_count = len(self._segment_futures)
if not active:
return False
if record_id is None:
raise ValueError("server did not return the current call_record_id")
_call_repository_method(
method,
{
"diagnosis_id": self.request.diagnosis_id,
"call_record_id": record_id,
"transcription_session_id": session_id,
"expected_segment_count": expected_count,
"status": clean_status,
},
)
with self._lock:
self._transcription_active = False
self.logger.info(
"video call transcription finalized",
extra={
"video_call": self.request.safe_log_context(),
"transcription_status": clean_status,
"segment_count": expected_count,
},
)
return True
self._transcription_finish_future = self._worker.submit(
"transcription-finish", operation
)
return self._transcription_finish_future
def end(self, reason: str) -> Future[bool]:
with self._lock:
if self._end_future is not None:
return self._end_future
if (
self._transcription_start_future is not None
and self._transcription_finish_future is None
):
self.finish_transcription(status="partial")
method = getattr(self.repository, "end_call", None)
def operation() -> bool:
+120 -2
View File
@@ -210,6 +210,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
call_error = Signal(str) # type: ignore[misc]
_start_completed = Signal(bool) # type: ignore[misc]
_screenshot_completed = Signal(bool, str) # type: ignore[misc]
_transcription_completed = Signal(str, str, str, bool, str) # type: ignore[misc]
def __init__(
self,
@@ -284,6 +285,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._start_completed.connect(self._on_lifecycle_started)
self._screenshot_completed.connect(self._on_screenshot_completed)
self._transcription_completed.connect(self._on_transcription_completed)
self.web_view.loadFinished.connect(self._on_load_finished)
self.web_view.setUrl(QUrl(self.location.url))
@@ -426,6 +428,21 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
str(message.get("message") or "截屏图片无效。")[:200],
)
return
if event == "transcription-start-request":
self._start_transcription(
str(message.get("sessionId") or ""),
str(message.get("language") or "zh-CN"),
)
return
if event == "transcription-segment":
self._save_transcript_segment(message)
return
if event == "transcription-stop":
self._finish_transcription(
str(message.get("sessionId") or ""),
str(message.get("status") or "completed"),
)
return
room_id = message.get("roomId", message.get("room_id"))
if room_id not in (None, ""):
self.lifecycle.bind_room(room_id)
@@ -434,8 +451,6 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if event == "status":
status = str(message.get("status", "unknown"))[:80]
self.status_changed.emit(status)
if status == "idle" and not self.open_im:
self._close_from_companion("remote-idle")
elif event == "hangup":
status = str(message.get("status", "ended"))[:80]
self.call_ended.emit(status)
@@ -509,6 +524,109 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
f"window.doctorConsultation?.screenshotResult?.({state}, {payload});"
)
def _notify_transcription_completed(
self,
operation: str,
session_id: str,
segment_id: str,
future: Future[bool],
) -> None:
try:
succeeded = bool(future.result())
except Exception as error:
succeeded = False
message = str(error)[:200] or "录音文字保存失败。"
else:
message = {
"start": "录音文字存储已准备。",
"segment": "",
"stop": "本次面诊对话文字已保存。",
}.get(operation, "")
with suppress(RuntimeError):
self._transcription_completed.emit(
operation, session_id, segment_id, succeeded, message
)
def _on_transcription_completed(
self,
operation: str,
session_id: str,
segment_id: str,
succeeded: bool,
message: str,
) -> None:
payload = json.dumps(str(message)[:200], ensure_ascii=True)
state = "true" if succeeded else "false"
self._page.runJavaScript(
"window.doctorConsultation?.transcriptionResult?.("
f"{json.dumps(operation)}, {json.dumps(session_id)}, "
f"{json.dumps(segment_id)}, {state}, {payload});"
)
def _start_transcription(self, session_id: str, language: str) -> None:
try:
future = self.lifecycle.start_transcription(session_id, language=language)
except Exception as error:
self._on_transcription_completed(
"start", session_id, "", False, str(error)[:200]
)
return
future.add_done_callback(
lambda completed: self._notify_transcription_completed(
"start", session_id, "", completed
)
)
def _save_transcript_segment(self, message: Mapping[str, Any]) -> None:
session_id = str(message.get("sessionId") or "").strip()
segment = message.get("segment")
segment_id = (
str(segment.get("segment_id") or segment.get("segmentId") or "").strip()
if isinstance(segment, Mapping)
else ""
)
if not isinstance(segment, Mapping):
self._on_transcription_completed(
"segment", session_id, segment_id, False, "录音文字片段无效。"
)
return
if session_id != str(self.lifecycle.transcription_session_id or ""):
self._on_transcription_completed(
"segment", session_id, segment_id, False, "录音会话标识不匹配。"
)
return
try:
future = self.lifecycle.save_transcript_segment(segment)
except Exception as error:
self._on_transcription_completed(
"segment", session_id, segment_id, False, str(error)[:200]
)
return
future.add_done_callback(
lambda completed: self._notify_transcription_completed(
"segment", session_id, segment_id, completed
)
)
def _finish_transcription(self, session_id: str, status: str) -> None:
if session_id.strip() != str(self.lifecycle.transcription_session_id or ""):
self._on_transcription_completed(
"stop", session_id, "", False, "录音会话标识不匹配。"
)
return
try:
future = self.lifecycle.finish_transcription(status=status)
except Exception as error:
self._on_transcription_completed(
"stop", session_id, "", False, str(error)[:200]
)
return
future.add_done_callback(
lambda completed: self._notify_transcription_completed(
"stop", session_id, "", completed
)
)
def _close_from_companion(self, reason: str) -> None:
self._companion_ended = True
self._close_reason = reason