This commit is contained in:
Your Name
2026-08-14 14:37:30 +08:00
parent 21790e35f4
commit 18c15d1262
117 changed files with 28157 additions and 8080 deletions
+450 -176
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import mimetypes
import re
import time
from collections.abc import Mapping, Sequence
from collections.abc import Mapping, Sequence
from contextlib import suppress
from datetime import date
from io import BytesIO
@@ -41,9 +41,21 @@ PRESCRIPTION_LIBRARY_PERMISSIONS: Final[dict[str, str]] = {
"read": "wcf.prescription/read",
"update": "wcf.prescription/edit",
"delete": "wcf.prescription/delete",
"ai_reports": "tcm.prescriptionLibrary/aiReports",
"generate_ai_reports": "tcm.prescriptionLibrary/generateAiReports",
"edit_ai_report": "tcm.prescriptionLibrary/editAiReport",
}
"""Canonical permissions used by the routed prescription-library view."""
DIAGNOSIS_AI_PERMISSIONS: Final[dict[str, str]] = {
"ai_reports": "tcm.diagnosis/aiReports",
"generate_ai_reports": "tcm.diagnosis/generateAiReports",
"edit_ai_report": "tcm.diagnosis/editAiReport",
"analysis": "tcm.diagnosis/aiAnalysis",
"assistant": "tcm.diagnosis/aiAssistant",
}
"""Canonical permissions used by reception and patient-profile AI reports."""
PRESCRIPTION_PERMISSIONS: Final[dict[str, str]] = {
"create": "cf.prescription/add",
"read": "cf.prescription/read",
@@ -140,6 +152,64 @@ class DoctorRepository(Protocol):
def delete_prescription_template(self, template_id: int) -> Any:
"""Delete a prescription-library record."""
def list_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
"""Return saved AI interpretation reports for one library template."""
def generate_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
"""Regenerate every model report for one library template."""
def edit_prescription_template_ai_report(
self,
template_id: int,
*,
report_id: int,
content: str,
) -> dict[str, Any]:
"""Save a manually edited AI report for one library template."""
def list_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
"""Return saved AI reports for one diagnosis / patient profile."""
def generate_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
"""Regenerate every model report for one diagnosis."""
def edit_diagnosis_ai_report(
self,
diagnosis_id: int,
*,
report_id: int,
content: str,
) -> dict[str, Any]:
"""Save a manually edited AI report for one diagnosis."""
def analyze_diagnosis_ai(
self,
diagnosis_id: int,
prompt: str,
*,
task: str = "custom",
) -> dict[str, Any]:
"""Ask the first-party diagnosis assistant; the server selects the model."""
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
*,
model: Literal["qwen", "openai"] = "qwen",
) -> dict[str, Any]:
"""Generate one model's structured reception analysis for a diagnosis."""
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
"""Return every persisted AI diagnosis snapshot for one patient."""
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: Literal["qwen", "openai"],
) -> dict[str, Any]:
"""Append one model-specific AI diagnosis snapshot for one patient."""
def get_prescription(self, prescription_id: int) -> Prescription:
"""Return one issued prescription."""
@@ -619,38 +689,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 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 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`."""
@@ -1165,7 +1235,191 @@ class RemoteDoctorRepository:
return self.client.post("tcm.prescriptionLibrary/delete", {"id": template_id})
def list_medicines(
def list_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
"""Read persisted AI reports without triggering model generation."""
payload = _client_request(
self.client,
"get",
"tcm.prescriptionLibrary/aiReports",
{"id": template_id},
timeout=30.0,
)
return dict(_require_mapping(payload, "tcm.prescriptionLibrary/aiReports"))
def generate_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
"""Regenerate the multi-model diagnosis report; not retried by the client."""
payload = _client_request(
self.client,
"post",
"tcm.prescriptionLibrary/generateAiReports",
{"id": template_id},
timeout=210.0,
)
return dict(_require_mapping(payload, "tcm.prescriptionLibrary/generateAiReports"))
def edit_prescription_template_ai_report(
self,
template_id: int,
*,
report_id: int,
content: str,
) -> dict[str, Any]:
"""Persist a manual edit of one saved model report."""
payload = _client_request(
self.client,
"post",
"tcm.prescriptionLibrary/editAiReport",
{"id": template_id, "report_id": report_id, "content": content},
timeout=30.0,
)
return dict(_require_mapping(payload, "tcm.prescriptionLibrary/editAiReport"))
def list_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
"""Read persisted diagnosis AI reports without triggering model generation."""
payload = _client_request(
self.client,
"get",
"tcm.diagnosis/aiReports",
{"id": diagnosis_id},
timeout=30.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/aiReports"))
def generate_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
"""Regenerate the multi-model diagnosis report; not retried by the client."""
payload = _client_request(
self.client,
"post",
"tcm.diagnosis/generateAiReports",
{"id": diagnosis_id},
timeout=210.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/generateAiReports"))
def edit_diagnosis_ai_report(
self,
diagnosis_id: int,
*,
report_id: int,
content: str,
) -> dict[str, Any]:
"""Persist a manual edit of one saved diagnosis report."""
payload = _client_request(
self.client,
"post",
"tcm.diagnosis/editAiReport",
{"id": diagnosis_id, "report_id": report_id, "content": content},
timeout=30.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/editAiReport"))
def analyze_diagnosis_ai(
self,
diagnosis_id: int,
prompt: str,
*,
task: str = "custom",
) -> dict[str, Any]:
"""Submit a diagnosis question to the first-party server assistant."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
clean_prompt = prompt.strip()
if not clean_prompt:
raise ValueError("prompt is required")
if len(clean_prompt) > 500:
raise ValueError("prompt must not exceed 500 characters")
clean_task = task.strip().lower() or "custom"
if clean_task not in {
"summary",
"tcm_pattern",
"prescription_review",
"medication_review",
"exam_review",
"complication_risk",
"guideline_review",
"custom",
}:
raise ValueError("task is not supported")
payload = _client_request(
self.client,
"post",
"tcm.diagnosis/aiAssistant",
{"id": diagnosis_id, "prompt": clean_prompt, "task": clean_task},
# The upstream is allowed 90 seconds by server configuration. Keep a
# small transport buffer so the desktop can receive the server's own
# timeout response instead of racing it.
timeout=105.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/aiAssistant"))
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
*,
model: Literal["qwen", "openai"] = "qwen",
) -> dict[str, Any]:
"""Generate one model's structured analysis via the first-party API."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
clean_model = str(model).strip().lower()
if clean_model not in {"qwen", "openai"}:
raise ValueError("model must be qwen or openai")
payload = _client_request(
self.client,
"post",
"tcm.diagnosis/aiAnalysis",
{"id": diagnosis_id, "model": clean_model},
timeout=105.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/aiAnalysis"))
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
"""Read patient-level snapshots without triggering model generation."""
if patient_id <= 0:
raise ValueError("patient_id must be positive")
payload = _client_request(
self.client,
"get",
"tcm.diagnosis/patientAiReports",
{"patient_id": patient_id},
timeout=30.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/patientAiReports"))
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: Literal["qwen", "openai"],
) -> dict[str, Any]:
"""Append one patient snapshot; the desktop never sends provider secrets."""
if patient_id <= 0:
raise ValueError("patient_id must be positive")
clean_model = str(model).strip().lower()
if clean_model not in {"qwen", "openai"}:
raise ValueError("model must be qwen or openai")
payload = _client_request(
self.client,
"post",
"tcm.diagnosis/generatePatientAiReport",
{"patient_id": patient_id, "model": clean_model},
timeout=105.0,
)
return dict(
_require_mapping(payload, "tcm.diagnosis/generatePatientAiReport")
)
def list_medicines(
self,
*,
name: str = "",
@@ -2201,157 +2455,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."""
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 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()},
)
@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)
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:
@@ -2402,6 +2656,26 @@ def _require_mapping(value: object, endpoint: str) -> Mapping[str, Any]:
return value
def _client_request(
client: Any,
method: str,
endpoint: str,
payload: Mapping[str, Any] | None = None,
*,
timeout: float | None = None,
) -> Any:
"""Call get/post, ignoring timeout kwargs that test doubles do not accept."""
fn = getattr(client, method)
params = dict(payload or {})
try:
if timeout is None:
return fn(endpoint, params)
return fn(endpoint, params, timeout=timeout)
except TypeError:
return fn(endpoint, params)
def _material_kind(
material_type: str,
) -> Literal["image", "video", "file"]: