feat: 消费停录汇总与查结果同口径,并更新分类与 TSV

- 停录时由 details 经 build_consumption_summary 写 TSV/终端汇总,移除 consumption_log_totals 与时间窗累加
- 更新 consumable_classifier 权重、YAML 标签与 consumable_vision_algorithm
- 扩展 consumption TSV 相关测试与配置注释

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-24 14:27:56 +08:00
parent 557fcee803
commit b651364877
9 changed files with 318 additions and 155 deletions

View File

@@ -273,7 +273,7 @@ class Settings(BaseSettings):
video_result_doctor_id: str = "vision" video_result_doctor_id: str = "vision"
#: 为 true 时,每次单帧分类得到 top1 等结果会打一条 INFO 日志(联调用;高流量时建议关)。 #: 为 true 时,每次单帧分类得到 top1 等结果会打一条 INFO 日志(联调用;高流量时建议关)。
video_log_inference_results: bool = False video_log_inference_results: bool = False
#: 为 true 时,将时间窗级识别写入文本日志(`start_surgery` 时按手术截断/初始化;每行 tabitem_id、item_name、qty、doctor_id、timestamp停录后追加汇总块 item_id、item_name、qty #: 为 true 时,将时间窗级识别写入文本日志(`start_surgery` 时按手术截断/初始化;每行 tabitem_id、item_name、qty、doctor_id、timestamp停录后按内存明细与查结果 API 同口径追加汇总块 item_id、item_name、qty
consumption_tsv_log_enabled: bool = True consumption_tsv_log_enabled: bool = True
#: 路径模板,须含 `{surgery_id}`(每例手术独立文件)。不含占位时自动在扩展名前追加 `_<surgery_id>`。 #: 路径模板,须含 `{surgery_id}`(每例手术独立文件)。不含占位时自动在扩展名前追加 `_<surgery_id>`。
consumption_tsv_log_path: str = "logs/consumption_{surgery_id}.txt" consumption_tsv_log_path: str = "logs/consumption_{surgery_id}.txt"

View File

