Align client surgery API with documented contract and support consumable codes.
Unify result 503 to RESULT_NOT_READY, restore resolve ASR failures as HTTP 422, accept label_id-only candidate_consumables, and document the changelog for integrators. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -46,9 +46,6 @@ from app.surgery_errors import SurgeryPipelineError
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# 上传 WAV 后 ASR/解析失败:HTTP 200 + status=failed,待确认项仍留在 FIFO 队首,便于桌面端重试。
|
|
||||||
_RECOVERABLE_VOICE_RESOLVE_CODES = frozenset({"VOICE_ASR_FAILED", "VOICE_TEXT_EMPTY", "VOICE_PARSE_FAILED"})
|
|
||||||
|
|
||||||
|
|
||||||
def _pipeline_error_detail(exc: SurgeryPipelineError, surgery_id: str) -> dict:
|
def _pipeline_error_detail(exc: SurgeryPipelineError, surgery_id: str) -> dict:
|
||||||
d: dict = {
|
d: dict = {
|
||||||
@@ -671,7 +668,7 @@ async def get_pending_consumable_confirmation(
|
|||||||
"multipart/form-data 上传单个 WAV 文件(字段名 `audio`)。"
|
"multipart/form-data 上传单个 WAV 文件(字段名 `audio`)。"
|
||||||
"服务端将音频存入 MinIO、调用百度 ASR 识别、解析候选项并完成确认。"
|
"服务端将音频存入 MinIO、调用百度 ASR 识别、解析候选项并完成确认。"
|
||||||
"解析并确认后记一条消耗明细;若语音表示否认全部候选则不记消耗。"
|
"解析并确认后记一条消耗明细;若语音表示否认全部候选则不记消耗。"
|
||||||
"ASR/解析可重试失败时仍返回 HTTP 200,`status`=`failed`,队首待确认项不弹出,便于桌面端重试。"
|
"ASR/解析失败时返回 HTTP 422(如 VOICE_ASR_FAILED),队首待确认项不弹出,便于客户端重试。"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
async def resolve_pending_consumable_confirmation(
|
async def resolve_pending_consumable_confirmation(
|
||||||
@@ -723,21 +720,6 @@ async def resolve_pending_consumable_confirmation(
|
|||||||
content_type=audio.content_type,
|
content_type=audio.content_type,
|
||||||
)
|
)
|
||||||
except SurgeryPipelineError as exc:
|
except SurgeryPipelineError as exc:
|
||||||
if exc.code in _RECOVERABLE_VOICE_RESOLVE_CODES:
|
|
||||||
extra = exc.extra or {}
|
|
||||||
asr_txt = extra.get("asr_text")
|
|
||||||
akey = extra.get("audio_object_key")
|
|
||||||
return SurgeryPendingConfirmationResolveResponse(
|
|
||||||
surgery_id=surgery_id,
|
|
||||||
confirmation_id=confirmation_id,
|
|
||||||
status="failed",
|
|
||||||
message=exc.message,
|
|
||||||
resolved_label=None,
|
|
||||||
rejected=False,
|
|
||||||
asr_text=asr_txt if isinstance(asr_txt, str) else None,
|
|
||||||
audio_object_key=akey if isinstance(akey, str) else None,
|
|
||||||
error_code=exc.code,
|
|
||||||
)
|
|
||||||
_raise_confirmation_http(exc, surgery_id)
|
_raise_confirmation_http(exc, surgery_id)
|
||||||
return SurgeryPendingConfirmationResolveResponse(
|
return SurgeryPendingConfirmationResolveResponse(
|
||||||
surgery_id=surgery_id,
|
surgery_id=surgery_id,
|
||||||
@@ -748,5 +730,4 @@ async def resolve_pending_consumable_confirmation(
|
|||||||
rejected=result.rejected,
|
rejected=result.rejected,
|
||||||
asr_text=result.asr_text,
|
asr_text=result.asr_text,
|
||||||
audio_object_key=result.audio_object_key,
|
audio_object_key=result.audio_object_key,
|
||||||
error_code=None,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ from app.baked import algorithm as ba
|
|||||||
|
|
||||||
|
|
||||||
def normalize_candidate_consumables_raw(value: Any) -> list[str]:
|
def normalize_candidate_consumables_raw(value: Any) -> list[str]:
|
||||||
"""将开录请求里的 ``candidate_consumables`` 转为字符串列表。
|
"""将开录请求里的 ``candidate_consumables`` 转为字符串列表(名称或编号)。
|
||||||
|
|
||||||
除 ``list[str]`` 外,支持医院导出常见的 ``{\"消耗品编号\": ..., \"名称\": ...}`` 数组,
|
支持:
|
||||||
选取 ``名称``(或 ``name``)作为耗材显示名。
|
- ``list[str]``:名称或产品编码(label_id);
|
||||||
|
- 医院导出对象:含 ``名称`` / ``name``,或仅含 ``消耗品编号`` / ``code`` / ``label_id``。
|
||||||
|
编号会在 ``effective_candidate_consumables`` 中按 yaml 解析为类名。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
@@ -32,11 +34,20 @@ def normalize_candidate_consumables_raw(value: Any) -> list[str]:
|
|||||||
raw_name = item.get("名称")
|
raw_name = item.get("名称")
|
||||||
if raw_name is None:
|
if raw_name is None:
|
||||||
raw_name = item.get("name")
|
raw_name = item.get("name")
|
||||||
if raw_name is None:
|
if raw_name is not None:
|
||||||
|
s = str(raw_name).strip()
|
||||||
|
if s:
|
||||||
|
out.append(s)
|
||||||
continue
|
continue
|
||||||
s = str(raw_name).strip()
|
raw_code = item.get("消耗品编号")
|
||||||
if s:
|
if raw_code is None:
|
||||||
out.append(s)
|
raw_code = item.get("code")
|
||||||
|
if raw_code is None:
|
||||||
|
raw_code = item.get("label_id")
|
||||||
|
if raw_code is not None:
|
||||||
|
s = str(raw_code).strip()
|
||||||
|
if s:
|
||||||
|
out.append(s)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -117,22 +128,58 @@ def default_labels_yaml_path() -> Path:
|
|||||||
return Path(ba.CONSUMABLE_CLASSIFIER_LABELS_YAML_PATH).expanduser()
|
return Path(ba.CONSUMABLE_CLASSIFIER_LABELS_YAML_PATH).expanduser()
|
||||||
|
|
||||||
|
|
||||||
|
def load_label_id_to_name_from_yaml(path: Path) -> dict[str, str]:
|
||||||
|
"""产品编码 label_id → 归一化类名(与 load_name_to_label_id_from_yaml 互逆)。"""
|
||||||
|
name_to_id = load_name_to_label_id_from_yaml(path)
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for name, lid in name_to_id.items():
|
||||||
|
if lid not in out:
|
||||||
|
out[lid] = name
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_candidate_token(
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
class_names: set[str],
|
||||||
|
code_to_name: dict[str, str],
|
||||||
|
) -> str | None:
|
||||||
|
"""将请求项解析为算法使用的类名:优先名称匹配,其次 label_id。"""
|
||||||
|
n = norm_product_name((token or "").strip())
|
||||||
|
if not n:
|
||||||
|
return None
|
||||||
|
if n in class_names:
|
||||||
|
return n
|
||||||
|
if n in code_to_name:
|
||||||
|
return code_to_name[n]
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
def effective_candidate_consumables(
|
def effective_candidate_consumables(
|
||||||
requested: list[str],
|
requested: list[str],
|
||||||
*,
|
*,
|
||||||
labels_yaml_path: Path | None = None,
|
labels_yaml_path: Path | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
ypath = labels_yaml_path or default_labels_yaml_path()
|
||||||
|
class_names: set[str] = set()
|
||||||
|
code_to_name: dict[str, str] = {}
|
||||||
|
if ypath.is_file():
|
||||||
|
class_names = set(list_sorted_class_names_from_yaml(ypath))
|
||||||
|
code_to_name = load_label_id_to_name_from_yaml(ypath)
|
||||||
|
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for c in requested:
|
for c in requested:
|
||||||
n = norm_product_name((c or "").strip())
|
resolved = resolve_candidate_token(
|
||||||
if not n or n in seen:
|
c,
|
||||||
continue
|
class_names=class_names,
|
||||||
seen.add(n)
|
code_to_name=code_to_name,
|
||||||
out.append(n)
|
)
|
||||||
|
if resolved and resolved not in seen:
|
||||||
|
seen.add(resolved)
|
||||||
|
out.append(resolved)
|
||||||
if out:
|
if out:
|
||||||
return out
|
return out
|
||||||
ypath = labels_yaml_path or default_labels_yaml_path()
|
|
||||||
if ypath.is_file():
|
if ypath.is_file():
|
||||||
ylist = list_sorted_class_names_from_yaml(ypath)
|
ylist = list_sorted_class_names_from_yaml(ypath)
|
||||||
if ylist:
|
if ylist:
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ class SurgeryStartRequest(BaseModel):
|
|||||||
"非空时仅对该清单内名称做自动记账与待确认追问。"
|
"非空时仅对该清单内名称做自动记账与待确认追问。"
|
||||||
"缺省或空数组时,使用 consumable_classifier_labels.yaml 中全部类名;"
|
"缺省或空数组时,使用 consumable_classifier_labels.yaml 中全部类名;"
|
||||||
"无有效 yaml 则使用分类模型全部类名。"
|
"无有效 yaml 则使用分类模型全部类名。"
|
||||||
"每项可为字符串,或含「名称」(或 name)的对象(与耗材导出表一致)。"
|
"每项可为:耗材名称字符串;产品编码(label_id,与 yaml 中 label_id 一致);"
|
||||||
|
"或含「名称」(或 name)、「消耗品编号」(或 code / label_id)的对象(与耗材导出表一致)。"
|
||||||
|
"仅传编号时服务端按 yaml 解析为类名。"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,8 +88,8 @@ class SurgeryClientErrorDetail(BaseModel):
|
|||||||
|
|
||||||
code: str = Field(
|
code: str = Field(
|
||||||
description=(
|
description=(
|
||||||
"业务错误码,如 SURGERY_ALREADY_RECORDING、SURGERY_NOT_STARTED、"
|
"业务错误码,如 RESULT_NOT_READY、RECORDING_CANNOT_START、"
|
||||||
"SURGERY_IN_PROGRESS_NO_DETAILS、SURGERY_ENDED_NO_CONSUMPTION、RECORDING_CANNOT_START。"
|
"NO_PENDING_CONFIRMATION、VOICE_ASR_FAILED。"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
message: str = Field(description="人类可读说明。")
|
message: str = Field(description="人类可读说明。")
|
||||||
@@ -212,13 +214,9 @@ class SurgeryPendingConfirmationResolveResponse(BaseModel):
|
|||||||
surgery_id: str
|
surgery_id: str
|
||||||
confirmation_id: str
|
confirmation_id: str
|
||||||
status: str = Field(
|
status: str = Field(
|
||||||
description=("``accepted``:已确认或已否认并结案;``failed``:ASR/解析等可重试失败,队首待确认项未移除。"),
|
description="成功时为 ``accepted``。",
|
||||||
)
|
)
|
||||||
message: str
|
message: str
|
||||||
error_code: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="仅 status=failed 时与错误码一致(如 VOICE_ASR_FAILED)。",
|
|
||||||
)
|
|
||||||
resolved_label: str | None = Field(
|
resolved_label: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="解析并确认后的耗材名称;否认全部候选时为 null。",
|
description="解析并确认后的耗材名称;否认全部候选时为 null。",
|
||||||
|
|||||||
@@ -102,16 +102,16 @@ class SurgeryPipeline:
|
|||||||
self._sessions.set_voice_terminal_id(surgery_id, terminal_id)
|
self._sessions.set_voice_terminal_id(surgery_id, terminal_id)
|
||||||
|
|
||||||
async def classify_result_unavailable(self, surgery_id: str) -> tuple[str, str]:
|
async def classify_result_unavailable(self, surgery_id: str) -> tuple[str, str]:
|
||||||
"""无至少一条消耗明细时,区分未开始 / 进行中无结果 / 已结束无消耗等。"""
|
"""无至少一条消耗明细时返回 RESULT_NOT_READY;message 保留可读原因。"""
|
||||||
phase = self._sessions.active_recording_phase(surgery_id)
|
phase = self._sessions.active_recording_phase(surgery_id)
|
||||||
if phase == "starting":
|
if phase == "starting":
|
||||||
return (
|
return (
|
||||||
"SURGERY_STARTING",
|
"RESULT_NOT_READY",
|
||||||
"手术正在启动,算法尚未就绪,请稍后再查。",
|
"手术正在启动,算法尚未就绪,请稍后再查。",
|
||||||
)
|
)
|
||||||
if phase == "recording":
|
if phase == "recording":
|
||||||
return (
|
return (
|
||||||
"SURGERY_IN_PROGRESS_NO_DETAILS",
|
"RESULT_NOT_READY",
|
||||||
"手术进行中,尚无至少一条消耗明细。",
|
"手术进行中,尚无至少一条消耗明细。",
|
||||||
)
|
)
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
@@ -119,17 +119,17 @@ class SurgeryPipeline:
|
|||||||
persisted = await self._repo.load_final_details(session, surgery_id)
|
persisted = await self._repo.load_final_details(session, surgery_id)
|
||||||
if persisted is not None:
|
if persisted is not None:
|
||||||
return (
|
return (
|
||||||
"SURGERY_ENDED_NO_CONSUMPTION",
|
"RESULT_NOT_READY",
|
||||||
"手术已结束,当前无消耗明细。",
|
"手术已结束,当前无消耗明细。",
|
||||||
)
|
)
|
||||||
archived = await self._sessions.archived_consumption_fallback(surgery_id)
|
archived = await self._sessions.archived_consumption_fallback(surgery_id)
|
||||||
if archived is not None:
|
if archived is not None:
|
||||||
return (
|
return (
|
||||||
"SURGERY_ENDED_NO_CONSUMPTION",
|
"RESULT_NOT_READY",
|
||||||
"手术已结束(尚未落库),当前无消耗明细。",
|
"手术已结束(尚未落库),当前无消耗明细。",
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
"SURGERY_NOT_STARTED",
|
"RESULT_NOT_READY",
|
||||||
"手术未开始,请先调用开始手术接口。",
|
"手术未开始,请先调用开始手术接口。",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -301,26 +301,26 @@ def test_get_result_503_not_ready(api_app: FastAPI) -> None:
|
|||||||
pipeline = MagicMock()
|
pipeline = MagicMock()
|
||||||
pipeline.get_consumption_details_for_client = AsyncMock(return_value=None)
|
pipeline.get_consumption_details_for_client = AsyncMock(return_value=None)
|
||||||
pipeline.classify_result_unavailable = AsyncMock(
|
pipeline.classify_result_unavailable = AsyncMock(
|
||||||
return_value=("SURGERY_NOT_STARTED", "手术未开始,请先调用开始手术接口。")
|
return_value=("RESULT_NOT_READY", "手术未开始,请先调用开始手术接口。")
|
||||||
)
|
)
|
||||||
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
|
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
|
||||||
client = TestClient(api_app)
|
client = TestClient(api_app)
|
||||||
r = client.get("/client/surgeries/123456/result")
|
r = client.get("/client/surgeries/123456/result")
|
||||||
assert r.status_code == 503
|
assert r.status_code == 503
|
||||||
assert r.json()["detail"]["code"] == "SURGERY_NOT_STARTED"
|
assert r.json()["detail"]["code"] == "RESULT_NOT_READY"
|
||||||
|
|
||||||
|
|
||||||
def test_get_result_503_empty_details(api_app: FastAPI) -> None:
|
def test_get_result_503_empty_details(api_app: FastAPI) -> None:
|
||||||
pipeline = MagicMock()
|
pipeline = MagicMock()
|
||||||
pipeline.get_consumption_details_for_client = AsyncMock(return_value=[])
|
pipeline.get_consumption_details_for_client = AsyncMock(return_value=[])
|
||||||
pipeline.classify_result_unavailable = AsyncMock(
|
pipeline.classify_result_unavailable = AsyncMock(
|
||||||
return_value=("SURGERY_ENDED_NO_CONSUMPTION", "手术已结束,当前无消耗明细。")
|
return_value=("RESULT_NOT_READY", "手术已结束,当前无消耗明细。")
|
||||||
)
|
)
|
||||||
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
|
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
|
||||||
client = TestClient(api_app)
|
client = TestClient(api_app)
|
||||||
r = client.get("/client/surgeries/123456/result")
|
r = client.get("/client/surgeries/123456/result")
|
||||||
assert r.status_code == 503
|
assert r.status_code == 503
|
||||||
assert r.json()["detail"]["code"] == "SURGERY_ENDED_NO_CONSUMPTION"
|
assert r.json()["detail"]["code"] == "RESULT_NOT_READY"
|
||||||
|
|
||||||
|
|
||||||
def test_pending_confirmation_200_and_404(api_app: FastAPI) -> None:
|
def test_pending_confirmation_200_and_404(api_app: FastAPI) -> None:
|
||||||
@@ -403,13 +403,12 @@ def test_resolve_200(api_app: FastAPI) -> None:
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["status"] == "accepted"
|
assert body["status"] == "accepted"
|
||||||
assert body["error_code"] is None
|
|
||||||
assert body["resolved_label"] == "纱布"
|
assert body["resolved_label"] == "纱布"
|
||||||
assert body["rejected"] is False
|
assert body["rejected"] is False
|
||||||
assert body["asr_text"] == "第一个"
|
assert body["asr_text"] == "第一个"
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_voice_recoverable_error_returns_200_failed(api_app: FastAPI) -> None:
|
def test_resolve_voice_recoverable_error_returns_422(api_app: FastAPI) -> None:
|
||||||
pipeline = MagicMock()
|
pipeline = MagicMock()
|
||||||
pipeline.resolve_pending_confirmation_from_audio = AsyncMock(
|
pipeline.resolve_pending_confirmation_from_audio = AsyncMock(
|
||||||
side_effect=SurgeryPipelineError(
|
side_effect=SurgeryPipelineError(
|
||||||
@@ -423,11 +422,10 @@ def test_resolve_voice_recoverable_error_returns_200_failed(api_app: FastAPI) ->
|
|||||||
"/client/surgeries/123456/pending-confirmation/cid/resolve",
|
"/client/surgeries/123456/pending-confirmation/cid/resolve",
|
||||||
files={"audio": ("a.wav", b"RIFF", "audio/wav")},
|
files={"audio": ("a.wav", b"RIFF", "audio/wav")},
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 422
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["status"] == "failed"
|
assert body["detail"]["code"] == "VOICE_ASR_FAILED"
|
||||||
assert body["error_code"] == "VOICE_ASR_FAILED"
|
assert "3301" in body["detail"]["message"]
|
||||||
assert "3301" in body["message"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_maps_surgery_pipeline_error_to_http(api_app: FastAPI) -> None:
|
def test_resolve_maps_surgery_pipeline_error_to_http(api_app: FastAPI) -> None:
|
||||||
|
|||||||
@@ -20,10 +20,33 @@ def test_normalize_candidate_consumables_raw_strings_and_export_objects() -> Non
|
|||||||
{"name": "缝线"},
|
{"name": "缝线"},
|
||||||
]
|
]
|
||||||
assert normalize_candidate_consumables_raw(raw) == ["一次性使用手术单", "缝线"]
|
assert normalize_candidate_consumables_raw(raw) == ["一次性使用手术单", "缝线"]
|
||||||
|
assert normalize_candidate_consumables_raw([{"消耗品编号": "8036-5-22"}]) == ["8036-5-22"]
|
||||||
assert normalize_candidate_consumables_raw([{}]) == []
|
assert normalize_candidate_consumables_raw([{}]) == []
|
||||||
assert normalize_candidate_consumables_raw("not-a-list") == []
|
assert normalize_candidate_consumables_raw("not-a-list") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_resolves_product_codes_via_yaml(tmp_path: Path) -> None:
|
||||||
|
yml = tmp_path / "lab.yaml"
|
||||||
|
yml.write_text(
|
||||||
|
"names:\n 0: 商品A\n 1: 商品乙\nlabel_id:\n 0: code-a\n 1: code-b\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
got = effective_candidate_consumables(["code-b", "code-a"], labels_yaml_path=yml)
|
||||||
|
assert got == ["商品乙", "商品A"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_code_only_export_object(tmp_path: Path) -> None:
|
||||||
|
yml = tmp_path / "lab.yaml"
|
||||||
|
yml.write_text(
|
||||||
|
"names:\n 0: 一次性使用手术单\nlabel_id:\n 0: 14764-2-4\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
raw = normalize_candidate_consumables_raw([{"消耗品编号": "14764-2-4"}])
|
||||||
|
assert raw == ["14764-2-4"]
|
||||||
|
got = effective_candidate_consumables(raw, labels_yaml_path=yml)
|
||||||
|
assert got == ["一次性使用手术单"]
|
||||||
|
|
||||||
|
|
||||||
def test_effective_preserves_non_empty_request() -> None:
|
def test_effective_preserves_non_empty_request() -> None:
|
||||||
got = effective_candidate_consumables([" 纱布 ", "缝线", "纱布"])
|
got = effective_candidate_consumables([" 纱布 ", "缝线", "纱布"])
|
||||||
assert got == ["纱布", "缝线"]
|
assert got == ["纱布", "缝线"]
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ async def test_classify_not_started() -> None:
|
|||||||
repo.load_final_details = AsyncMock(return_value=None)
|
repo.load_final_details = AsyncMock(return_value=None)
|
||||||
pipeline = SurgeryPipeline(sessions, result_repository=repo, voice_confirmation=MagicMock())
|
pipeline = SurgeryPipeline(sessions, result_repository=repo, voice_confirmation=MagicMock())
|
||||||
code, _ = await pipeline.classify_result_unavailable("123456")
|
code, _ = await pipeline.classify_result_unavailable("123456")
|
||||||
assert code == "SURGERY_NOT_STARTED"
|
assert code == "RESULT_NOT_READY"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -29,7 +29,7 @@ async def test_classify_in_progress_no_details() -> None:
|
|||||||
sessions.active_recording_phase = MagicMock(return_value="recording")
|
sessions.active_recording_phase = MagicMock(return_value="recording")
|
||||||
pipeline = SurgeryPipeline(sessions, result_repository=MagicMock(), voice_confirmation=MagicMock())
|
pipeline = SurgeryPipeline(sessions, result_repository=MagicMock(), voice_confirmation=MagicMock())
|
||||||
code, _ = await pipeline.classify_result_unavailable("123456")
|
code, _ = await pipeline.classify_result_unavailable("123456")
|
||||||
assert code == "SURGERY_IN_PROGRESS_NO_DETAILS"
|
assert code == "RESULT_NOT_READY"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -38,7 +38,7 @@ async def test_classify_starting() -> None:
|
|||||||
sessions.active_recording_phase = MagicMock(return_value="starting")
|
sessions.active_recording_phase = MagicMock(return_value="starting")
|
||||||
pipeline = SurgeryPipeline(sessions, result_repository=MagicMock(), voice_confirmation=MagicMock())
|
pipeline = SurgeryPipeline(sessions, result_repository=MagicMock(), voice_confirmation=MagicMock())
|
||||||
code, _ = await pipeline.classify_result_unavailable("123456")
|
code, _ = await pipeline.classify_result_unavailable("123456")
|
||||||
assert code == "SURGERY_STARTING"
|
assert code == "RESULT_NOT_READY"
|
||||||
|
|
||||||
|
|
||||||
def test_active_recording_phase() -> None:
|
def test_active_recording_phase() -> None:
|
||||||
|
|||||||
@@ -75,12 +75,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RESULT_UNAVAILABLE_LABELS = {
|
const RESULT_UNAVAILABLE_LABELS = {
|
||||||
SURGERY_NOT_STARTED: "手术未开始",
|
|
||||||
SURGERY_STARTING: "手术启动中,算法尚未就绪",
|
|
||||||
SURGERY_IN_PROGRESS_NO_DETAILS: "手术进行中,尚无消耗明细",
|
|
||||||
SURGERY_ENDED_NO_CONSUMPTION: "手术已结束,无消耗明细",
|
|
||||||
SURGERY_ALREADY_RECORDING: "该手术已在录制中",
|
|
||||||
RESULT_NOT_READY: "结果尚不可查询",
|
RESULT_NOT_READY: "结果尚不可查询",
|
||||||
|
SURGERY_ALREADY_RECORDING: "该手术已在录制中",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatResultUnavailable(body) {
|
function formatResultUnavailable(body) {
|
||||||
|
|||||||
@@ -772,15 +772,6 @@ class VoiceMonitorEngine {
|
|||||||
this._emitState("待机");
|
this._emitState("待机");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (rstatus === "failed") {
|
|
||||||
this._emitResolveResult({ ...baseMeta, body: res });
|
|
||||||
const code = res.error_code || "";
|
|
||||||
const msg = String(res.message || "");
|
|
||||||
this._log("语音未通过(可重试)" + (code ? "[" + code + "] " : "") + msg);
|
|
||||||
this._state.failed_resolve_cid = cid;
|
|
||||||
this._emitState("请重试录音或检查麦克风");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (st === 422 && typeof res === "object" && res && res.detail) {
|
if (st === 422 && typeof res === "object" && res && res.detail) {
|
||||||
const d = res.detail;
|
const d = res.detail;
|
||||||
@@ -788,7 +779,7 @@ class VoiceMonitorEngine {
|
|||||||
const c = d.code;
|
const c = d.code;
|
||||||
if (c === "VOICE_ASR_FAILED" || c === "VOICE_TEXT_EMPTY" || c === "VOICE_PARSE_FAILED") {
|
if (c === "VOICE_ASR_FAILED" || c === "VOICE_TEXT_EMPTY" || c === "VOICE_PARSE_FAILED") {
|
||||||
this._emitResolveResult({ ...baseMeta, body: res });
|
this._emitResolveResult({ ...baseMeta, body: res });
|
||||||
this._log("语音未通过(可重试,旧接口)[" + c + "]: " + (d.message || ""));
|
this._log("语音未通过(可重试)[" + c + "]: " + (d.message || ""));
|
||||||
this._state.failed_resolve_cid = cid;
|
this._state.failed_resolve_cid = cid;
|
||||||
this._emitState("请重试录音或检查麦克风");
|
this._emitState("请重试录音或检查麦克风");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
# 手术室监控服务:客户端手术通信接口说明
|
# 手术室监控服务:客户端手术通信接口说明
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
对接方请按部署版本核对本节;**已发布路径与字段语义以正文为准**,本节仅记录相对上一版的客户端需改动点。
|
||||||
|
|
||||||
|
### 2026-05-25
|
||||||
|
|
||||||
|
**行为对齐(实现已与正文一致)**
|
||||||
|
|
||||||
|
| 接口 | 变更 | 客户端需改动 |
|
||||||
|
| ---- | ---- | ------------ |
|
||||||
|
| `GET /client/surgeries/{surgery_id}/result` | 无明细时的 `503` **统一**返回 `detail.code = RESULT_NOT_READY`(`detail.message` 仍为人可读原因) | 勿再依赖已废弃的 `SURGERY_NOT_STARTED`、`SURGERY_STARTING`、`SURGERY_IN_PROGRESS_NO_DETAILS`、`SURGERY_ENDED_NO_CONSUMPTION` 等细分码;判断 `503` 时用 `RESULT_NOT_READY` |
|
||||||
|
| `POST .../pending-confirmation/{confirmation_id}/resolve` | ASR/解析可重试失败(如 `VOICE_ASR_FAILED`)改回 **HTTP 422**,不再返回 `200` + `status: "failed"` | 删除对 `200`/`status=failed`/`error_code` 的处理;可重试失败按 `422` + `detail.code` 重录上传 |
|
||||||
|
| `POST /client/surgeries/start` → `candidate_consumables` | 新增:可仅传**产品编码**(`label_id`,与 `consumable_classifier_labels.yaml` 一致) | 可传 `["14764-2-4"]` 或 `[{"消耗品编号":"14764-2-4"}]`;服务端解析为类名后参与推理,响应与其它字段不变 |
|
||||||
|
|
||||||
|
**未变更**
|
||||||
|
|
||||||
|
- 路由路径、HTTP 方法、`surgery_id` 约束、成功体字段名与 §5 各节描述一致。
|
||||||
|
- 可新增 `/internal/demo/...` 等内部接口,不影响本文档契约。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 能力概览
|
## 能力概览
|
||||||
|
|
||||||
| **能力** | **说明** |
|
| **能力** | **说明** |
|
||||||
@@ -165,8 +186,8 @@ flowchart LR
|
|||||||
|
|
||||||
- 服务端会为 `camera_ids` 中的每个摄像头建立拉流与推理任务,只有在确认开录成功(如首帧就绪)后才返回 HTTP `200`。
|
- 服务端会为 `camera_ids` 中的每个摄像头建立拉流与推理任务,只有在确认开录成功(如首帧就绪)后才返回 HTTP `200`。
|
||||||
|
|
||||||
- `candidate_consumables` 为空时,服务端会展开为目录中的全部耗材名。
|
- `candidate_consumables` 为空时,服务端会展开为目录中的全部耗材名。
|
||||||
|
- 非空时,每项可为**耗材名称**或**产品编码**(`label_id`);编码通过 `consumable_classifier_labels.yaml` 解析为类名。亦支持医院导出对象(见下表)。
|
||||||
|
|
||||||
**请求体(JSON)**
|
**请求体(JSON)**
|
||||||
|
|
||||||
@@ -174,7 +195,16 @@ flowchart LR
|
|||||||
| ----------------------- | ---------- | ------ | ----------------------------------- |
|
| ----------------------- | ---------- | ------ | ----------------------------------- |
|
||||||
| `surgery_id` | `string` | 是 | 6 位数字 |
|
| `surgery_id` | `string` | 是 | 6 位数字 |
|
||||||
| `camera_ids` | `string[]` | 是 | 至少 1 个;必须与运维配置的摄像头 ID 完全一致,见第 2 节 |
|
| `camera_ids` | `string[]` | 是 | 至少 1 个;必须与运维配置的摄像头 ID 完全一致,见第 2 节 |
|
||||||
| `candidate_consumables` | `string[]` | 否 | 非空时仅这些名称参与自动记账与待确认;缺省或 `[]` 时使用全部候选 |
|
| `candidate_consumables` | `string[]` 或对象数组 | 否 | 非空时仅这些名称/编码参与自动记账与待确认;缺省或 `[]` 时使用全部候选。字符串可为类名或 `label_id`;对象见下 |
|
||||||
|
|
||||||
|
**`candidate_consumables` 数组元素**
|
||||||
|
|
||||||
|
| **形式** | **示例** | **说明** |
|
||||||
|
| -------- | -------- | -------- |
|
||||||
|
| 名称字符串 | `"纱布"` | 与 yaml / 模型类名一致 |
|
||||||
|
| 编码字符串 | `"14764-2-4"` | 与 yaml 中 `label_id` 一致,服务端解析为类名 |
|
||||||
|
| 导出对象(名称) | `{"消耗品编号":"14764-2-4","名称":"一次性使用手术单"}` | 以 `名称`(或 `name`)为准 |
|
||||||
|
| 导出对象(仅编号) | `{"消耗品编号":"14764-2-4"}` | 按编号解析为类名 |
|
||||||
|
|
||||||
**响应体(200)**
|
**响应体(200)**
|
||||||
|
|
||||||
@@ -202,6 +232,16 @@ flowchart LR
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
仅传产品编码时:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"surgery_id": "123456",
|
||||||
|
"camera_ids": ["or-cam-01"],
|
||||||
|
"candidate_consumables": ["14764-2-4", "8036-5-22"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**响应示例(200)**
|
**响应示例(200)**
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -265,11 +305,11 @@ flowchart LR
|
|||||||
|
|
||||||
**业务说明**
|
**业务说明**
|
||||||
|
|
||||||
- 仅当存在至少一条消耗明细时返回 `200`。
|
- 仅当存在至少一条消耗明细时返回 `200`。
|
||||||
|
|
||||||
- 无明细(包括已归档但零消耗)、手术未开始、未成功开录或当前尚不可查时,返回 `503`。
|
- 无明细(包括已归档但零消耗)、手术未开始、未成功开录或当前尚不可查时,返回 `503`。
|
||||||
|
|
||||||
- 上述 `503` 场景的常见错误码为 `RESULT_NOT_READY`。
|
- 上述 `503` 的 `detail.code` 为 **`RESULT_NOT_READY`**;`detail.message` 说明具体原因(如未开始、进行中尚无明细、已结束无消耗等)。
|
||||||
|
|
||||||
|
|
||||||
**响应体(200)**
|
**响应体(200)**
|
||||||
@@ -306,7 +346,7 @@ flowchart LR
|
|||||||
| -------- | ------------------------------ |
|
| -------- | ------------------------------ |
|
||||||
| `200` | 至少有一条明细 |
|
| `200` | 至少有一条明细 |
|
||||||
| `422` | `surgery_id` 路径不符合约束 |
|
| `422` | `surgery_id` 路径不符合约束 |
|
||||||
| `503` | `RESULT_NOT_READY`,当前无可用明细或不可查 |
|
| `503` | `detail.code` 为 `RESULT_NOT_READY`;`message` 为具体原因 |
|
||||||
|
|
||||||
**响应示例(200)**
|
**响应示例(200)**
|
||||||
|
|
||||||
@@ -433,7 +473,7 @@ flowchart LR
|
|||||||
| `200` | 已受理并完成解析 |
|
| `200` | 已受理并完成解析 |
|
||||||
| `404` | 确认项不存在或手术未活跃;例如 `CONFIRMATION_NOT_FOUND` |
|
| `404` | 确认项不存在或手术未活跃;例如 `CONFIRMATION_NOT_FOUND` |
|
||||||
| `409` | 当前确认项已处理过;例如 `CONFIRMATION_ALREADY_RESOLVED` |
|
| `409` | 当前确认项已处理过;例如 `CONFIRMATION_ALREADY_RESOLVED` |
|
||||||
| `422` | 空文件、非 `.wav`、`VOICE_AUDIO_INVALID`、ASR/解析失败等,具体错误码见响应 |
|
| `422` | 空文件、非 `.wav`、`VOICE_AUDIO_INVALID`、**ASR/解析可重试失败**(如 `VOICE_ASR_FAILED`、`VOICE_TEXT_EMPTY`、`VOICE_PARSE_FAILED`)等,具体错误码见 `detail.code` |
|
||||||
| `503` | MinIO、百度等依赖不可用;例如 `MINIO_NOT_CONFIGURED`、`MINIO_UPLOAD_FAILED`、`BAIDU_NOT_CONFIGURED` |
|
| `503` | MinIO、百度等依赖不可用;例如 `MINIO_NOT_CONFIGURED`、`MINIO_UPLOAD_FAILED`、`BAIDU_NOT_CONFIGURED` |
|
||||||
|
|
||||||
**cURL 示例**
|
**cURL 示例**
|
||||||
|
|||||||
Reference in New Issue
Block a user