AI 回复新增用药速览,中药/西药药名摘出并高亮

- 新增 ai_consult_drug_names:中药饮片、方剂/中成药、西药显式词表,长词优先识别,西药按通用名去重
- AI 回复标题下新增“用药速览”卡片:中药绿、西药蓝色块
- 正文药名同色浅底加粗高亮,剂量数字不着色;Markdown 回退路径同样保留底色
- 新增药名识别测试,更新药物剂量着色测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gr
2026-10-27 15:31:48 +08:00
co-authored by Claude Opus 5.5
parent bc3bae97af
commit fc73dd1aca
5 changed files with 285 additions and 6 deletions
@@ -63,6 +63,7 @@ from shiboken6 import isValid
from .. import icons
from ..chat_items import parse_chat_item as _parse_chat_item
from ..diagnosis_drawer import CaseGrid, _RemoteImageButton
from ..diagnosis_index_widgets import FlowWidget
from ..diagnosis_media import (
ImagePreviewDialog,
is_previewable_image,
@@ -100,6 +101,7 @@ from .ai_consult_metric_state import (
combined_metric_state,
risk_color,
)
from .ai_consult_drug_names import TCM as DRUG_TCM, WESTERN as DRUG_WESTERN, summarize_drugs
from .ai_consult_metric_text import MetricTextState, metric_text_context, metric_text_html
from .ai_consult_report_visuals import (
ReportVisual,
@@ -812,6 +814,51 @@ QDialog#AiConsultDialog QLabel#AiConsultSideRequestStatus[tone="failed"] {
background-color: #FFF1F3;
border-color: #F5C2C7;
}
QDialog#AiConsultDialog QFrame#AiConsultDrugStrip {
background-color: #FFFFFF;
border: 1px solid #E3E6EF;
border-radius: 12px;
}
QDialog#AiConsultDialog QLabel#AiConsultDrugStripTitle {
color: #1A1C1F;
background-color: transparent;
font-size: 14px;
font-weight: 600;
}
QDialog#AiConsultDialog QLabel#AiConsultDrugStripMeta {
color: #8A8B8D;
background-color: transparent;
font-size: 12px;
}
QDialog#AiConsultDialog QLabel#AiConsultDrugKind {
min-width: 40px;
max-width: 40px;
min-height: 26px;
max-height: 26px;
color: #FFFFFF;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
QDialog#AiConsultDialog QLabel#AiConsultDrugKind[kind="tcm"] { background-color: #1F7A4D; }
QDialog#AiConsultDialog QLabel#AiConsultDrugKind[kind="western"] { background-color: #3347B8; }
QDialog#AiConsultDialog QLabel#AiConsultDrugChip {
min-height: 24px;
padding: 0 10px;
border-radius: 7px;
font-size: 14px;
font-weight: 700;
}
QDialog#AiConsultDialog QLabel#AiConsultDrugChip[kind="tcm"] {
color: #1F7A4D;
background-color: #EAF7F0;
border: 1px solid #BFE6D0;
}
QDialog#AiConsultDialog QLabel#AiConsultDrugChip[kind="western"] {
color: #3347B8;
background-color: #EEF1FA;
border: 1px solid #C9D1F3;
}
QDialog#AiConsultDialog QLabel#AiConsultSideRequestStatus[tone="cancelled"] {
color: #9A5B00;
background-color: #FFF7E6;
@@ -2682,6 +2729,9 @@ def _highlight_document_metrics(document: QTextDocument) -> None:
style = QTextCharFormat()
style.setForeground(foreground)
style.setFontWeight(fragment.charFormat().fontWeight())
background = fragment.charFormat().background()
if background.style() != Qt.BrushStyle.NoBrush:
style.setBackground(background)
cursor.setPosition(fragment.position())
cursor.setPosition(fragment.position() + fragment.length(), QTextCursor.MoveMode.KeepAnchor)
cursor.mergeCharFormat(style)
@@ -2712,6 +2762,59 @@ def split_report_lead(item: str) -> tuple[str, str]:
return label, body
class _DrugSummaryStrip(QFrame):
"""用药速览:把回复中提到的中药、西药摘成色块,医生不读正文也能一眼看到用药。"""
def __init__(self, summary: Mapping[str, Sequence[str]], parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("AiConsultDrugStrip")
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
_style_surface(self)
layout = QVBoxLayout(self)
layout.setContentsMargins(14, 11, 14, 12)
layout.setSpacing(8)
total = sum(len(names) for names in summary.values())
head = QHBoxLayout()
head.setContentsMargins(0, 0, 0, 0)
head.setSpacing(8)
title = QLabel("用药速览", self)
title.setObjectName("AiConsultDrugStripTitle")
head.addWidget(title)
count = QLabel(f"共 {total} 种,正文中同色高亮", self)
count.setObjectName("AiConsultDrugStripMeta")
head.addWidget(count)
head.addStretch(1)
layout.addLayout(head)
for kind, label in ((DRUG_TCM, "中药"), (DRUG_WESTERN, "西药")):
names = list(summary.get(kind) or ())
if not names:
continue
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(10)
tag = QLabel(label, self)
tag.setObjectName("AiConsultDrugKind")
tag.setProperty("kind", kind)
tag.setAlignment(Qt.AlignmentFlag.AlignCenter)
row.addWidget(tag, 0, Qt.AlignmentFlag.AlignTop)
chips = FlowWidget(self, horizontal_spacing=6, vertical_spacing=6)
for name in names:
chip = QLabel(name, chips)
chip.setObjectName("AiConsultDrugChip")
chip.setProperty("kind", kind)
chip.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
chips.flow.addWidget(chip)
row.addWidget(chips, 1)
layout.addLayout(row)
@staticmethod
def for_text(text: str, parent: QWidget | None = None) -> "_DrugSummaryStrip | None":
summary = summarize_drugs(text)
return _DrugSummaryStrip(summary, parent) if any(summary.values()) else None
class _StructuredReportPanel(QWidget):
"""Scan-first report for any sectioned reply.
@@ -3645,6 +3748,7 @@ class _ChatBubble(QWidget):
parent=self._bubble_frame,
)
self._reset_clinical_panel()
drug_strip = _DrugSummaryStrip.for_text(self._raw_payload)
if panel is None:
document = self.body.document()
layout_enabled = document.isLayoutEnabled()
@@ -3660,9 +3764,17 @@ class _ChatBubble(QWidget):
visual = _report_visual_widget(report_visuals, self._bubble_frame)
self._standalone_visuals.append(visual)
self._inner.insertWidget(1, visual)
if drug_strip is not None:
drug_strip.setParent(self._bubble_frame)
self._standalone_visuals.append(drug_strip)
self._inner.insertWidget(1, drug_strip)
return False
if report_visuals:
panel.layout().insertWidget(1, _report_visual_widget(report_visuals, panel))
# 用药速览紧跟标题行,排在图表与正文之前。
if drug_strip is not None:
drug_strip.setParent(panel)
panel.layout().insertWidget(1, drug_strip)
panel.setParent(self._bubble_frame)
self._clinical_panel = panel
self._role_name.hide()
@@ -0,0 +1,121 @@
"""从 AI 回复中摘出药名(中药 / 西药),供用药速览与正文高亮使用。
只用显式词表匹配,不按“…汤/…片”等后缀猜测,宁可漏标也不把普通词误标成药。
匹配按长词优先,所以「六味地黄丸」不会被拆成「地黄」。
"""
from __future__ import annotations
import re
from typing import NamedTuple
TCM = "tcm"
WESTERN = "western"
# 中药绿、西药蓝;与侧栏状态卡的完成/进行中色一致。
DRUG_COLORS = {
TCM: ("#1F7A4D", "#EAF7F0", "#BFE6D0"),
WESTERN: ("#3347B8", "#EEF1FA", "#C9D1F3"),
}
DRUG_STYLES = {
kind: f"color: {fg}; background-color: {bg}; font-weight: 700;"
for kind, (fg, bg, _border) in DRUG_COLORS.items()
}
# 常用中药饮片(两字及以上,避免单字误配)。
_TCM_HERBS = """
黄芪 党参 人参 西洋参 太子参 红参 白术 苍术 茯苓 茯神 土茯苓 猪苓 泽泻 薏苡仁 甘草 炙甘草
当归 川芎 白芍 赤芍 熟地黄 生地黄 熟地 生地 地黄 山茱萸 山萸肉 牡丹皮 丹皮 丹参 三七 红花 桃仁
黄连 黄芩 黄柏 栀子 知母 石膏 天花粉 葛根 麦冬 天冬 玉竹 黄精 石斛 沙参 北沙参 南沙参 百合
五味子 枸杞子 女贞子 墨旱莲 菟丝子 桑椹 覆盆子 金樱子 芡实 怀山药 鸡内金 神曲 麦芽 山楂
陈皮 青皮 枳实 枳壳 厚朴 半夏 法半夏 姜半夏 竹茹 干姜 炮姜 桂枝 肉桂 附子 制附子
柴胡 升麻 防风 荆芥 羌活 独活 秦艽 威灵仙 桑枝 桑叶 菊花 薄荷 蝉蜕 牛蒡子 连翘 金银花 蒲公英
板蓝根 大青叶 鱼腥草 败酱草 白花蛇舌草 夏枯草 决明子 车前子 车前草 木通 通草 滑石 萹蓄 瞿麦
茵陈 金钱草 虎杖 大黄 芒硝 火麻仁 郁李仁 杏仁 苦杏仁 桔梗 紫菀 款冬花 百部 川贝母 浙贝母 贝母
瓜蒌 全瓜蒌 薤白 远志 酸枣仁 柏子仁 夜交藤 首乌藤 合欢皮 龙骨 牡蛎 珍珠母 磁石 天麻 钩藤 石决明
地龙 全蝎 蜈蚣 僵蚕 水蛭 土鳖虫 鸡血藤 牛膝 怀牛膝 川牛膝 杜仲 续断 桑寄生 狗脊 骨碎补 补骨脂
淫羊藿 仙茅 巴戟天 肉苁蓉 锁阳 益智仁 乌药 小茴香 吴茱萸 木香 香附 郁金 延胡索 川楝子 佛手
砂仁 白豆蔻 豆蔻 草豆蔻 藿香 佩兰 苍耳子 辛夷 白芷 细辛 麻黄 紫苏 苏叶 紫苏叶 苏子 紫苏子
乌梅 五倍子 诃子 浮小麦 麻黄根 山茱萸肉 何首乌 制何首乌 阿胶 鹿角胶 龟甲 鳖甲 玄参 牛黄 冰片
麝香 苏合香 石菖蒲 益母草 泽兰 王不留行 莪术 三棱 乳香 没药 五灵脂 蒲黄 茜草 白茅根 侧柏叶
仙鹤草 艾叶 地榆 槐花 小蓟 大蓟 白及 荷叶 绞股蓝 桑白皮 地骨皮 青蒿 银柴胡 胡黄连 白薇
苦参 白鲜皮 地肤子 蛇床子 黄药子 海藻 昆布 荔枝核 橘核 鬼箭羽 翻白草 玉米须 冬瓜皮 赤小豆
""".split()
# 常用方剂与中成药(含糖尿病常用)。
_TCM_FORMULAS = """
六味地黄丸 知柏地黄丸 杞菊地黄丸 麦味地黄丸 金匮肾气丸 济生肾气丸 桂附地黄丸 左归丸 右归丸
玉泉丸 消渴丸 参芪降糖颗粒 津力达颗粒 天芪降糖胶囊 渴乐宁胶囊 金芪降糖片 降糖舒胶囊 糖脉康颗粒
芪蛭降糖胶囊 木丹颗粒 通心络胶囊 复方丹参滴丸 银杏叶片 血塞通 血栓通 丹参酮 脑心通胶囊
补中益气丸 补中益气汤 归脾丸 归脾汤 逍遥丸 逍遥散 加味逍遥丸 丹栀逍遥散 柴胡疏肝散 四逆散
四君子汤 六君子汤 香砂六君子汤 参苓白术散 理中丸 附子理中丸 四物汤 八珍汤 十全大补汤 当归补血汤
生脉饮 生脉散 玉女煎 白虎加人参汤 白虎汤 葛根芩连汤 黄连解毒汤 半夏泻心汤 大柴胡汤 小柴胡汤
血府逐瘀汤 补阳还五汤 桃红四物汤 二陈汤 温胆汤 黄连温胆汤 天麻钩藤饮 镇肝熄风汤 酸枣仁汤
天王补心丹 一贯煎 增液汤 沙参麦冬汤 益胃汤 竹叶石膏汤 五苓散 猪苓汤 真武汤 实脾饮 防己黄芪汤
玉屏风散 桂枝汤 麻黄汤 银翘散 桑菊饮 藿香正气散 藿香正气水 保和丸 平胃散 二妙丸 三妙丸 四妙丸
当归四逆汤 黄芪桂枝五物汤 独活寄生汤 身痛逐瘀汤 大补阴丸 二至丸 交泰丸 安宫牛黄丸 浓缩水丸
""".split()
# 西药(以内分泌、心血管、常见合并用药为主)。
_WESTERN = """
二甲双胍 盐酸二甲双胍 格列美脲 格列齐特 格列吡嗪 格列本脲 格列喹酮 瑞格列奈 那格列奈 阿卡波糖
伏格列波糖 米格列醇 吡格列酮 罗格列酮 西格列汀 沙格列汀 维格列汀 利格列汀 阿格列汀 达格列净
恩格列净 卡格列净 艾托格列净 利拉鲁肽 司美格鲁肽 度拉糖肽 艾塞那肽 利司那肽 贝那鲁肽 替尔泊肽
胰岛素 门冬胰岛素 赖脯胰岛素 谷赖胰岛素 甘精胰岛素 地特胰岛素 德谷胰岛素 精蛋白锌胰岛素
重组人胰岛素 生物合成人胰岛素 预混胰岛素 硝苯地平 氨氯地平 左旋氨氯地平 非洛地平 尼群地平
贝尼地平 厄贝沙坦 缬沙坦 氯沙坦 替米沙坦 坎地沙坦 奥美沙坦 沙库巴曲缬沙坦 依那普利 贝那普利
培哚普利 卡托普利 福辛普利 美托洛尔 比索洛尔 卡维地洛 阿替洛尔 氢氯噻嗪 吲达帕胺 呋塞米
螺内酯 托拉塞米 阿托伐他汀 瑞舒伐他汀 辛伐他汀 普伐他汀 匹伐他汀 依折麦布 非诺贝特 苯扎贝特
阿司匹林 氯吡格雷 替格瑞洛 华法林 利伐沙班 达比加群 硫酸氢氯吡格雷 甲钴胺 依帕司他 硫辛酸
α-硫辛酸 胰激肽原酶 羟苯磺酸钙 前列地尔 贝前列素钠 别嘌醇 非布司他 苯溴马隆 碳酸氢钠
左甲状腺素 甲巯咪唑 丙硫氧嘧啶 奥美拉唑 泮托拉唑 雷贝拉唑 埃索美拉唑 雷尼替丁 法莫替丁
多潘立酮 莫沙必利 二甲硅油 乳果糖 阿莫西林 头孢呋辛 左氧氟沙星 莫西沙星 阿奇霉素 布洛芬
对乙酰氨基酚 塞来昔布 加巴喷丁 普瑞巴林 碳酸钙 骨化三醇 维生素D 维生素B12 叶酸 硝酸甘油
单硝酸异山梨酯 曲美他嗪 地高辛 胺碘酮 坦索罗辛 非那雄胺 西地那非 他达拉非
""".split()
# 西药常带剂型后缀;一起摘出,展示时保留原文写法。
_WESTERN_FORM = r"(?:缓释片|控释片|肠溶片|分散片|片|缓释胶囊|肠溶胶囊|胶囊|注射液|注射剂|笔|颗粒)?"
class DrugMention(NamedTuple):
start: int
end: int
# 去掉剂型后缀的药名,用于速览去重;高亮范围仍覆盖原文中的完整写法。
name: str
kind: str
def _alternation(names: list[str]) -> str:
return "|".join(re.escape(name) for name in sorted(set(names), key=len, reverse=True))
# 单一正则、长词优先:中成药 > 西药 > 饮片,保证一个位置只被归入一类。
_DRUG_RE = re.compile(
rf"(?P<tcm_formula>{_alternation(_TCM_FORMULAS)})"
rf"|(?P<western>(?P<western_base>{_alternation(_WESTERN)}){_WESTERN_FORM})"
rf"|(?P<herb>{_alternation(_TCM_HERBS)})"
)
def find_drug_mentions(text: str) -> list[DrugMention]:
"""返回正文中每一处药名的位置与类别(不重叠、按出现顺序)。"""
mentions: list[DrugMention] = []
for match in _DRUG_RE.finditer(str(text or "")):
if match.group("western"):
mentions.append(DrugMention(match.start(), match.end(), match.group("western_base"), WESTERN))
else:
mentions.append(DrugMention(match.start(), match.end(), match.group(), TCM))
return mentions
def summarize_drugs(text: str) -> dict[str, list[str]]:
"""按类别去重汇总药名,保留首次出现的顺序。"""
summary: dict[str, list[str]] = {TCM: [], WESTERN: []}
for mention in find_drug_mentions(text):
names = summary[mention.kind]
if mention.name not in names:
names.append(mention.name)
return summary
@@ -15,6 +15,8 @@ from contextvars import ContextVar
from dataclasses import dataclass
from html import escape
from .ai_consult_drug_names import DRUG_STYLES, find_drug_mentions
from .ai_consult_metric_state import (
ABNORMAL_COLOR,
NORMAL_COLOR,
@@ -393,18 +395,23 @@ def metric_text_html(text: str, *, metric_label: str = "") -> str:
if metric_label and _MISSING_PLACEHOLDER_RE.fullmatch(text):
spans.append((0, len(text), UNKNOWN_COLOR))
# 药名高亮:中药绿、西药蓝,浅底加粗,让医生在正文里一眼看到用药。
styled = [(start, end, f"color: {color}; font-weight: 600;") for start, end, color in spans]
styled.extend(
(mention.start, mention.end, DRUG_STYLES[mention.kind])
for mention in find_drug_mentions(text)
)
# Prefer a full reading over a contained unitless value, then consume each
# original character once. Generated markup is never scanned by a regex.
spans.sort(key=lambda span: (span[0], -(span[1] - span[0])))
styled.sort(key=lambda span: (span[0], -(span[1] - span[0])))
pieces = ['<span style="white-space: pre-wrap;">']
position = 0
for start, end, color in spans:
for start, end, style in styled:
if start < position:
continue
pieces.append(escape(text[position:start]))
pieces.append(
f'<span style="color: {color}; font-weight: 600;">{escape(text[start:end])}</span>'
)
pieces.append(f'<span style="{style}">{escape(text[start:end])}</span>')
position = end
pieces.append(escape(text[position:]))
pieces.append("</span>")
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
from doctor_workstation.ui.dialogs.ai_consult_drug_names import (
TCM,
WESTERN,
find_drug_mentions,
summarize_drugs,
)
def test_formula_wins_over_contained_herb() -> None:
mentions = find_drug_mentions("可参考六味地黄丸加减")
assert [(m.name, m.kind) for m in mentions] == [("六味地黄丸", TCM)]
def test_western_dosage_form_is_highlighted_but_deduplicated_by_base_name() -> None:
text = "在用二甲双胍胶囊,二甲双胍早晚各1片,加用阿卡波糖"
mentions = find_drug_mentions(text)
assert text[mentions[0].start:mentions[0].end] == "二甲双胍胶囊"
assert summarize_drugs(text) == {TCM: [], WESTERN: ["二甲双胍", "阿卡波糖"]}
def test_herbs_and_western_drugs_are_split_by_kind() -> None:
summary = summarize_drugs("黄芪、丹参益气活血;格列美脲注意低血糖;黄芪可加量")
assert summary == {TCM: ["黄芪", "丹参"], WESTERN: ["格列美脲"]}
def test_plain_text_and_diet_words_are_not_drugs() -> None:
assert summarize_drugs("建议多吃山药、大枣,少吃甜食,规律运动") == {TCM: [], WESTERN: []}
+11 -1
View File
@@ -118,7 +118,6 @@ def test_current_readings_after_targets_do_not_keep_reference_color() -> None:
"诊单ID:123456;patient_id=1501;流水ABC123456;电话13800138000",
"2026-08-18,2026/08/19,08:30,第1章,1. 检查,2. 处理",
"病程3年,持续2周,随访7天,每次30分钟",
"二甲双胍500mg,每日2次;胰岛素10U;阿司匹林100 mg",
"剂量0.2mg/kg、浓度5mg/mL,外用2%利多卡因",
"统计显示80%的患者改善20%,目标完成率90%,报销比例50%",
"BMI2026-08-18;eGFR2026/08/19;heart_rate2026-08-18",
@@ -131,6 +130,17 @@ def test_unrelated_numbers_and_drug_doses_remain_unemphasized(text: str) -> None
assert "color:" not in rendered
def test_drug_names_are_highlighted_but_their_doses_stay_plain() -> None:
text = "二甲双胍500mg,每日2次;胰岛素10U;阿司匹林100 mg;黄芪30g"
rendered = metric_text_html(text)
for name in ("二甲双胍", "胰岛素", "阿司匹林"):
assert f'background-color: #EEF1FA; font-weight: 700;">{name}</span>' in rendered
assert 'background-color: #EAF7F0; font-weight: 700;">黄芪</span>' in rendered
for dose in ("500mg", "10U", "100 mg", "30g"):
assert f'font-weight: 600;">{dose}' not in rendered
assert f'font-weight: 700;">{dose}' not in rendered
def test_explicit_statuses_are_emphasized_without_classifying_the_numbers() -> None:
document = _document("血糖6.0 mmol/L未达标;血压140/90mmHg正常;血糖偏高;检查异常")
assert _color(document, "6.0 mmol/L") == "#BD4B58"