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:
@@ -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.000–00: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 多为 voice);top2/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,
|
||||
|
||||
Reference in New Issue
Block a user