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

View File

@@ -12,7 +12,7 @@ from loguru import logger
from app.config import Settings
from app.database import AsyncSessionLocal
from app.repositories.surgery_results import SurgeryResultRepository
from app.schemas import SurgeryConsumptionDetail
from app.schemas import SurgeryConsumptionDetail, SurgeryConsumptionStored
from app.services.consumable_vision_algorithm import (
ClsTop3,
ConsumableVisionAlgorithmService,
@@ -26,7 +26,12 @@ from app.services.video.backend_resolver import BackendResolver
from app.services.video.hikvision_runtime import HikvisionInitRefCount, HikvisionRuntime
from app.services.video.rtsp_capture import RtspCapture
from app.services.video.types import VideoBackendKind
from app.services.consumption_tsv_log import append_consumption_window, init_consumption_log_file
from app.services.consumption_tsv_log import (
append_consumption_log_summary,
append_consumption_window,
init_consumption_log_file,
print_consumption_summary_markdown,
)
from app.services.voice_file_log import init_voice_log_file
from app.services.voice_confirm import build_prompt_text
from app.surgery_errors import SurgeryPipelineError
@@ -64,7 +69,7 @@ class SurgerySessionState:
#: 分类类名(归一化) -> 业务物品 idExcel 产品编码或名称)。
name_to_code: dict[str, str] = field(default_factory=dict)
camera_infer: dict[str, CameraStreamInferState] = field(default_factory=dict)
details: list[SurgeryConsumptionDetail] = field(default_factory=list)
details: list[SurgeryConsumptionStored] = field(default_factory=list)
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
ready: asyncio.Event = field(default_factory=asyncio.Event)
last_detail_monotonic: dict[str, float] = field(default_factory=dict)
@@ -76,6 +81,8 @@ class SurgerySessionState:
last_asr_text: str | None = None
#: 最近一次语音确认错误说明ASR/解析失败等)。
last_voice_error: str | None = None
#: 视觉时间窗落盘用量累计供停录时写汇总item_id -> 首次名称, 次数)。
consumption_log_totals: dict[str, tuple[str, int]] = field(default_factory=dict)
@dataclass
@@ -87,7 +94,7 @@ class RunningSurgery:
@dataclass
class ArchivedSurgery:
details: list[SurgeryConsumptionDetail]
details: list[SurgeryConsumptionStored]
def _rank_topk_for_candidates(
@@ -293,7 +300,7 @@ class CameraSessionManager:
async def _persist_archived_details(
self,
surgery_id: str,
details: list[SurgeryConsumptionDetail],
details: list[SurgeryConsumptionStored],
) -> bool:
if self._repo is None:
return True
@@ -331,6 +338,10 @@ class CameraSessionManager:
if isinstance(res, BaseException):
logger.warning("surgery task finished with error: {}", res)
totals = dict(run.state.consumption_log_totals)
append_consumption_log_summary(surgery_id, totals)
print_consumption_summary_markdown(totals)
details = list(run.state.details)
persisted = False
@@ -364,26 +375,13 @@ class CameraSessionManager:
rows = list(self._active[surgery_id].state.details)
if not rows:
return None
return rows
return [r.as_response() for r in rows]
def archived_consumption_fallback(self, surgery_id: str) -> list[SurgeryConsumptionDetail] | None:
arch = self._archive.get(surgery_id)
if arch is None:
return None
return list(arch.details)
def voice_status(self, surgery_id: str) -> dict[str, object] | None:
if surgery_id not in self._active:
return None
st = self._active[surgery_id].state
return {
"surgery_id": surgery_id,
"voice_enabled": bool(self._s.voice_confirmation_enabled),
"pending_queue_approx": len(st.pending_fifo),
"last_prompt_snippet": st.last_pending_prompt_snippet,
"last_asr_text": st.last_asr_text,
"last_error": st.last_voice_error,
}
return [r.as_response() for r in arch.details]
def record_voice_trace(
self,
@@ -525,10 +523,10 @@ class CameraSessionManager:
return
state.last_detail_monotonic[item_id] = now_m
state.details.append(
SurgeryConsumptionDetail(
SurgeryConsumptionStored(
item_id=item_id,
item_name=item_name,
quantity=1,
qty=1,
doctor_id=doctor_id,
timestamp=datetime.now(timezone.utc),
source=source,
@@ -698,6 +696,7 @@ class CameraSessionManager:
camera_id=camera_id,
wall_start_epoch=wall_lo,
wall_end_epoch=wall_hi,
running_totals=state.consumption_log_totals,
)
pending_preds.append(
cls_top3_to_prediction_result(best)