feat: consumption log top1 + elapsed since recording; RTSP play once

- Add top1_name/top1_conf to TSV and show top1–3 in pending markdown
- Add 相对开录 column and pass since_recording_start from surgery start
- Track surgery_started_wall and format_elapsed_mmss_since in session registry
- Remove ffmpeg stream_loop from synthetic/demo fake RTSP (play once)
- Fix fake_rtsp_from_file poll loop indentation; update README
- Extend consumption TSV tests; add face test PNGs under tests/faces

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-27 09:22:46 +08:00
parent 8a6bfe9100
commit e4c6127619
13 changed files with 130 additions and 40 deletions

View File

@@ -1,4 +1,7 @@
"""每例手术一个文本文件(制表符列):`start_surgery` 时截断并写表头,每次时间窗识别**追加**一行(仅 item_id, item_name, qty, doctor_id, timestamp。待确认行首列为 ``pending:{confirmation_id}``,语音落锤后**整行替换**为与客户端一致的最终真值,不再重复追加。终端 Markdown 时间戳为可读形式;落盘时间戳为 ISO 区间便于程序解析。 """每例手术一个文本文件(制表符列):`start_surgery` 时截断并写表头,每次时间窗识别**追加**一行
item_id, item_name, qty, doctor_id, timestamp, top13 名与置信度)。待确认行首列为 ``pending:{confirmation_id}``、
item_name 为「待确认」top13 仍为模型输出;语音落锤后**整行替换**为与客户端一致的最终真值,不再重复追加。
终端 Markdown 时间戳为可读形式;落盘时间戳为 ISO 区间便于程序解析。
手术结束时再追加一节汇总行item_id, item_name, qty无其它列与 HTTP ``summary`` 同算法,由内存 ``details`` 经 ``build_consumption_summary`` 得到,非录制过程中按窗累计。 手术结束时再追加一节汇总行item_id, item_name, qty无其它列与 HTTP ``summary`` 同算法,由内存 ``details`` 经 ``build_consumption_summary`` 得到,非录制过程中按窗累计。
@@ -20,10 +23,11 @@ from app.services.consumable_vision_algorithm import ClsTop3, _norm_product_name
from app.terminal_markdown import print_markdown_stderr from app.terminal_markdown import print_markdown_stderr
# 制表符分隔;时间范围用 U+2013 连接;本窗消耗数量恒为 1。 # 制表符分隔;时间范围用 U+2013 连接;本窗消耗数量恒为 1。
# top2/top3 为模型原始排序未按手术候选重排item_id 只写与展示名不同的业务 idlabel_id与名称相同时留空 # top1/2/3 为模型原始排序(未按手术候选重排);确认行 item_name 与 top1_name 同为 Top1 标签
# item_id 只写与展示名不同的业务 idlabel_id与名称相同时留空。
HEADER = ( HEADER = (
"item_id\titem_name\tqty\tdoctor_id\ttimestamp\t" "item_id\titem_name\tqty\tdoctor_id\ttimestamp\t"
"top2_name\ttop2_conf\ttop3_name\ttop3_conf\n" "top1_name\ttop1_conf\ttop2_name\ttop2_conf\ttop3_name\ttop3_conf\n"
) )
SUMMARY_HEADER = "item_id\titem_name\tqty\n" SUMMARY_HEADER = "item_id\titem_name\tqty\n"
_RANGE_SEP = "\u2013" # en dash与样例 `00:00:00.00000:00:45.000` 一致 _RANGE_SEP = "\u2013" # en dash与样例 `00:00:00.00000:00:45.000` 一致
@@ -151,6 +155,8 @@ def build_tsv_line(
"1", "1",
_encode_cell(doctor_id), _encode_cell(doctor_id),
_encode_cell(ts), _encode_cell(ts),
_encode_cell(name1),
_fmt_top_conf(best.t1_conf),
_encode_cell(n2), _encode_cell(n2),
_fmt_top_conf(best.t2_conf), _fmt_top_conf(best.t2_conf),
_encode_cell(n3), _encode_cell(n3),
@@ -217,6 +223,7 @@ def build_consumption_markdown(
camera_id: str, camera_id: str,
wall_start_epoch: float, wall_start_epoch: float,
wall_end_epoch: float, wall_end_epoch: float,
since_recording_start: str | None = None,
) -> str: ) -> str:
"""终端用:与落盘列一致;本窗 qty 恒为 1。""" """终端用:与落盘列一致;本窗 qty 恒为 1。"""
tsv_id, _ = resolve_consumption_ids(best.t1_name, best.t1_pid, name_to_code) tsv_id, _ = resolve_consumption_ids(best.t1_name, best.t1_pid, name_to_code)
@@ -224,14 +231,16 @@ def build_consumption_markdown(
n2 = (best.t2_name or "").strip() n2 = (best.t2_name or "").strip()
n3 = (best.t3_name or "").strip() n3 = (best.t3_name or "").strip()
ts = format_consumption_timestamp_readable(camera_id, wall_start_epoch, wall_end_epoch) ts = format_consumption_timestamp_readable(camera_id, wall_start_epoch, wall_end_epoch)
rel = _md_cell(since_recording_start or "")
return "\n".join( return "\n".join(
[ [
"| item_id | item_name | qty | doctor_id | timestamp | top2 | top3 |", "| item_id | item_name | qty | doctor_id | 相对开录 | timestamp | top2 | top3 |",
"| :--- | :--- | ---: | :--- | :--- | :--- | :--- |", "| :--- | :--- | ---: | :--- | :--- | :--- | :--- | :--- |",
"| {} | {} | 1 | {} | {} | {} | {} |".format( "| {} | {} | 1 | {} | {} | {} | {} | {} |".format(
_md_cell(tsv_id), _md_cell(tsv_id),
_md_cell(n1), _md_cell(n1),
_md_cell(doctor_id), _md_cell(doctor_id),
rel,
_md_cell(ts), _md_cell(ts),
_md_cell( _md_cell(
f"{n2} ({_fmt_top_conf(best.t2_conf)})" if n2 else "", f"{n2} ({_fmt_top_conf(best.t2_conf)})" if n2 else "",
@@ -259,6 +268,7 @@ def _build_pending_tsv_line(
) -> str: ) -> str:
pid = f"pending:{confirmation_id}" pid = f"pending:{confirmation_id}"
ts = format_consumption_timestamp(camera_id, wall_start_epoch, wall_end_epoch) ts = format_consumption_timestamp(camera_id, wall_start_epoch, wall_end_epoch)
n1 = (model_snap.t1_name or "").strip()
n2 = (model_snap.t2_name or "").strip() n2 = (model_snap.t2_name or "").strip()
n3 = (model_snap.t3_name or "").strip() n3 = (model_snap.t3_name or "").strip()
row = [ row = [
@@ -267,6 +277,8 @@ def _build_pending_tsv_line(
"1", "1",
_encode_cell(doctor_id), _encode_cell(doctor_id),
_encode_cell(ts), _encode_cell(ts),
_encode_cell(n1),
_fmt_top_conf(model_snap.t1_conf),
_encode_cell(n2), _encode_cell(n2),
_fmt_top_conf(model_snap.t2_conf), _fmt_top_conf(model_snap.t2_conf),
_encode_cell(n3), _encode_cell(n3),
@@ -283,26 +295,31 @@ def build_pending_consumption_markdown(
camera_id: str, camera_id: str,
wall_start_epoch: float, wall_start_epoch: float,
wall_end_epoch: float, wall_end_epoch: float,
since_recording_start: str | None = None,
) -> str: ) -> str:
pid = f"pending:{confirmation_id}" pid = f"pending:{confirmation_id}"
n1 = (model_snap.t1_name or "").strip()
n2 = (model_snap.t2_name or "").strip() n2 = (model_snap.t2_name or "").strip()
n3 = (model_snap.t3_name or "").strip() n3 = (model_snap.t3_name or "").strip()
ts = format_consumption_timestamp_readable(camera_id, wall_start_epoch, wall_end_epoch) ts = format_consumption_timestamp_readable(camera_id, wall_start_epoch, wall_end_epoch)
rel = _md_cell(since_recording_start or "")
def _top_cell(name: str, conf: float) -> str:
return _md_cell(f"{name} ({_fmt_top_conf(conf)})" if name else "")
return "\n".join( return "\n".join(
[ [
"| item_id | item_name | qty | doctor_id | timestamp | top2 | top3 |", "| item_id | item_name | qty | doctor_id | 相对开录 | timestamp | top1 | top2 | top3 |",
"| :--- | :--- | ---: | :--- | :--- | :--- | :--- |", "| :--- | :--- | ---: | :--- | :--- | :--- | :--- | :--- | :--- |",
"| {} | {} | 1 | {} | {} | {} | {} |".format( "| {} | {} | 1 | {} | {} | {} | {} | {} | {} |".format(
_md_cell(pid), _md_cell(pid),
_md_cell(PENDING_CONSUMPTION_ITEM_NAME), _md_cell(PENDING_CONSUMPTION_ITEM_NAME),
_md_cell(doctor_id), _md_cell(doctor_id),
rel,
_md_cell(ts), _md_cell(ts),
_md_cell( _top_cell(n1, model_snap.t1_conf),
f"{n2} ({_fmt_top_conf(model_snap.t2_conf)})" if n2 else "", _top_cell(n2, model_snap.t2_conf),
), _top_cell(n3, model_snap.t3_conf),
_md_cell(
f"{n3} ({_fmt_top_conf(model_snap.t3_conf)})" if n3 else "",
),
), ),
"", "",
] ]
@@ -320,8 +337,9 @@ def append_consumption_pending_window(
wall_end_epoch: float, wall_end_epoch: float,
tsv_enabled: bool | None = None, tsv_enabled: bool | None = None,
markdown_terminal: bool | None = None, markdown_terminal: bool | None = None,
since_recording_start: str | None = None,
) -> None: ) -> None:
"""需医生确认的时间窗:落盘/终端记「待确认」top2/3 保留模型提示;不更新消耗汇总。""" """需医生确认的时间窗:落盘/终端记「待确认」top1/2/3 保留模型提示;不更新消耗汇总。"""
en_tsv = bp.CONSUMPTION_TSV_LOG_ENABLED if tsv_enabled is None else tsv_enabled en_tsv = bp.CONSUMPTION_TSV_LOG_ENABLED if tsv_enabled is None else tsv_enabled
en_md = ( en_md = (
bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL
@@ -349,6 +367,7 @@ def append_consumption_pending_window(
camera_id=camera_id, camera_id=camera_id,
wall_start_epoch=wall_start_epoch, wall_start_epoch=wall_start_epoch,
wall_end_epoch=wall_end_epoch, wall_end_epoch=wall_end_epoch,
since_recording_start=since_recording_start,
), ),
) )
@@ -549,6 +568,7 @@ class ConsumptionTsvWriter:
camera_id: str, camera_id: str,
wall_start_epoch: float, wall_start_epoch: float,
wall_end_epoch: float, wall_end_epoch: float,
since_recording_start: str | None = None,
) -> None: ) -> None:
if not bp.CONSUMPTION_TSV_LOG_ENABLED and not bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL: if not bp.CONSUMPTION_TSV_LOG_ENABLED and not bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL:
return return
@@ -571,6 +591,7 @@ class ConsumptionTsvWriter:
camera_id=camera_id, camera_id=camera_id,
wall_start_epoch=wall_start_epoch, wall_start_epoch=wall_start_epoch,
wall_end_epoch=wall_end_epoch, wall_end_epoch=wall_end_epoch,
since_recording_start=since_recording_start,
), ),
) )
@@ -621,6 +642,7 @@ def append_consumption_window(
camera_id: str, camera_id: str,
wall_start_epoch: float, wall_start_epoch: float,
wall_end_epoch: float, wall_end_epoch: float,
since_recording_start: str | None = None,
) -> None: ) -> None:
if not bp.CONSUMPTION_TSV_LOG_ENABLED and not bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL: if not bp.CONSUMPTION_TSV_LOG_ENABLED and not bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL:
return return
@@ -643,5 +665,6 @@ def append_consumption_window(
camera_id=camera_id, camera_id=camera_id,
wall_start_epoch=wall_start_epoch, wall_start_epoch=wall_start_epoch,
wall_end_epoch=wall_end_epoch, wall_end_epoch=wall_end_epoch,
since_recording_start=since_recording_start,
), ),
) )

