- 新增 Alembic 初始迁移、领域明细模型及归档持久化与重试链路\n- 拆分视频会话注册表、分类处理、推理时间窗聚合与流处理\n- 消耗日志:TSV/Markdown 含 top2/top3;item_id 优先产品编码;待确认记「待确认」行,语音确认后落正式行并更新汇总\n- 待确认时内存/DB 明细为占位行,确认后替换;拒绝时移除占位\n- 分类 probs 先 detach/cpu 再转 NumPy,修复 MPS/CUDA 上推理被静默跳过\n- 补充集成测试、归档与设备张量等单测 Made-with: Cursor
190 lines
5.9 KiB
Python
190 lines
5.9 KiB
Python
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)
|