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:
Kevin
2026-05-25 09:36:09 +08:00
parent aae0340a1b
commit 0917109d6a
10 changed files with 157 additions and 83 deletions

View File

@@ -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,
)

View File

@@ -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:

View File

@@ -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_READYRECORDING_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。",

View File

@@ -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_READYmessage 保留可读原因"""
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",
"手术未开始,请先调用开始手术接口。",
)

View File

@@ -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:

View File

@@ -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 == ["纱布", "缝线"]

View File

@@ -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: