feat: 手术视频消耗、待确认与持久化改造

- 新增 Alembic 初始迁移、领域明细模型及归档持久化与重试链路\n- 拆分视频会话注册表、分类处理、推理时间窗聚合与流处理\n- 消耗日志:TSV/Markdown 含 top2/top3;item_id 优先产品编码;待确认记「待确认」行,语音确认后落正式行并更新汇总\n- 待确认时内存/DB 明细为占位行,确认后替换;拒绝时移除占位\n- 分类 probs 先 detach/cpu 再转 NumPy,修复 MPS/CUDA 上推理被静默跳过\n- 补充集成测试、归档与设备张量等单测

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-23 20:42:21 +08:00
parent 69980d8073
commit 3d7bd70355
55 changed files with 4544 additions and 2050 deletions

View File

@@ -19,8 +19,12 @@ from app.config import settings
from app.services.consumable_vision_algorithm import ClsTop3, _norm_product_name
from app.terminal_markdown import print_markdown_stderr
# 制表符分隔;时间范围用 U+2013 连接;本窗消耗数量恒为 1
HEADER = "item_id\titem_name\tqty\tdoctor_id\ttimestamp\n"
# 制表符分隔;时间范围用 U+2013 连接;本窗消耗数量恒为 1
# top2/top3 为模型原始排序未按手术候选重排item_id 仅写产品编码,无编码时留空。
HEADER = (
"item_id\titem_name\tqty\tdoctor_id\ttimestamp\t"
"top2_name\ttop2_conf\ttop3_name\ttop3_conf\n"
)
SUMMARY_HEADER = "item_id\titem_name\tqty\n"
_RANGE_SEP = "\u2013" # en dash与样例 `00:00:00.00000:00:45.000` 一致
@@ -86,21 +90,43 @@ def _encode_cell(value: str) -> str:
return s
def resolve_consumption_ids(
t1_name: str,
t1_pid: str,
name_to_code: dict[str, str],
) -> tuple[str, str]:
"""TSV 第一列 item_id 与内存汇总键。
- ``tsv_item_id``:仅产品编码(或模型侧 t1_pid与展示名相同则视为无独立编码留空。
- ``totals_key``:汇总用稳定键;无编码时用归一化名称,避免多行空 id 碰撞。
"""
n = (t1_name or "").strip()
norm = _norm_product_name(n)
code = (name_to_code.get(norm) or name_to_code.get(n) or "").strip()
p = (t1_pid or "").strip()
catalog = (code or p).strip()
if catalog and catalog != n:
return catalog, catalog
if catalog == n and catalog:
return "", norm
return "", norm if norm else (n or "unknown")
def resolve_consumption_item_id(
t1_name: str,
t1_pid: str,
name_to_code: dict[str, str],
) -> str:
"""业务物品 id`name_to_code` 的键为归一化名称,须与分类输出一同参与查找"""
n = (t1_name or "").strip()
norm = _norm_product_name(n)
code = (name_to_code.get(norm) or name_to_code.get(n) or "").strip()
if code:
return code
p = (t1_pid or "").strip()
if p:
return p
return n
"""兼容旧调用:有编码则返回编码,否则返回汇总键(归一化名或 unknown"""
tsv_id, totals_key = resolve_consumption_ids(t1_name, t1_pid, name_to_code)
return tsv_id or totals_key
def _fmt_top_conf(v: float) -> str:
try:
return f"{float(v):.4f}"
except (TypeError, ValueError):
return "0.0000"
def build_tsv_line(
@@ -112,15 +138,23 @@ def build_tsv_line(
wall_start_epoch: float,
wall_end_epoch: float,
) -> str:
id1 = resolve_consumption_item_id(best.t1_name, best.t1_pid, name_to_code)
tsv_id, _tot_key = resolve_consumption_ids(
best.t1_name, best.t1_pid, name_to_code
)
name1 = (best.t1_name or "").strip()
ts = format_consumption_timestamp(camera_id, wall_start_epoch, wall_end_epoch)
n2 = (best.t2_name or "").strip()
n3 = (best.t3_name or "").strip()
row = [
_encode_cell(id1),
_encode_cell(tsv_id),
_encode_cell(name1),
"1",
_encode_cell(doctor_id),
_encode_cell(ts),
_encode_cell(n2),
_fmt_top_conf(best.t2_conf),
_encode_cell(n3),
_fmt_top_conf(best.t3_conf),
]
return "\t".join(row) + "\n"
@@ -185,24 +219,185 @@ def build_consumption_markdown(
wall_end_epoch: float,
) -> str:
"""终端用:与落盘列一致;本窗 qty 恒为 1。"""
id1 = resolve_consumption_item_id(best.t1_name, best.t1_pid, name_to_code)
tsv_id, _ = resolve_consumption_ids(best.t1_name, best.t1_pid, name_to_code)
n1 = (best.t1_name or "").strip()
n2 = (best.t2_name or "").strip()
n3 = (best.t3_name or "").strip()
ts = format_consumption_timestamp_readable(camera_id, wall_start_epoch, wall_end_epoch)
return "\n".join(
[
"| item_id | item_name | qty | doctor_id | timestamp |",
"| :--- | :--- | ---: | :--- | :--- |",
"| {} | {} | 1 | {} | {} |".format(
_md_cell(id1),
"| item_id | item_name | qty | doctor_id | timestamp | top2 | top3 |",
"| :--- | :--- | ---: | :--- | :--- | :--- | :--- |",
"| {} | {} | 1 | {} | {} | {} | {} |".format(
_md_cell(tsv_id),
_md_cell(n1),
_md_cell(doctor_id),
_md_cell(ts),
_md_cell(
f"{n2} ({_fmt_top_conf(best.t2_conf)})" if n2 else "",
),
_md_cell(
f"{n3} ({_fmt_top_conf(best.t3_conf)})" if n3 else "",
),
),
"",
]
)
PENDING_CONSUMPTION_ITEM_NAME = "待确认"
def _build_pending_tsv_line(
*,
confirmation_id: str,
model_snap: ClsTop3,
doctor_id: str,
camera_id: str,
wall_start_epoch: float,
wall_end_epoch: float,
) -> str:
pid = f"pending:{confirmation_id}"
ts = format_consumption_timestamp(camera_id, wall_start_epoch, wall_end_epoch)
n2 = (model_snap.t2_name or "").strip()
n3 = (model_snap.t3_name or "").strip()
row = [
_encode_cell(pid),
_encode_cell(PENDING_CONSUMPTION_ITEM_NAME),
"1",
_encode_cell(doctor_id),
_encode_cell(ts),
_encode_cell(n2),
_fmt_top_conf(model_snap.t2_conf),
_encode_cell(n3),
_fmt_top_conf(model_snap.t3_conf),
]
return "\t".join(row) + "\n"
def build_pending_consumption_markdown(
*,
confirmation_id: str,
model_snap: ClsTop3,
doctor_id: str,
camera_id: str,
wall_start_epoch: float,
wall_end_epoch: float,
) -> str:
pid = f"pending:{confirmation_id}"
n2 = (model_snap.t2_name or "").strip()
n3 = (model_snap.t3_name or "").strip()
ts = format_consumption_timestamp_readable(camera_id, wall_start_epoch, wall_end_epoch)
return "\n".join(
[
"| item_id | item_name | qty | doctor_id | timestamp | top2 | top3 |",
"| :--- | :--- | ---: | :--- | :--- | :--- | :--- |",
"| {} | {} | 1 | {} | {} | {} | {} |".format(
_md_cell(pid),
_md_cell(PENDING_CONSUMPTION_ITEM_NAME),
_md_cell(doctor_id),
_md_cell(ts),
_md_cell(
f"{n2} ({_fmt_top_conf(model_snap.t2_conf)})" if n2 else "",
),
_md_cell(
f"{n3} ({_fmt_top_conf(model_snap.t3_conf)})" if n3 else "",
),
),
"",
]
)
def append_consumption_pending_window(
*,
surgery_id: str,
confirmation_id: str,
model_snap: ClsTop3,
doctor_id: str,
camera_id: str,
wall_start_epoch: float,
wall_end_epoch: float,
tsv_enabled: bool | None = None,
markdown_terminal: bool | None = None,
) -> None:
"""需医生确认的时间窗:落盘/终端记「待确认」top2/3 仍保留模型提示;不更新消耗汇总。"""
en_tsv = settings.consumption_tsv_log_enabled if tsv_enabled is None else tsv_enabled
en_md = (
settings.consumption_log_markdown_terminal
if markdown_terminal is None
else markdown_terminal
)
if not en_tsv and not en_md:
return
line = _build_pending_tsv_line(
confirmation_id=confirmation_id,
model_snap=model_snap,
doctor_id=doctor_id,
camera_id=camera_id,
wall_start_epoch=wall_start_epoch,
wall_end_epoch=wall_end_epoch,
)
if en_tsv:
append_consumption_tsv_line(surgery_id, line)
if en_md:
print_markdown_stderr(
build_pending_consumption_markdown(
confirmation_id=confirmation_id,
model_snap=model_snap,
doctor_id=doctor_id,
camera_id=camera_id,
wall_start_epoch=wall_start_epoch,
wall_end_epoch=wall_end_epoch,
),
)
def append_consumption_voice_resolution_line(
*,
surgery_id: str,
name_to_code: dict[str, str],
chosen_label: str,
doctor_id: str,
wall_epoch: float,
tsv_enabled: bool | None = None,
) -> None:
"""语音确认后追加一行最终耗材(医生 ID 多为 voicetop2/3 空列。
待确认流程下,时间窗仅记「待确认」,此处写入医生选定后的正式记录。
"""
en = settings.consumption_tsv_log_enabled if tsv_enabled is None else tsv_enabled
if not en:
return
lb = (chosen_label or "").strip()
if not lb:
return
norm = _norm_product_name(lb)
p = (
name_to_code.get(norm) or name_to_code.get(lb) or ""
).strip()
snap = ClsTop3(
t1_name=lb,
t1_conf=1.0,
t2_name="",
t2_conf=0.0,
t3_name="",
t3_conf=0.0,
t1_pid=p,
t2_pid="",
t3_pid="",
)
line = build_tsv_line(
name_to_code=name_to_code,
best=snap,
doctor_id=doctor_id,
camera_id="voice",
wall_start_epoch=wall_epoch,
wall_end_epoch=wall_epoch,
)
append_consumption_tsv_line(surgery_id, line)
def append_consumption_log_summary(
surgery_id: str,
totals: dict[str, tuple[str, int]],
@@ -244,6 +439,107 @@ def print_consumption_summary_markdown(
print_markdown_stderr("\n".join(lines))
class ConsumptionTsvWriter:
"""注入式 consumption 日志写入器,取代模块全局 ``settings`` 读取。
行为与模块级函数完全一致;保留模块级函数以维持旧调用点的兼容期。
"""
def __init__(self, app_settings) -> None:
self._s = app_settings
def init_file(self, surgery_id: str) -> None:
if not self._s.consumption_tsv_log_enabled:
return
path = resolved_consumption_log_path(surgery_id)
path.parent.mkdir(parents=True, exist_ok=True)
with _lock:
with path.open("w", encoding="utf-8") as f:
f.write(HEADER)
def append_window(
self,
*,
surgery_id: str,
name_to_code: dict[str, str],
best: ClsTop3,
doctor_id: str,
camera_id: str,
wall_start_epoch: float,
wall_end_epoch: float,
running_totals: dict[str, tuple[str, int]] | None = None,
) -> None:
if not self._s.consumption_tsv_log_enabled and not self._s.consumption_log_markdown_terminal:
return
_tsv_id, totals_key = resolve_consumption_ids(
best.t1_name, best.t1_pid, name_to_code
)
iname = (best.t1_name or "").strip()
if running_totals is not None:
if totals_key not in running_totals:
running_totals[totals_key] = (iname, 0)
prev_name, q = running_totals[totals_key]
running_totals[totals_key] = (prev_name, q + 1)
if self._s.consumption_tsv_log_enabled:
line = build_tsv_line(
name_to_code=name_to_code,
best=best,
doctor_id=doctor_id,
camera_id=camera_id,
wall_start_epoch=wall_start_epoch,
wall_end_epoch=wall_end_epoch,
)
append_consumption_tsv_line(surgery_id, line)
if self._s.consumption_log_markdown_terminal:
print_markdown_stderr(
build_consumption_markdown(
name_to_code=name_to_code,
best=best,
doctor_id=doctor_id,
camera_id=camera_id,
wall_start_epoch=wall_start_epoch,
wall_end_epoch=wall_end_epoch,
),
)
def append_summary(
self,
surgery_id: str,
totals: dict[str, tuple[str, int]],
) -> None:
if not self._s.consumption_tsv_log_enabled or not totals:
return
path = resolved_consumption_log_path(surgery_id)
if not path.is_file():
return
body = "".join(
["\n", SUMMARY_HEADER]
+ [
"\t".join([_encode_cell(iid), _encode_cell(name), str(qty)]) + "\n"
for iid, (name, qty) in sorted(totals.items(), key=lambda x: x[0])
]
)
with _lock:
with path.open("a", encoding="utf-8") as f:
f.write(body)
def print_summary_markdown(self, totals: dict[str, tuple[str, int]]) -> None:
if not self._s.consumption_log_markdown_terminal or not totals:
return
lines = [
"## 消耗汇总",
"",
"| item_id | item_name | qty |",
"| :--- | :--- | ---: |",
]
for iid, (name, qty) in sorted(totals.items(), key=lambda x: x[0]):
lines.append(
"| {} | {} | {} |".format(_md_cell(iid), _md_cell(name), qty)
)
lines.append("")
print_markdown_stderr("\n".join(lines))
def append_consumption_window(
*,
surgery_id: str,
@@ -257,13 +553,15 @@ def append_consumption_window(
) -> None:
if not settings.consumption_tsv_log_enabled and not settings.consumption_log_markdown_terminal:
return
iid = resolve_consumption_item_id(best.t1_name, best.t1_pid, name_to_code)
_tsv_id, totals_key = resolve_consumption_ids(
best.t1_name, best.t1_pid, name_to_code
)
iname = (best.t1_name or "").strip()
if running_totals is not None:
if iid not in running_totals:
running_totals[iid] = (iname, 0)
prev_name, q = running_totals[iid]
running_totals[iid] = (prev_name, q + 1)
if totals_key not in running_totals:
running_totals[totals_key] = (iname, 0)
prev_name, q = running_totals[totals_key]
running_totals[totals_key] = (prev_name, q + 1)
if settings.consumption_tsv_log_enabled:
line = build_tsv_line(
name_to_code=name_to_code,