@@ -1,93 +1,87 @@
# 与训练 data.yaml 类名一一对应;多规格时 label_id 以 / 连接;业务唯一来源 # 与训练 data.yaml 类名一label_id 来自 refs/商品信息表.xlsx商品名称→产品编码同名多规格以 / 连接)
# 耗材分类器 consumable_classifier.pt 的 YOLO-cls 类别名应与训练 data.yaml 一致; # 推理时以权重内嵌 names 为准;本文件供业务 label_id 与开录空 candidate 全量类名。
# 业务 label_id 与 names 下标一一对应;推理时以权重内嵌 names 为准。
names: names:
0: 医用纱布敷料 0: MCuⅡ功能性宫内节育器
1: 一次性使用无菌注射器带针 1: 一次性中性电极板
2: 一次性使用手术单 2: 一次性使用乳胶导尿管
3: 一次性使用肛门管 3: 一次性使用冲洗袋
4: 医用缝合针 4: 一次性使用医疗卫生用品
5: 非吸收性外科缝线(蚕丝线) 5: 一次性使用单极手术电极
6: 一次性中性电极板 6: 一次性使用导尿管
7: 一次性使用胃管 7: 一次性使用手术单
8: 一次性使用输卵管导管 8: 一次性使用无菌敷贴
9: 医用脱脂棉纱布块 9: 一次性使用无菌气管插管Tracheal Tube
10: 导管固定器 10: 一次性使用无菌注射器带针
11: 可吸收性外科缝线 11: 一次性使用无菌采样拭子
12: 一次性使用医疗卫生用品 12: 一次性使用气管插管
13: 一次性用灭菌棉签 13: 一次性使用灭菌橡胶外科手套
14: 一次性使用静脉输液针 14: 一次性使用牙垫
15: 非吸收性外科缝线 15: 一次性使用精密过滤输液器 带针
16: 一次性使用冲洗袋 16: 一次性使用肛门管
17: 医用凡士林敷料 17: 一次性使用胃管
18: 一次性使用无菌敷贴 18: 一次性使用血液透析管路
19: 自粘性薄膜敷料 19: 一次性使用输卵管导管
20: 密闭式防针刺伤型静脉留置针 20: 一次性使用雾化器
21: 一次性使用静脉留置针 21: 一次性使用静脉留置针
22: 一次性使用手术单(一次性医用垫单) 22: 一次性使用静脉输液针
23: 一次性使用无菌采样拭子 23: 一次性使用麻醉面罩
24: 一次性使用手术衣 24: 一次性内窥镜护套
25: 一次性使用气管插管 25: 一次性医用灭菌棉签
26: 一次性使用牙垫 26: 一次性无菌喉罩
27: 一次性使用血液透析管路 27: 医用凡士林敷料
28: 血液净化装置的体外循环血路 28: 医用纱布敷料
29: 一次性使用精密过滤输液器 带 29: 医用缝合
30: 结扎夹Ligating Clips 30: 医用脱脂棉纱布块
31: 一次性使用麻醉面罩 31: 可吸收性外科缝线
32: 一次性内窥镜护套 32: 密闭式防针刺伤型静脉留置针
33: 一次性使用导尿管 33: 导管固定器
34: 一次性使用灭菌橡胶外科手套 34: 气管切开插管
35: 一次性无菌喉罩 35: 结扎夹Ligating Clips
36: 一次性使用无菌气管插管Tracheal Tube 36: 自粘性薄膜敷料
37: 负压引流器 37: 血液净化装置的体外循环血路
38: 一次性使用单极手术电极 38: 负压引流器
39: 一次性使用乳胶导尿管 39: 非吸收性外科缝线
40: 气管切开插管 40: 非吸收性外科缝线(蚕丝线)
41: 一次性使用雾化器
42: MCuⅡ功能性宫内节育器
# 与 names 的整数下标一一对应;多规格同名则为 id1/id2/... 。
label_id: label_id:
0: 19246-3-14 0: "740-2-14"
1: 1531-3-2/1531-3-1/1174-42-4/1174-42-1 1: "4787-2-55"
2: 14764-2-4 2: "7386-10-89"
3: 1518-22-4 3: "1644-37-3"
4: 583039/11207-1-64 4: "2241272"
5: 654032 5: "4805-2-50"
6: 4787-2-55 6: "735592/14556-4-18"
7: 1518-34-17 7: "14764-2-4"
8: 1380-15-1 8: "215-93-1"
9: 8028-4-39 9: "14780-3-5"
10: 1441340 10: "1531-3-2/1531-3-1/1174-42-4/1174-42-1"
11: 11765-1-101/1330-49-185 11: "15026-1-1"
12: 2241272 12: "21444-1-2"
13: 2237844/10183-1-29 13: "10362-1-4"
14: 129-5-30 14: "975961"
15: 4142-1-46 15: "2295950"
16: 1644-37-3 16: "1518-22-4"
17: 10870-25-16 17: "1518-34-17"
18: 215-93-1 18: "14730-10-10"
19: 1819-4-1 19: "1380-15-1"
20: 1281-39-3 20: "5019-4-43"
21: 12591-1-184 21: "12591-1-184"
22: 21504-1-1 22: "129-5-30"
23: 15026-1-1 23: "2003984"
24: 8386-24-1 24: "521-31-1"
25: 21444-1-2 25: "2237844/10183-1-29"
26: 975961 26: "7386-61-46"
27: 14730-10-10 27: "10870-25-16"
28: 739-2-1 28: "19246-3-14"
29: 2295950 29: "583039/11207-1-64"
30: 14780-2-12 30: "8028-4-39"
31: 2003984 31: "11765-1-101/1330-49-185"
32: 521-31-1 32: "1281-39-3"
33: 735592/14556-4-18 33: "1441340"
34: 10362-1-4 34: "10869-30-7"
35: 7386-61-46 35: "14780-2-12"
36: 14780-3-5 36: "1819-4-1"
37: 1518-20-8 37: "739-2-1"
38: 4805-2-50 38: "1518-20-8"
39: 7386-10-89 39: "4142-1-46"
40: 10869-30-7 40: "654032"
41: 5019-4-43 nc: 41
42: 740-2-14
nc: 43

