feat: align surgery API with schemas and extend client tooling

- Refactor app API and schemas; adjust surgery pipeline, repository, and session manager.

- Improve consumption TSV logging and consumable vision integration; trim voice resolution.

- Add Baidu Face 1:N search script, .env.example entries, and client API integration doc.

- Update demo client, staging checklist, surgery interface doc, and related tests; add sample face image.

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-23 16:09:20 +08:00
parent 0c05463617
commit 69980d8073
20 changed files with 994 additions and 610 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

View File

@@ -115,10 +115,9 @@ def test_get_result_200(api_app: FastAPI) -> None:
SurgeryConsumptionDetail(
item_id="纱布",
item_name="纱布",
quantity=1,
qty=1,
doctor_id="vision",
timestamp=ts,
source="vision",
),
]
)
@@ -129,6 +128,13 @@ def test_get_result_200(api_app: FastAPI) -> None:
body = r.json()
assert body["surgery_id"] == "123456"
assert len(body["details"]) == 1
assert list(body["details"][0].keys()) == [
"item_id",
"item_name",
"qty",
"doctor_id",
"timestamp",
]
assert body["summary"][0]["total_quantity"] == 1
@@ -148,21 +154,24 @@ def test_pending_confirmation_200_and_404(api_app: FastAPI) -> None:
surgery_id="123456",
confirmation_id="cid",
prompt_text="请确认",
prompt_audio_mp3_base64="//uQ",
options=[],
model_top1_label="x",
model_top1_confidence=0.4,
created_at=ts,
)
pipeline_ok = MagicMock()
pipeline_ok.get_pending_confirmation_for_client = MagicMock(return_value=payload)
pipeline_ok.get_pending_confirmation_for_client = AsyncMock(return_value=payload)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline_ok
client = TestClient(api_app)
r = client.get("/client/surgeries/123456/pending-confirmation")
assert r.status_code == 200
assert r.json()["confirmation_id"] == "cid"
body_ok = r.json()
assert body_ok["confirmation_id"] == "cid"
assert body_ok["prompt_audio_mp3_base64"] == "//uQ"
pipeline_none = MagicMock()
pipeline_none.get_pending_confirmation_for_client = MagicMock(return_value=None)
pipeline_none.get_pending_confirmation_for_client = AsyncMock(return_value=None)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline_none
client2 = TestClient(api_app)
r2 = client2.get("/client/surgeries/123456/pending-confirmation")
@@ -193,60 +202,6 @@ def test_resolve_non_wav_422(api_app: FastAPI) -> None:
assert r.status_code == 422
def test_prompt_audio_200(api_app: FastAPI) -> None:
pipeline = MagicMock()
pipeline.get_pending_prompt_audio_mp3 = AsyncMock(return_value=b"\xff\xfb\x90")
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.get("/client/surgeries/123456/pending-confirmation/cid1/prompt-audio")
assert r.status_code == 200
assert r.content == b"\xff\xfb\x90"
assert "mpeg" in (r.headers.get("content-type") or "")
pipeline.get_pending_prompt_audio_mp3.assert_awaited_once_with(
surgery_id="123456",
confirmation_id="cid1",
)
def test_resolve_text_200(api_app: FastAPI) -> None:
pipeline = MagicMock()
pipeline.resolve_pending_confirmation_from_client_text = AsyncMock(
return_value=VoiceResolveResult(
resolved_label="纱布",
rejected=False,
asr_text="第一个",
audio_object_key=None,
message="ok",
)
)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.post(
"/client/surgeries/123456/pending-confirmation/cid/resolve-text",
json={"recognized_text": "第一个"},
)
assert r.status_code == 200
body = r.json()
assert body["resolved_label"] == "纱布"
assert body["asr_text"] == "第一个"
pipeline.resolve_pending_confirmation_from_client_text.assert_awaited_once()
def test_resolve_text_maps_surgery_error(api_app: FastAPI) -> None:
pipeline = MagicMock()
pipeline.resolve_pending_confirmation_from_client_text = AsyncMock(
side_effect=SurgeryPipelineError("VOICE_PARSE_FAILED", "无法匹配")
)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.post(
"/client/surgeries/123456/pending-confirmation/cid/resolve-text",
json={"recognized_text": "随便说说"},
)
assert r.status_code == 422
assert r.json()["detail"]["code"] == "VOICE_PARSE_FAILED"
def test_resolve_200(api_app: FastAPI) -> None:
pipeline = MagicMock()
pipeline.resolve_pending_confirmation_from_audio = AsyncMock(
@@ -284,47 +239,3 @@ def test_resolve_maps_surgery_pipeline_error_to_http(api_app: FastAPI) -> None:
)
assert r.status_code == 404
assert r.json()["detail"]["code"] == "CONFIRMATION_NOT_FOUND"
def test_internal_voice_status_404_and_200(api_app: FastAPI) -> None:
p_none = MagicMock()
p_none.voice_status = MagicMock(return_value=None)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: p_none
client = TestClient(api_app)
r = client.get("/internal/surgeries/123456/voice-status")
assert r.status_code == 404
p_ok = MagicMock()
p_ok.voice_status = MagicMock(
return_value={
"voice_enabled": True,
"pending_queue_approx": 2,
"last_prompt_snippet": "hi",
"last_asr_text": "纱布",
"last_error": None,
}
)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: p_ok
client2 = TestClient(api_app)
r2 = client2.get("/internal/surgeries/123456/voice-status")
assert r2.status_code == 200
assert r2.json()["pending_queue_approx"] == 2
def test_internal_voice_audits_200_empty(api_app: FastAPI) -> None:
pipeline = MagicMock()
pipeline.list_voice_audits = AsyncMock(return_value=([], 0))
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.get(
"/internal/surgeries/123456/voice-audits",
params={"limit": 1, "offset": 0},
)
assert r.status_code == 200
j = r.json()
assert j["surgery_id"] == "123456"
assert j["total"] == 0
assert j["limit"] == 1
assert j["offset"] == 0
assert j["items"] == []
pipeline.list_voice_audits.assert_awaited_once_with("123456", limit=1, offset=0)

