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:
@@ -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` 时按手术截断/初始化;每行 tab:item_id、item_name、qty、doctor_id、timestamp;停录后追加汇总块 item_id、item_name、qty)。
|
#: 为 true 时,将时间窗级识别写入文本日志(`start_surgery` 时按手术截断/初始化;每行 tab:item_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"
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 多为 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()
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user