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

Made-with: Cursor
2026-04-23 20:42:21 +08:00

190 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import re
_CN_DIGITS = {
"": 0,
"": 1,
"": 2,
"": 2,
"": 3,
"": 4,
"": 5,
"": 6,
"": 7,
"": 8,
"": 9,
"": 10,
}
def _parse_ordinal_index_1based(token: str) -> int | None:
"""将「1」「3」「一」「三」「十一」等解析为 1-based 序数,失败返回 None。"""
t = (token or "").strip()
if not t:
return None
if t.isdigit():
v = int(t)
return v if 1 <= v <= 99 else None
if t in _CN_DIGITS and t != "" and t != "":
return int(_CN_DIGITS[t])
if t == "":
return 10
if len(t) == 2 and t[0] == "" and t[1] in _CN_DIGITS and t[1] not in ("", ""):
return 10 + int(_CN_DIGITS[t[1]])
if len(t) == 2 and t[1] == "" and t[0] in _CN_DIGITS and t[0] != "":
return int(_CN_DIGITS[t[0]]) * 10
if len(t) == 3 and t[0] in _CN_DIGITS and t[1] == "" and t[2] in _CN_DIGITS:
return int(_CN_DIGITS[t[0]]) * 10 + int(_CN_DIGITS[t[2]])
return None
def _label_from_ordinal_1based(n1: int, options: list[str]) -> str | None:
if n1 < 1:
return None
idx = n1 - 1
if 0 <= idx < len(options):
return options[idx]
return None
def _choose_from_ordinal_text(raw: str, options: list[str]) -> str | None:
"""从「第一个」「第2个」「选3」「1号」等表述解析选项。返回 None 表示本函数未识别。"""
n_opt = len(options)
if n_opt < 1:
return None
# 1) 显式「第N个/项/款/…」,允许夹带后噪声,如「第一个对」
for m in re.finditer(
r"第([0-9]+|[一二两三四五六七八九十百]+)(?:个|项|款|的|种|名)?", raw
):
n1 = _parse_ordinal_index_1based(m.group(1))
if n1 is not None:
ch = _label_from_ordinal_1based(n1, options)
if ch is not None:
return ch
m_pick = re.search(
r"(?:^|[\s,;:])(?:选|要|就)\s*0*([1-9]\d?)(?:\s*号|个|项|款)?",
raw,
)
if m_pick:
n1 = int(m_pick.group(1))
ch = _label_from_ordinal_1based(n1, options)
if ch is not None:
return ch
norm_for_opt = raw.replace(" ", "").lower()
m_op = re.search(r"(?:option|选项)\s*[:]?\s*(\d+)", norm_for_opt, re.IGNORECASE)
if m_op:
n1 = int(m_op.group(1))
ch = _label_from_ordinal_1based(n1, options)
if ch is not None:
return ch
# 2) 行首/句末「一」「二」单字,仅当候选项数较少时
s = raw.replace(" ", "")
if n_opt <= 3:
m_one = re.match(r"^([一二两三四])$", s)
if m_one:
tok = m_one.group(1)
if tok in _CN_DIGITS and tok not in ("", ""):
n1 = int(_CN_DIGITS[tok])
ch = _label_from_ordinal_1based(n1, options)
if ch is not None:
return ch
m_tail = re.search(r"([0-9一二两三四五六七八九十]+)\s*号$", s)
if m_tail:
n1 = _parse_ordinal_index_1based(m_tail.group(1))
if n1 is not None:
ch = _label_from_ordinal_1based(n1, options)
if ch is not None:
return ch
return None
def parse_voice_choice(asr_text: str, options: list[str]) -> str | None:
"""
从识别文本中解析医生选择的耗材名称。
支持:完全匹配、子串匹配、第 N 个1/一/第一个)。
"""
raw = re.sub(
r"^[。,、;:!?\s]+|[。,、;:!?\s]+$",
"",
(asr_text or "").strip(),
)
if not raw:
return None
normalized = raw.replace(" ", "").lower()
for opt in options:
if opt and opt in raw:
return opt
chosen_ord = _choose_from_ordinal_text(raw, options)
if chosen_ord is not None:
return chosen_ord
m_num = re.search(r"(\d+)", raw)
if m_num:
idx = int(m_num.group(1)) - 1
if 0 <= idx < len(options):
return options[idx]
m_cn = re.search(r"第([一二两三四五六七八九十\d]+)个", raw)
if m_cn:
token = m_cn.group(1)
n1 = int(token) if token.isdigit() else _parse_ordinal_index_1based(token)
if n1 is not None:
ch = _label_from_ordinal_1based(n1, options)
if ch is not None:
return ch
for i, opt in enumerate(options):
if not opt:
continue
aliases = [f"{i + 1}", f"{i + 1}", f"{i + 1}"]
if any(a in normalized for a in aliases):
return opt
negatives = ("不是", "没有", "", "", "")
if any(n in raw for n in negatives):
return None
return None
def match_voice_choice_against_candidates(
asr_text: str, candidates: list[str]
) -> str | None:
"""
在未匹配 pending 展示的 topk 话术时,按本台手术「候选耗材清单」做名称子串匹配。
长名优先,减少短名误命中(如「纱」同时匹配多种耗材时优先更长全称)。
"""
raw = (asr_text or "").strip()
if not raw:
return None
stripped = [c.strip() for c in candidates if c and str(c).strip()]
if not stripped:
return None
for c in sorted(stripped, key=len, reverse=True):
if c in raw:
return c
return None
def is_rejection_phrase(asr_text: str) -> bool:
"""医生明确否认全部候选时返回 True须在 parse_voice_choice 之前调用)。"""
raw = (asr_text or "").strip()
if not raw:
return False
negatives = ("不是", "没有", "", "", "")
return any(n in raw for n in negatives)
def build_prompt_text(options: list[tuple[str, float]]) -> str:
parts = ["请确认刚才使用的耗材是下面哪一项。"]
for i, (name, _conf) in enumerate(options, start=1):
parts.append(f"{i}个,{name}")
return "".join(parts)