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()
|
||||
|
||||
# 上传 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:
|
||||
d: dict = {
|
||||
@@ -671,7 +668,7 @@ async def get_pending_consumable_confirmation(
|
||||
"multipart/form-data 上传单个 WAV 文件(字段名 `audio`)。"
|
||||
"服务端将音频存入 MinIO、调用百度 ASR 识别、解析候选项并完成确认。"
|
||||
"解析并确认后记一条消耗明细;若语音表示否认全部候选则不记消耗。"
|
||||
"ASR/解析可重试失败时仍返回 HTTP 200,`status`=`failed`,队首待确认项不弹出,便于桌面端重试。"
|
||||
"ASR/解析失败时返回 HTTP 422(如 VOICE_ASR_FAILED),队首待确认项不弹出,便于客户端重试。"
|
||||
),
|
||||
)
|
||||
async def resolve_pending_consumable_confirmation(
|
||||
@@ -723,21 +720,6 @@ async def resolve_pending_consumable_confirmation(
|
||||
content_type=audio.content_type,
|
||||
)
|
||||
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)
|
||||
return SurgeryPendingConfirmationResolveResponse(
|
||||
surgery_id=surgery_id,
|
||||
@@ -748,5 +730,4 @@ async def resolve_pending_consumable_confirmation(
|
||||
rejected=result.rejected,
|
||||
asr_text=result.asr_text,
|
||||
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]:
|
||||
"""将开录请求里的 ``candidate_consumables`` 转为字符串列表。
|
||||
"""将开录请求里的 ``candidate_consumables`` 转为字符串列表(名称或编号)。
|
||||
|
||||
除 ``list[str]`` 外,支持医院导出常见的 ``{\"消耗品编号\": ..., \"名称\": ...}`` 数组,
|
||||
选取 ``名称``(或 ``name``)作为耗材显示名。
|
||||
支持:
|
||||
- ``list[str]``:名称或产品编码(label_id);
|
||||
- 医院导出对象:含 ``名称`` / ``name``,或仅含 ``消耗品编号`` / ``code`` / ``label_id``。
|
||||
编号会在 ``effective_candidate_consumables`` 中按 yaml 解析为类名。
|
||||
"""
|
||||
|
||||
if not isinstance(value, list):
|
||||
@@ -32,11 +34,20 @@ def normalize_candidate_consumables_raw(value: Any) -> list[str]:
|
||||
raw_name = item.get("名称")
|
||||
if raw_name is None:
|
||||
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
|
||||
s = str(raw_name).strip()
|
||||
if s:
|
||||
out.append(s)
|
||||
raw_code = item.get("消耗品编号")
|
||||
if raw_code is None:
|
||||
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
|
||||
|
||||
|
||||
@@ -117,22 +128,58 @@ def default_labels_yaml_path() -> Path:
|
||||
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(
|
||||
requested: list[str],
|
||||
*,
|
||||
labels_yaml_path: Path | None = None,
|
||||
) -> 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] = []
|
||||
seen: set[str] = set()
|
||||
for c in requested:
|
||||
n = norm_product_name((c or "").strip())
|
||||
if not n or n in seen:
|
||||
continue
|
||||
seen.add(n)
|
||||
out.append(n)
|
||||
resolved = resolve_candidate_token(
|
||||
c,
|
||||
class_names=class_names,
|
||||
code_to_name=code_to_name,
|
||||
)
|
||||
if resolved and resolved not in seen:
|
||||
seen.add(resolved)
|
||||
out.append(resolved)
|
||||
if out:
|
||||
return out
|
||||
ypath = labels_yaml_path or default_labels_yaml_path()
|
||||
if ypath.is_file():
|
||||
ylist = list_sorted_class_names_from_yaml(ypath)
|
||||
if ylist:
|
||||
|
||||
@@ -47,7 +47,9 @@ class SurgeryStartRequest(BaseModel):
|
||||
"非空时仅对该清单内名称做自动记账与待确认追问。"
|
||||
"缺省或空数组时,使用 consumable_classifier_labels.yaml 中全部类名;"
|
||||
"无有效 yaml 则使用分类模型全部类名。"
|
||||
"每项可为字符串,或含「名称」(或 name)的对象(与耗材导出表一致)。"
|
||||
"每项可为:耗材名称字符串;产品编码(label_id,与 yaml 中 label_id 一致);"
|
||||
"或含「名称」(或 name)、「消耗品编号」(或 code / label_id)的对象(与耗材导出表一致)。"
|
||||
"仅传编号时服务端按 yaml 解析为类名。"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -86,8 +88,8 @@ class SurgeryClientErrorDetail(BaseModel):
|
||||
|
||||
code: str = Field(
|
||||
description=(
|
||||
"业务错误码,如 SURGERY_ALREADY_RECORDING、SURGERY_NOT_STARTED、"
|
||||
"SURGERY_IN_PROGRESS_NO_DETAILS、SURGERY_ENDED_NO_CONSUMPTION、RECORDING_CANNOT_START。"
|
||||
"业务错误码,如 RESULT_NOT_READY、RECORDING_CANNOT_START、"
|
||||
"NO_PENDING_CONFIRMATION、VOICE_ASR_FAILED。"
|
||||
)
|
||||
)
|
||||
message: str = Field(description="人类可读说明。")
|
||||
@@ -212,13 +214,9 @@ class SurgeryPendingConfirmationResolveResponse(BaseModel):
|
||||
surgery_id: str
|
||||
confirmation_id: str
|
||||
status: str = Field(
|
||||
description=("``accepted``:已确认或已否认并结案;``failed``:ASR/解析等可重试失败,队首待确认项未移除。"),
|
||||
description="成功时为 ``accepted``。",
|
||||
)
|
||||
message: str
|
||||
error_code: str | None = Field(
|
||||
default=None,
|
||||
description="仅 status=failed 时与错误码一致(如 VOICE_ASR_FAILED)。",
|
||||
)
|
||||
resolved_label: str | None = Field(
|
||||
default=None,
|
||||
description="解析并确认后的耗材名称;否认全部候选时为 null。",
|
||||
|
||||
@@ -102,16 +102,16 @@ class SurgeryPipeline:
|
||||
self._sessions.set_voice_terminal_id(surgery_id, terminal_id)
|
||||
|
||||
async def classify_result_unavailable(self, surgery_id: str) -> tuple[str, str]:
|
||||
"""无至少一条消耗明细时,区分未开始 / 进行中无结果 / 已结束无消耗等。"""
|
||||
"""无至少一条消耗明细时返回 RESULT_NOT_READY;message 保留可读原因。"""
|
||||
phase = self._sessions.active_recording_phase(surgery_id)
|
||||
if phase == "starting":
|
||||
return (
|
||||
"SURGERY_STARTING",
|
||||
"RESULT_NOT_READY",
|
||||
"手术正在启动,算法尚未就绪,请稍后再查。",
|
||||
)
|
||||
if phase == "recording":
|
||||
return (
|
||||
"SURGERY_IN_PROGRESS_NO_DETAILS",
|
||||
"RESULT_NOT_READY",
|
||||
"手术进行中,尚无至少一条消耗明细。",
|
||||
)
|
||||
async with self._session_factory() as session:
|
||||
@@ -119,17 +119,17 @@ class SurgeryPipeline:
|
||||
persisted = await self._repo.load_final_details(session, surgery_id)
|
||||
if persisted is not None:
|
||||
return (
|
||||
"SURGERY_ENDED_NO_CONSUMPTION",
|
||||
"RESULT_NOT_READY",
|
||||
"手术已结束,当前无消耗明细。",
|
||||
)
|
||||
archived = await self._sessions.archived_consumption_fallback(surgery_id)
|
||||
if archived is not None:
|
||||
return (
|
||||
"SURGERY_ENDED_NO_CONSUMPTION",
|
||||
"RESULT_NOT_READY",
|
||||
"手术已结束(尚未落库),当前无消耗明细。",
|
||||
)
|
||||
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.get_consumption_details_for_client = AsyncMock(return_value=None)
|
||||
pipeline.classify_result_unavailable = AsyncMock(
|
||||
return_value=("SURGERY_NOT_STARTED", "手术未开始,请先调用开始手术接口。")
|
||||
return_value=("RESULT_NOT_READY", "手术未开始,请先调用开始手术接口。")
|
||||
)
|
||||
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
|
||||
client = TestClient(api_app)
|
||||
r = client.get("/client/surgeries/123456/result")
|
||||
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:
|
||||
pipeline = MagicMock()
|
||||
pipeline.get_consumption_details_for_client = AsyncMock(return_value=[])
|
||||
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
|
||||
client = TestClient(api_app)
|
||||
r = client.get("/client/surgeries/123456/result")
|
||||
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:
|
||||
@@ -403,13 +403,12 @@ def test_resolve_200(api_app: FastAPI) -> None:
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "accepted"
|
||||
assert body["error_code"] is None
|
||||
assert body["resolved_label"] == "纱布"
|
||||
assert body["rejected"] is False
|
||||
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.resolve_pending_confirmation_from_audio = AsyncMock(
|
||||
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",
|
||||
files={"audio": ("a.wav", b"RIFF", "audio/wav")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.status_code == 422
|
||||
body = r.json()
|
||||
assert body["status"] == "failed"
|
||||
assert body["error_code"] == "VOICE_ASR_FAILED"
|
||||
assert "3301" in body["message"]
|
||||
assert body["detail"]["code"] == "VOICE_ASR_FAILED"
|
||||
assert "3301" in body["detail"]["message"]
|
||||
|
||||
|
||||
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": "缝线"},
|
||||
]
|
||||
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("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:
|
||||
got = effective_candidate_consumables([" 纱布 ", "缝线", "纱布"])
|
||||
assert got == ["纱布", "缝线"]
|
||||
|
||||
@@ -20,7 +20,7 @@ async def test_classify_not_started() -> None:
|
||||
repo.load_final_details = AsyncMock(return_value=None)
|
||||
pipeline = SurgeryPipeline(sessions, result_repository=repo, voice_confirmation=MagicMock())
|
||||
code, _ = await pipeline.classify_result_unavailable("123456")
|
||||
assert code == "SURGERY_NOT_STARTED"
|
||||
assert code == "RESULT_NOT_READY"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -29,7 +29,7 @@ async def test_classify_in_progress_no_details() -> None:
|
||||
sessions.active_recording_phase = MagicMock(return_value="recording")
|
||||
pipeline = SurgeryPipeline(sessions, result_repository=MagicMock(), voice_confirmation=MagicMock())
|
||||
code, _ = await pipeline.classify_result_unavailable("123456")
|
||||
assert code == "SURGERY_IN_PROGRESS_NO_DETAILS"
|
||||
assert code == "RESULT_NOT_READY"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -38,7 +38,7 @@ async def test_classify_starting() -> None:
|
||||
sessions.active_recording_phase = MagicMock(return_value="starting")
|
||||
pipeline = SurgeryPipeline(sessions, result_repository=MagicMock(), voice_confirmation=MagicMock())
|
||||
code, _ = await pipeline.classify_result_unavailable("123456")
|
||||
assert code == "SURGERY_STARTING"
|
||||
assert code == "RESULT_NOT_READY"
|
||||
|
||||
|
||||
def test_active_recording_phase() -> None:
|
||||
|
||||
Reference in New Issue
Block a user