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:
BIN
tests/faces/图片_20260423151100_350_42.png
Normal file
BIN
tests/faces/图片_20260423151100_350_42.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 451 KiB |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user