View File

@@ -5,8 +5,9 @@
from __future__ import annotations from __future__ import annotations
from typing import Any import functools
import os import os
from typing import Any
import sys import sys
from collections import Counter from collections import Counter
from dataclasses import dataclass from dataclasses import dataclass
@@ -126,6 +127,38 @@ def load_name_to_label_id_from_yaml(path: Path) -> dict[str, str]:
return out return out
def load_index_to_label_id_from_yaml(path: Path) -> dict[int, str]:
"""与 ``label_id`` 段:类索引 -> 业务 id 字符串;类名与 YAML 略有不一致时仍可落盘到正确 id。"""
try:
raw = path.read_text(encoding="utf-8")
except OSError:
return {}
try:
data: Any = yaml.safe_load(raw)
except yaml.YAMLError:
return {}
if not isinstance(data, dict):
return {}
label_raw = data.get("label_id")
if not isinstance(label_raw, dict):
return {}
out: dict[int, str] = {}
for k, v in label_raw.items():
try:
i = int(k)
except (TypeError, ValueError):
continue
if v is None or (isinstance(v, str) and not str(v).strip()):
continue
out[i] = str(v).strip()
return out
@functools.lru_cache(maxsize=8)
def _cached_index_to_label_id(path_resolved: str, mtime_ns: int) -> dict[int, str]:
return load_index_to_label_id_from_yaml(Path(path_resolved))
def list_sorted_class_names_from_yaml(path: Path) -> list[str]: def list_sorted_class_names_from_yaml(path: Path) -> list[str]:
"""自 ``names`` 段按类索引升序取类名字符串(与训练/权重一致)。""" """自 ``names`` 段按类索引升序取类名字符串(与训练/权重一致)。"""
try: try:
@@ -218,7 +251,11 @@ def _probs_data_to_numpy1d(raw) -> np.ndarray:
def cls_top3_from_result( def cls_top3_from_result(
cls: YOLO, r, name_to_code: dict[str, str] cls: YOLO,
r,
name_to_code: dict[str, str],
*,
index_to_label_id: dict[int, str] | None = None,
) -> ClsTop3 | None: ) -> ClsTop3 | None:
pr = r[0].probs pr = r[0].probs
if pr is None: if pr is None:
@@ -244,6 +281,7 @@ def cls_top3_from_result(
n2 = n3 = "" n2 = n3 = ""
c2 = c3 = 0.0 c2 = c3 = 0.0
i2 = i3 = -1
if len(t5i) > 1: if len(t5i) > 1:
i2 = int(t5i[1]) i2 = int(t5i[1])
n2 = str(cls.names.get(i2, "")).strip() n2 = str(cls.names.get(i2, "")).strip()
@@ -253,12 +291,19 @@ def cls_top3_from_result(
n3 = str(cls.names.get(i3, "")).strip() n3 = str(cls.names.get(i3, "")).strip()
c3 = _conf_for_idx(i3) c3 = _conf_for_idx(i3)
def _pid(label: str) -> str: idx_extras = index_to_label_id or {}
def _pid(label: str, class_idx: int) -> str:
lb = (label or "").strip() lb = (label or "").strip()
if not lb: if not lb:
return "" return ""
norm = _norm_product_name(lb) norm = _norm_product_name(lb)
return (name_to_code.get(norm) or name_to_code.get(lb) or "").strip() c = (name_to_code.get(norm) or name_to_code.get(lb) or "").strip()
if c:
return c
if class_idx >= 0 and class_idx in idx_extras:
return idx_extras[class_idx]
return ""
return ClsTop3( return ClsTop3(
t1_name=n1, t1_name=n1,
@@ -267,9 +312,9 @@ def cls_top3_from_result(
t2_conf=c2, t2_conf=c2,
t3_name=n3, t3_name=n3,
t3_conf=c3, t3_conf=c3,
t1_pid=_pid(n1), t1_pid=_pid(n1, t1i),
t2_pid=_pid(n2), t2_pid=_pid(n2, i2),
t3_pid=_pid(n3), t3_pid=_pid(n3, i3),
) )
@@ -475,12 +520,31 @@ class ConsumableVisionAlgorithmService:
except Exception as exc: except Exception as exc:
raise PredictionError(f"耗材分类推理失败: {exc}") from exc raise PredictionError(f"耗材分类推理失败: {exc}") from exc
snap = cls_top3_from_result(cls_model, r, name_to_code) yp = Path(self._s.consumable_classifier_labels_yaml_path).expanduser()
if yp.is_file():
st = yp.stat()
index_to_label_id = _cached_index_to_label_id(
str(yp.resolve()), st.st_mtime_ns
)
else:
index_to_label_id = {}
snap = cls_top3_from_result(
cls_model,
r,
name_to_code,
index_to_label_id=index_to_label_id,
)
if snap is None: if snap is None:
return None return None
if snap.t1_conf < self._s.consumable_min_cls_confidence: if snap.t1_conf < self._s.consumable_min_cls_confidence:
return None return None
pname = snap.t1_name pname = snap.t1_name
if not pname or pname not in whitelist: if not pname:
return None return None
return snap pnorm = _norm_product_name(pname)
if pnorm in whitelist or pname in whitelist:
return snap
if (snap.t1_pid or "").strip():
return snap
return None

View File

@@ -1,6 +1,6 @@
"""每例手术一个文本文件(制表符列):`start_surgery` 时截断并写表头,每次时间窗识别**追加**一行(仅 item_id, item_name, qty, doctor_id, timestamp。终端 Markdown 时间戳为可读形式;落盘时间戳为 ISO 区间便于程序解析。 """每例手术一个文本文件(制表符列):`start_surgery` 时截断并写表头,每次时间窗识别**追加**一行(仅 item_id, item_name, qty, doctor_id, timestamp待确认行首列为 ``pending:{confirmation_id}``,语音落锤后**整行替换**为与客户端一致的最终真值,不再重复追加。终端 Markdown 时间戳为可读形式;落盘时间戳为 ISO 区间便于程序解析。
手术结束时再追加一节汇总行item_id, item_name, qty无其它列 手术结束时再追加一节汇总行item_id, item_name, qty无其它列;与 HTTP ``summary`` 同算法,由内存 ``details`` 经 ``build_consumption_summary`` 得到,非录制过程中按窗累计
时间戳:在拉流起点记录 `time.time()`,与 `time.monotonic()` 时间窗对齐。直播 RTSP 经 OpenCV 一般无可靠绝对时码,以本机接收时刻为准。 时间戳:在拉流起点记录 `time.time()`,与 `time.monotonic()` 时间窗对齐。直播 RTSP 经 OpenCV 一般无可靠绝对时码,以本机接收时刻为准。
""" """
@@ -353,25 +353,15 @@ def append_consumption_pending_window(
) )
def append_consumption_voice_resolution_line( def _build_voice_resolved_tsv_data_line(
*, *,
surgery_id: str,
name_to_code: dict[str, str], name_to_code: dict[str, str],
chosen_label: str, chosen_label: str,
doctor_id: str, doctor_id: str,
wall_epoch: float, wall_epoch: float,
tsv_enabled: bool | None = None, ) -> str:
) -> None: """与客户端一致的最终行top2/3 空列timestamp 为单点)。"""
"""语音确认后追加一行最终耗材(医生 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() lb = (chosen_label or "").strip()
if not lb:
return
norm = _norm_product_name(lb) norm = _norm_product_name(lb)
p = ( p = (
name_to_code.get(norm) or name_to_code.get(lb) or "" name_to_code.get(norm) or name_to_code.get(lb) or ""
@@ -387,7 +377,7 @@ def append_consumption_voice_resolution_line(
t2_pid="", t2_pid="",
t3_pid="", t3_pid="",
) )
line = build_tsv_line( return build_tsv_line(
name_to_code=name_to_code, name_to_code=name_to_code,
best=snap, best=snap,
doctor_id=doctor_id, doctor_id=doctor_id,
@@ -395,6 +385,98 @@ def append_consumption_voice_resolution_line(
wall_start_epoch=wall_epoch, wall_start_epoch=wall_epoch,
wall_end_epoch=wall_epoch, wall_end_epoch=wall_epoch,
) )
def _data_line_starts_with_pending_id(line: str, confirmation_id: str) -> bool:
s = (line or "").rstrip("\r\n")
if not s or s.startswith("item_id\t"):
return False
want = f"pending:{(confirmation_id or '').strip()}"
first = s.split("\t", 1)[0]
return first == _encode_cell(want) or first == want
def replace_pending_line_with_voice_resolution(
*,
surgery_id: str,
confirmation_id: str,
name_to_code: dict[str, str],
chosen_label: str,
doctor_id: str,
wall_epoch: float,
tsv_enabled: bool | None = None,
) -> None:
"""将同一 ``confirmation_id`` 下先前追加的「待确认」行整行改为最终真值,不再多追加一行。
未找到 ``pending:{confirmation_id}`` 时回退为追加(兼容旧文件或行缺失)。读改写全程持
:data:`_lock`,避免与并发的 append 交错。
"""
en = settings.consumption_tsv_log_enabled if tsv_enabled is None else tsv_enabled
if not en:
return
if not (chosen_label or "").strip() or not (confirmation_id or "").strip():
return
new_line = _build_voice_resolved_tsv_data_line(
name_to_code=name_to_code,
chosen_label=chosen_label,
doctor_id=doctor_id,
wall_epoch=wall_epoch,
)
path = resolved_consumption_log_path(surgery_id)
with _lock:
if not path.is_file():
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
f.write(HEADER)
f.write(new_line)
logger.warning(
"consumption TSV 无文件,无法替换 pending 行,已写表头+最终行: {}",
path,
)
return
text = path.read_text(encoding="utf-8")
lines = text.splitlines(keepends=True)
replaced = False
out: list[str] = []
for line in lines:
if (
not replaced
and _data_line_starts_with_pending_id(line, confirmation_id)
):
out.append(new_line)
replaced = True
else:
out.append(line)
if not replaced:
logger.warning(
"未在 TSV 中找到 pending 行,回退为追加: surgery={} confirm={}",
surgery_id,
confirmation_id,
)
with path.open("a", encoding="utf-8") as f:
f.write(new_line)
return
path.write_text("".join(out), encoding="utf-8")
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:
"""已弃用:请使用 ``replace_pending_line_with_voice_resolution`` 并传 ``confirmation_id``。"""
if not (chosen_label or "").strip():
return
line = _build_voice_resolved_tsv_data_line(
name_to_code=name_to_code,
chosen_label=chosen_label,
doctor_id=doctor_id,
wall_epoch=wall_epoch,
)
append_consumption_tsv_line(surgery_id, line) append_consumption_tsv_line(surgery_id, line)
@@ -467,19 +549,9 @@ class ConsumptionTsvWriter:
camera_id: str, camera_id: str,
wall_start_epoch: float, wall_start_epoch: float,
wall_end_epoch: float, wall_end_epoch: float,
running_totals: dict[str, tuple[str, int]] | None = None,
) -> None: ) -> None:
if not self._s.consumption_tsv_log_enabled and not self._s.consumption_log_markdown_terminal: if not self._s.consumption_tsv_log_enabled and not self._s.consumption_log_markdown_terminal:
return 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: if self._s.consumption_tsv_log_enabled:
line = build_tsv_line( line = build_tsv_line(
name_to_code=name_to_code, name_to_code=name_to_code,
@@ -549,19 +621,9 @@ 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,
running_totals: dict[str, tuple[str, int]] | None = None,
) -> None: ) -> None:
if not settings.consumption_tsv_log_enabled and not settings.consumption_log_markdown_terminal: if not settings.consumption_tsv_log_enabled and not settings.consumption_log_markdown_terminal:
return 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 settings.consumption_tsv_log_enabled: if settings.consumption_tsv_log_enabled:
line = build_tsv_line( line = build_tsv_line(
name_to_code=name_to_code, name_to_code=name_to_code,

View File

@@ -7,7 +7,7 @@
- 置信度 ≥ 自动阈值但 Top1 不在候选内 → 视 voice_confirmation_enabled 入 pending。 - 置信度 ≥ 自动阈值但 Top1 不在候选内 → 视 voice_confirmation_enabled 入 pending。
- 中等置信度 → 入 pending若有可展示候选项 - 中等置信度 → 入 pending若有可展示候选项
需医生确认时:消耗 TSV / 内存明细记「待确认」(不写模型 top1 商品名);语音确认后再落最终耗材并更新汇总 需医生确认时:消耗 TSV / 内存明细记「待确认」(不写模型 top1 商品名);语音确认后**替换**该条 TSV 为最终耗材。停录时 TSV/终端汇总与查结果 API 同口径,由 details 经 ``build_consumption_summary`` 生成
""" """
from __future__ import annotations from __future__ import annotations
@@ -85,7 +85,6 @@ 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,
running_totals=state.consumption_log_totals,
) )
async def handle( async def handle(
@@ -99,7 +98,8 @@ class VisionClassificationHandler:
) -> None: ) -> None:
conf = cls_res.confidence conf = cls_res.confidence
label = (cls_res.label or "").strip() label = (cls_res.label or "").strip()
item_id = resolve_consumption_item_id(label, "", state.name_to_code) t1_pid = (ready.best.t1_pid if ready is not None else "")
item_id = resolve_consumption_item_id(label, t1_pid, state.name_to_code)
voice_floor = self._s.video_voice_confirm_min_confidence voice_floor = self._s.video_voice_confirm_min_confidence
if conf < voice_floor: if conf < voice_floor:
return return

View File

@@ -29,6 +29,7 @@ from app.services.video.session_registry import (
) )
from app.services.video.stream_worker import CameraStreamWorker, redact_rtsp_url from app.services.video.stream_worker import CameraStreamWorker, redact_rtsp_url
from app.services.video.types import VideoBackendKind from app.services.video.types import VideoBackendKind
from app.schemas import SurgeryConsumptionDetail, build_consumption_summary
from app.services.consumption_tsv_log import ( from app.services.consumption_tsv_log import (
append_consumption_log_summary, append_consumption_log_summary,
init_consumption_log_file, init_consumption_log_file,
@@ -214,11 +215,21 @@ class CameraSessionManager:
if isinstance(res, BaseException): if isinstance(res, BaseException):
logger.warning("surgery task finished with error: {}", res) logger.warning("surgery task finished with error: {}", res)
totals = dict(run.state.consumption_log_totals) details = list(run.state.details)
detail_rows = [
SurgeryConsumptionDetail(
item_id=r.item_id,
item_name=r.item_name,
qty=r.qty,
doctor_id=r.doctor_id,
timestamp=r.timestamp,
)
for r in details
]
summ = build_consumption_summary(detail_rows)
totals = {s.item_id: (s.item_name, s.total_quantity) for s in summ}
append_consumption_log_summary(surgery_id, totals) append_consumption_log_summary(surgery_id, totals)
print_consumption_summary_markdown(totals) print_consumption_summary_markdown(totals)
details = list(run.state.details)
await self._archive.persist_or_archive(surgery_id, details) await self._archive.persist_or_archive(surgery_id, details)
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -22,8 +22,7 @@ from app.services.consumable_vision_algorithm import (
_norm_product_name, _norm_product_name,
) )
from app.services.consumption_tsv_log import ( from app.services.consumption_tsv_log import (
append_consumption_voice_resolution_line, replace_pending_line_with_voice_resolution,
resolve_consumption_ids,
resolve_consumption_item_id, resolve_consumption_item_id,
) )
from app.services.voice_confirm import build_prompt_text from app.services.voice_confirm import build_prompt_text
@@ -74,8 +73,6 @@ 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
#: 视觉时间窗落盘用量累计供停录时写汇总item_id -> 首次名称, 次数)。
consumption_log_totals: dict[str, tuple[str, int]] = field(default_factory=dict)
@dataclass @dataclass
@@ -281,6 +278,7 @@ class SurgerySessionRegistry:
self._finalize_voice_confirmed_consumption_log( self._finalize_voice_confirmed_consumption_log(
state=st, state=st,
surgery_id=surgery_id, surgery_id=surgery_id,
confirmation_id=confirmation_id,
chosen_label=label, chosen_label=label,
) )
try: try:
@@ -295,20 +293,16 @@ class SurgerySessionRegistry:
*, *,
state: SurgerySessionState, state: SurgerySessionState,
surgery_id: str, surgery_id: str,
confirmation_id: str,
chosen_label: str, chosen_label: str,
) -> None: ) -> None:
"""待确认流程在语音落锤后:汇总 +1 最终耗材,并追加 TSV 正式行""" """待确认流程在语音落锤后:将 TSV 中原 pending 行替换为最终真值。停录汇总与 HTTP 一致,由 details 经 ``build_consumption_summary`` 得到"""
cl = (chosen_label or "").strip() cl = (chosen_label or "").strip()
if not cl: if not cl:
return return
_, key_chosen = resolve_consumption_ids(cl, "", state.name_to_code) replace_pending_line_with_voice_resolution(
tot = state.consumption_log_totals
if key_chosen not in tot:
tot[key_chosen] = (cl, 0)
nm, q = tot[key_chosen]
tot[key_chosen] = (nm, q + 1)
append_consumption_voice_resolution_line(
surgery_id=surgery_id, surgery_id=surgery_id,
confirmation_id=confirmation_id,
name_to_code=state.name_to_code, name_to_code=state.name_to_code,
chosen_label=cl, chosen_label=cl,
doctor_id=self._s.video_voice_confirm_doctor_id, doctor_id=self._s.video_voice_confirm_doctor_id,

View File

@@ -13,6 +13,7 @@ from app.services.consumption_tsv_log import (
build_consumption_markdown, build_consumption_markdown,
build_tsv_line, build_tsv_line,
init_consumption_log_file, init_consumption_log_file,
replace_pending_line_with_voice_resolution,
resolve_consumption_item_id, resolve_consumption_item_id,
short_camera_label, short_camera_label,
) )
@@ -84,6 +85,43 @@ def test_header_columns() -> None:
] ]
def test_replace_pending_line_with_voice_resolution_rewrites_one_row(
tmp_path: object,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""语音确认后应替换 pending 行,而不是再多一行。"""
monkeypatch.setattr(settings, "consumption_tsv_log_enabled", True)
monkeypatch.setattr(settings, "consumption_log_timezone", "UTC")
monkeypatch.setattr(
settings,
"consumption_tsv_log_path",
str(tmp_path / "{surgery_id}.txt"),
)
init_consumption_log_file("SURG01")
pending = (
"pending:abc-123\t待确认\t1\tvision\t"
"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"
)
append_consumption_tsv_line("SURG01", pending)
replace_pending_line_with_voice_resolution(
surgery_id="SURG01",
confirmation_id="abc-123",
name_to_code={"纱布": "G1"},
chosen_label="纱布",
doctor_id="voice",
wall_epoch=1704067200.0,
)
text = (tmp_path / "SURG01.txt").read_text(encoding="utf-8")
assert "待确认" not in text
assert "pending:abc-123" not in text
assert "纱布" in text
assert "G1" in text
# HEADER + 恰好一行数据
data_lines = [ln for ln in text.splitlines() if ln and not ln.startswith("item_id\t")]
assert len(data_lines) == 1
def test_per_surgery_file_init_and_append( def test_per_surgery_file_init_and_append(
tmp_path, tmp_path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,