View File

@@ -6,11 +6,14 @@ from app.config import settings
from app.services.consumable_vision_algorithm import ClsTop3
from app.services.consumption_tsv_log import (
HEADER,
SUMMARY_HEADER,
_RANGE_SEP,
append_consumption_log_summary,
append_consumption_tsv_line,
build_consumption_markdown,
build_tsv_line,
init_consumption_log_file,
resolve_consumption_item_id,
short_camera_label,
)
@@ -44,25 +47,27 @@ def test_build_tsv_line_matches_sample_shape(monkeypatch: pytest.MonkeyPatch) ->
wall_end_epoch=w0 + 45.0,
)
parts = line.rstrip("\n").split("\t")
assert len(parts) == 7
assert len(parts) == 5
assert parts[0] == "2237844"
assert parts[1] == "一次性医用灭菌棉签 0.9997"
assert parts[2] == "cls2"
assert parts[3] == "cls3"
assert parts[4] == "1"
assert parts[5] == "DOCTOR_PLACEHOLDER"
assert parts[1] == "一次性医用灭菌棉签"
assert parts[2] == "1"
assert parts[3] == "DOCTOR_PLACEHOLDER"
assert (
parts[6]
parts[4]
== "cam01@2024-01-01T00:00:00.000+00:00"
+ _RANGE_SEP
+ "2024-01-01T00:00:45.000+00:00"
)
def test_resolve_consumption_item_id_uses_normalized_catalog_key() -> None:
name_to_code = {"一次性使用手术单(一次性医用垫单)": "PID-900"}
assert resolve_consumption_item_id("一次性医用垫单", "", name_to_code) == "PID-900"
def test_header_columns() -> None:
cols = HEADER.strip().split("\t")
assert cols[0] == "物品id"
assert cols[-1] == "时间戳"
assert cols == ["item_id", "item_name", "qty", "doctor_id", "timestamp"]
def test_per_surgery_file_init_and_append(
@@ -84,6 +89,31 @@ def test_per_surgery_file_init_and_append(
assert p.read_text(encoding="utf-8") == HEADER
def test_append_consumption_log_summary_appends_three_column_block(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "consumption_tsv_log_enabled", True)
monkeypatch.setattr(
settings,
"consumption_tsv_log_path",
str(tmp_path / "{surgery_id}.txt"),
)
init_consumption_log_file("s1")
append_consumption_tsv_line("s1", "x\n")
append_consumption_log_summary(
"s1",
{"A": ("nA", 2), "B": ("nB", 1)},
)
text = (tmp_path / "s1.txt").read_text(encoding="utf-8")
assert text.endswith(
"\n"
+ SUMMARY_HEADER
+ "A\tnA\t2\n"
+ "B\tnB\t1\n"
)
def test_build_consumption_markdown_top123_columns(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "consumption_log_timezone", "UTC")
best = ClsTop3(
@@ -106,15 +136,9 @@ def test_build_consumption_markdown_top123_columns(monkeypatch: pytest.MonkeyPat
wall_start_epoch=w0,
wall_end_epoch=w0 + 45.0,
)
assert "Top1 物品id" in md and "Top1 物品名称" in md and "Top1 置信度" in md
assert "Top2 物品名称" in md and "Top3 物品名称" in md
assert "Top2 物品id" not in md
assert "| item_id |" in md and "| item_name |" in md and "| qty |" in md
assert "2237844" in md
assert "一次性医用灭菌棉签" in md
assert "0.9997" in md
assert "cls2" in md and "cls3" in md
assert "11765-1-101" not in md and "21504-1-1" not in md
assert "0.0003" not in md and "0.0002" not in md
assert "DOCTOR_PLACEHOLDER" in md
assert "| 1 |" in md
# 终端为可读时间戳,非落盘用 ISO@cam

View File

@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import Settings
from app.repositories.surgery_results import SurgeryResultRepository
from app.schemas import SurgeryConsumptionDetail
from app.schemas import SurgeryConsumptionStored
from app.services.surgery_pipeline import SurgeryPipeline
from app.services.video.session_manager import (
ArchivedSurgery,
@@ -53,10 +53,10 @@ async def test_stop_surgery_persists_final_result(
ts = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc)
st = SurgerySessionState(candidate_consumables=["纱布"])
st.details.append(
SurgeryConsumptionDetail(
SurgeryConsumptionStored(
item_id="纱布",
item_name="纱布",
quantity=1,
qty=1,
doctor_id="vision",
timestamp=ts,
source="vision",
@@ -107,10 +107,10 @@ async def test_stop_surgery_failed_persist_goes_to_archive_then_retry_persists(
ts = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc)
st = SurgerySessionState(candidate_consumables=[])
st.details.append(
SurgeryConsumptionDetail(
SurgeryConsumptionStored(
item_id="缝线",
item_name="缝线",
quantity=1,
qty=1,
doctor_id="vision",
timestamp=ts,
source="vision",
@@ -161,10 +161,10 @@ async def test_pipeline_prefers_live_then_db_then_archive(
ts = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc)
st = SurgerySessionState(candidate_consumables=["纱布"])
st.details.append(
SurgeryConsumptionDetail(
SurgeryConsumptionStored(
item_id="纱布",
item_name="纱布",
quantity=1,
qty=1,
doctor_id="vision",
timestamp=ts,
source="vision",
@@ -188,10 +188,10 @@ async def test_pipeline_prefers_live_then_db_then_archive(
mgr._archive["333333"] = ArchivedSurgery(
details=[
SurgeryConsumptionDetail(
SurgeryConsumptionStored(
item_id="归档项",
item_name="归档项",
quantity=1,
qty=1,
doctor_id="vision",
timestamp=ts,
source="vision",

View File

@@ -3,12 +3,14 @@ from __future__ import annotations
from datetime import datetime, timezone
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
import app.db.models # noqa: F401
from app.db.base import Base
from app.db.models import SurgeryResultDetailRow
from app.repositories.surgery_results import SurgeryResultRepository
from app.schemas import SurgeryConsumptionDetail
from app.schemas import SurgeryConsumptionStored
@pytest.fixture
@@ -38,18 +40,18 @@ async def test_save_roundtrip(db_session: AsyncSession) -> None:
repo = SurgeryResultRepository()
ts = datetime(2026, 4, 21, 10, 0, tzinfo=timezone.utc)
details = [
SurgeryConsumptionDetail(
SurgeryConsumptionStored(
item_id="纱布",
item_name="纱布",
quantity=1,
qty=1,
doctor_id="D1",
timestamp=ts,
source="vision",
),
SurgeryConsumptionDetail(
SurgeryConsumptionStored(
item_id="纱布",
item_name="纱布",
quantity=1,
qty=1,
doctor_id="voice",
timestamp=ts,
source="voice",
@@ -61,8 +63,17 @@ async def test_save_roundtrip(db_session: AsyncSession) -> None:
loaded = await repo.load_final_details(db_session, "654321")
assert loaded is not None
assert len(loaded) == 2
assert loaded[0].source == "vision"
assert loaded[1].source == "voice"
assert loaded[0].qty == 1 and loaded[0].item_id == "纱布"
assert loaded[1].qty == 1
async with db_session.begin():
res = await db_session.execute(
select(SurgeryResultDetailRow)
.where(SurgeryResultDetailRow.surgery_id == "654321")
.order_by(SurgeryResultDetailRow.id)
)
orm_rows = res.scalars().all()
assert orm_rows[0].source == "vision"
assert orm_rows[1].source == "voice"
@pytest.mark.asyncio
@@ -83,10 +94,10 @@ async def test_save_overwrites_previous_final_result(db_session: AsyncSession) -
db_session,
surgery_id="888888",
details=[
SurgeryConsumptionDetail(
SurgeryConsumptionStored(
item_id="",
item_name="",
quantity=1,
qty=1,
doctor_id="D1",
timestamp=ts1,
source="vision",
@@ -98,10 +109,10 @@ async def test_save_overwrites_previous_final_result(db_session: AsyncSession) -
db_session,
surgery_id="888888",
details=[
SurgeryConsumptionDetail(
SurgeryConsumptionStored(
item_id="",
item_name="",
quantity=2,
qty=2,
doctor_id="D2",
timestamp=ts2,
source="voice",
@@ -113,5 +124,4 @@ async def test_save_overwrites_previous_final_result(db_session: AsyncSession) -
assert loaded is not None
assert len(loaded) == 1
assert loaded[0].item_id == ""
assert loaded[0].quantity == 2
assert loaded[0].source == "voice"
assert loaded[0].qty == 2