View File

@@ -1,4 +1,8 @@
"""Start/stop local fake RTSP streams (MediaMTX + ffmpeg) for dev orchestration.""" """Start/stop local fake RTSP streams (MediaMTX + ffmpeg) for dev orchestration.
Each input file is published once (no ``-stream_loop``); when ffmpeg exits the
process is gone — reconnect or re-orchestrate for another playthrough.
"""
from __future__ import annotations from __future__ import annotations
@@ -203,7 +207,7 @@ class SyntheticRtspManager:
url_map[s.camera_id] = dest url_map[s.camera_id] = dest
pub = [ pub = [
"ffmpeg", "-hide_banner", "-loglevel", "warning", "ffmpeg", "-hide_banner", "-loglevel", "warning",
"-re", "-stream_loop", "-1", "-re",
"-i", str(s.file_path), "-i", str(s.file_path),
"-c", "copy", "-f", "rtsp", "-rtsp_transport", "tcp", dest, "-c", "copy", "-f", "rtsp", "-rtsp_transport", "tcp", dest,
] ]

View File

@@ -12,7 +12,7 @@
from __future__ import annotations from __future__ import annotations
from loguru import logger import time
from app.baked import pipeline as bp from app.baked import pipeline as bp
from app.services.consumable_vision_algorithm import ( from app.services.consumable_vision_algorithm import (
@@ -28,6 +28,7 @@ from app.services.video.inference_aggregator import WindowInferenceReady
from app.services.video.session_registry import ( from app.services.video.session_registry import (
SurgerySessionRegistry, SurgerySessionRegistry,
SurgerySessionState, SurgerySessionState,
format_elapsed_mmss_since,
) )
@@ -83,6 +84,10 @@ class VisionClassificationHandler:
camera_id=camera_id, camera_id=camera_id,
wall_start_epoch=ready.wall_lo, wall_start_epoch=ready.wall_lo,
wall_end_epoch=ready.wall_hi, wall_end_epoch=ready.wall_hi,
since_recording_start=format_elapsed_mmss_since(
state.surgery_started_wall,
at_epoch=ready.wall_hi,
),
) )
async def handle( async def handle(
@@ -182,11 +187,7 @@ class VisionClassificationHandler:
) )
if cid is None: if cid is None:
return return
logger.info( at_ep = ready.wall_hi if ready is not None else time.time()
"Enqueued pending consumable confirmation id={} top_key={}",
cid,
top_key,
)
if ready is not None and surgery_id and camera_id and ( if ready is not None and surgery_id and camera_id and (
bp.CONSUMPTION_TSV_LOG_ENABLED bp.CONSUMPTION_TSV_LOG_ENABLED
or bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL or bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL
@@ -201,6 +202,10 @@ class VisionClassificationHandler:
wall_end_epoch=ready.wall_hi, wall_end_epoch=ready.wall_hi,
tsv_enabled=bp.CONSUMPTION_TSV_LOG_ENABLED, tsv_enabled=bp.CONSUMPTION_TSV_LOG_ENABLED,
markdown_terminal=bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL, markdown_terminal=bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL,
since_recording_start=format_elapsed_mmss_since(
state.surgery_started_wall,
at_epoch=at_ep,
),
) )
await self._registry.append_pending_consumption_detail( await self._registry.append_pending_consumption_detail(
state=state, state=state,

View File

@@ -30,6 +30,7 @@ from app.services.video.session_registry import (
RunningSurgery, RunningSurgery,
SurgerySessionRegistry, SurgerySessionRegistry,
SurgerySessionState, SurgerySessionState,
format_elapsed_mmss_since,
) )
from app.services.tear_gated_segment_consumption.product_map import ( from app.services.tear_gated_segment_consumption.product_map import (
load_tear_segment_name_to_id, load_tear_segment_name_to_id,
@@ -166,6 +167,7 @@ class CameraSessionManager:
state = SurgerySessionState( state = SurgerySessionState(
candidate_consumables=list(resolved), candidate_consumables=list(resolved),
name_to_code=name_to_code, name_to_code=name_to_code,
surgery_started_wall=time.time(),
) )
stop_event = asyncio.Event() stop_event = asyncio.Event()
readies = [asyncio.Event() for _ in camera_ids] readies = [asyncio.Event() for _ in camera_ids]
@@ -447,9 +449,13 @@ class CameraSessionManager:
if bp.VIDEO_LOG_INFERENCE_RESULTS: if bp.VIDEO_LOG_INFERENCE_RESULTS:
logger.info( logger.info(
"Vision result surgery={} camera={} top1={}({:.3f}) top2={}({:.3f}) top3={}({:.3f})", "Vision result surgery={} camera={} 相对开录={} top1={}({:.3f}) top2={}({:.3f}) top3={}({:.3f})",
surgery_id, surgery_id,
camera_id, camera_id,
format_elapsed_mmss_since(
state.surgery_started_wall,
at_epoch=time.time(),
),
snap.t1_name, snap.t1_name,
snap.t1_conf, snap.t1_conf,
snap.t2_name, snap.t2_name,

View File

@@ -29,6 +29,16 @@ from app.services.voice_confirm import build_prompt_text
from app.surgery_errors import SurgeryPipelineError from app.surgery_errors import SurgeryPipelineError
def format_elapsed_mmss_since(surgery_started_wall: float | None, *, at_epoch: float) -> str:
"""从 ``start_surgery`` 记录的开录时刻到 ``at_epoch`` 的流逝时间(分+秒),供终端 loguru 使用。"""
if surgery_started_wall is None:
return ""
sec = max(0.0, at_epoch - surgery_started_wall)
total = int(sec)
m, s = divmod(total, 60)
return f"{m}{s:02d}"
@dataclass @dataclass
class PendingConsumableConfirmation: class PendingConsumableConfirmation:
"""待客户端确认的一条低置信度识别(不阻塞后续帧推理)。""" """待客户端确认的一条低置信度识别(不阻塞后续帧推理)。"""
@@ -73,6 +83,8 @@ class SurgerySessionState:
last_asr_text: str | None = None last_asr_text: str | None = None
#: 最近一次语音确认错误说明ASR/解析失败等)。 #: 最近一次语音确认错误说明ASR/解析失败等)。
last_voice_error: str | None = None last_voice_error: str | None = None
#: ``start_surgery`` 创建会话时的 ``time.time()``,用于日志中「相对开录的流逝时间」。
surgery_started_wall: float | None = None
@dataclass @dataclass

View File

@@ -8,7 +8,7 @@
scripts/demo_client/ scripts/demo_client/
server.py # 基于 stdlib 的静态服务器;额外暴露 /labels.json server.py # 基于 stdlib 的静态服务器;额外暴露 /labels.json
index.html # 单文件页面(原生 JS零构建依赖 index.html # 单文件页面(原生 JS零构建依赖
fake_rtsp_from_file.py # 无真摄像头时:把本地视频循环发布为 RTSPffmpeg + Docker MediaMTX fake_rtsp_from_file.py # 无真摄像头时:把本地视频按文件时长推一次到 RTSPffmpeg + Docker MediaMTX
``` ```
## 调试:无真实摄像头,用录好的视频模拟 RTSP ## 调试:无真实摄像头,用录好的视频模拟 RTSP
@@ -53,10 +53,10 @@ python3 scripts/demo_client/fake_rtsp_from_file.py --port 18554 \
发布失败时,可尝试把输入转码后再推流(示例,需自行调整): 发布失败时,可尝试把输入转码后再推流(示例,需自行调整):
```bash ```bash
ffmpeg -re -stream_loop -1 -i recording.mp4 -c:v libx264 -pix_fmt yuv420p -f rtsp -rtsp_transport tcp rtsp://127.0.0.1:18554/demo ffmpeg -re -i recording.mp4 -c:v libx264 -pix_fmt yuv420p -f rtsp -rtsp_transport tcp rtsp://127.0.0.1:18554/demo
``` ```
(仍须先自行启动 MediaMTX 或等价 RTSP 服务端。) (仍须先自行启动 MediaMTX 或等价 RTSP 服务端;上例为**播完即止**,若要循环请加 `-stream_loop -1`。)
Demo 页面「调试:两路视频」中可用 **选择视频** / **拖放** 为路1/路2 指定文件,并配合下面 **一键开录** 上传,无需在页面里手抄 `python3` / `export` 命令。若必须完全手跑 `fake_rtsp_from_file.py`,请在上文命令示例与 `export VIDEO_RTSP_URLS_JSON=...` 方式自行在终端完成。 Demo 页面「调试:两路视频」中可用 **选择视频** / **拖放** 为路1/路2 指定文件,并配合下面 **一键开录** 上传,无需在页面里手抄 `python3` / `export` 命令。若必须完全手跑 `fake_rtsp_from_file.py`,请在上文命令示例与 `export VIDEO_RTSP_URLS_JSON=...` 方式自行在终端完成。

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Publish local video file(s) as looping RTSP stream(s) (fake camera) for local dev. """Publish local video file(s) to RTSP once per file (fake camera) for local dev.
The Operation Room server only opens RTSP URLs (OpenCV); there is no video-upload API. The Operation Room server only opens RTSP URLs (OpenCV); there is no video-upload API.
This script does NOT change the application backend: it runs ffmpeg + a small This script does NOT change the application backend: it runs ffmpeg + a small
@@ -103,7 +103,7 @@ def _parse_stream_arg(spec: str) -> tuple[str, Path, str]:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Loop video file(s) to RTSP URL(s) (dev fake camera; no backend code change).", description="Play each video file once to an RTSP URL (dev fake camera; no backend code change).",
) )
parser.add_argument( parser.add_argument(
"video", "video",
@@ -198,7 +198,6 @@ def main() -> int:
"ffmpeg", "ffmpeg",
"-hide_banner", "-loglevel", "info", "-hide_banner", "-loglevel", "info",
"-re", "-re",
"-stream_loop", "-1",
"-i", str(fp), "-i", str(fp),
"-c", "copy", "-c", "copy",
"-f", "rtsp", "-f", "rtsp",
@@ -225,7 +224,11 @@ def main() -> int:
h = u.replace("127.0.0.1", "host.docker.internal", 1) h = u.replace("127.0.0.1", "host.docker.internal", 1)
print(f" {cam}: {h}", file=sys.stderr) print(f" {cam}: {h}", file=sys.stderr)
print("---", file=sys.stderr) print("---", file=sys.stderr)
print("Fake RTSP running (Ctrl+C to stop; MediaMTX container removed on exit).", file=sys.stderr) print(
"Fake RTSP running: each file plays once; script exits when ffmpeg ends "
"(Ctrl+C to stop early; MediaMTX container removed on exit).",
file=sys.stderr,
)
def on_sigint(_sig: int, _frame) -> None: def on_sigint(_sig: int, _frame) -> None:
for p in procs: for p in procs:

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

View File

@@ -11,6 +11,7 @@ from app.services.consumption_tsv_log import (
append_consumption_log_summary, append_consumption_log_summary,
append_consumption_tsv_line, append_consumption_tsv_line,
build_consumption_markdown, build_consumption_markdown,
build_pending_consumption_markdown,
build_tsv_line, build_tsv_line,
init_consumption_log_file, init_consumption_log_file,
replace_pending_line_with_voice_resolution, replace_pending_line_with_voice_resolution,
@@ -48,7 +49,7 @@ def test_build_tsv_line_matches_sample_shape(monkeypatch: pytest.MonkeyPatch) ->
wall_end_epoch=w0 + 45.0, wall_end_epoch=w0 + 45.0,
) )
parts = line.rstrip("\n").split("\t") parts = line.rstrip("\n").split("\t")
assert len(parts) == 9 assert len(parts) == 11
assert parts[0] == "2237844" assert parts[0] == "2237844"
assert parts[1] == "一次性医用灭菌棉签" assert parts[1] == "一次性医用灭菌棉签"
assert parts[2] == "1" assert parts[2] == "1"
@@ -59,10 +60,12 @@ def test_build_tsv_line_matches_sample_shape(monkeypatch: pytest.MonkeyPatch) ->
+ _RANGE_SEP + _RANGE_SEP
+ "2024-01-01T00:00:45.000+00:00" + "2024-01-01T00:00:45.000+00:00"
) )
assert parts[5] == "cls2" assert parts[5] == "一次性医用灭菌棉签"
assert parts[6] == "0.0003" assert parts[6] == "0.9997"
assert parts[7] == "cls3" assert parts[7] == "cls2"
assert parts[8] == "0.0002" assert parts[8] == "0.0003"
assert parts[9] == "cls3"
assert parts[10] == "0.0002"
def test_resolve_consumption_item_id_uses_normalized_catalog_key() -> None: def test_resolve_consumption_item_id_uses_normalized_catalog_key() -> None:
@@ -78,6 +81,8 @@ def test_header_columns() -> None:
"qty", "qty",
"doctor_id", "doctor_id",
"timestamp", "timestamp",
"top1_name",
"top1_conf",
"top2_name", "top2_name",
"top2_conf", "top2_conf",
"top3_name", "top3_name",
@@ -101,7 +106,8 @@ def test_replace_pending_line_with_voice_resolution_rewrites_one_row(
pending = ( pending = (
"pending:abc-123\t待确认\t1\tvision\t" "pending:abc-123\t待确认\t1\tvision\t"
"cam01@2024-01-01T00:00:00.000+00:00" "cam01@2024-01-01T00:00:00.000+00:00"
f"{_RANGE_SEP}2024-01-01T00:00:45.000+00:00\tx\t0.1\ty\t0.2\n" f"{_RANGE_SEP}2024-01-01T00:00:45.000+00:00\t"
"一次性针头\t0.5000\tx\t0.1\ty\t0.2\n"
) )
append_consumption_tsv_line("SURG01", pending) append_consumption_tsv_line("SURG01", pending)
replace_pending_line_with_voice_resolution( replace_pending_line_with_voice_resolution(
@@ -189,6 +195,7 @@ def test_build_consumption_markdown_top123_columns(monkeypatch: pytest.MonkeyPat
wall_end_epoch=w0 + 45.0, wall_end_epoch=w0 + 45.0,
) )
assert "| item_id |" in md and "| item_name |" in md and "| qty |" in md assert "| item_id |" in md and "| item_name |" in md and "| qty |" in md
assert "| 相对开录 |" in md
assert "| top2 |" in md and "| top3 |" in md assert "| top2 |" in md and "| top3 |" in md
assert "2237844" in md assert "2237844" in md
assert "一次性医用灭菌棉签" in md assert "一次性医用灭菌棉签" in md
@@ -199,3 +206,33 @@ def test_build_consumption_markdown_top123_columns(monkeypatch: pytest.MonkeyPat
assert "2024-01-01 00:00:00.000" in md and "2024-01-01 00:00:45.000" in md assert "2024-01-01 00:00:00.000" in md and "2024-01-01 00:00:45.000" in md
assert "cam01" in md and " · " in md and _RANGE_SEP in md assert "cam01" in md and " · " in md and _RANGE_SEP in md
assert "cam01@2024-01" not in md assert "cam01@2024-01" not in md
assert "| DOCTOR_PLACEHOLDER | — |" in md
def test_build_pending_consumption_markdown_top123(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(bp, "CONSUMPTION_LOG_TIMEZONE", "UTC")
snap = ClsTop3(
t1_name="输液器A",
t1_conf=0.88,
t2_name="候选二",
t2_conf=0.07,
t3_name="候选三",
t3_conf=0.02,
t1_pid="",
t2_pid="",
t3_pid="",
)
w0 = 1704067200.0
md = build_pending_consumption_markdown(
confirmation_id="cid-1",
model_snap=snap,
doctor_id="vision",
camera_id="or-cam-01",
wall_start_epoch=w0,
wall_end_epoch=w0 + 15.0,
since_recording_start="0分15秒",
)
assert "| top1 | top2 | top3 |" in md
assert "输液器A (0.8800)" in md
assert "候选二 (0.0700)" in md
assert "候选三 (0.0200)" in md