Files
operating-room-monitor-server/app/services/voice_confirm.py

190 lines
5.9 KiB
Python
Raw Normal View History

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)