File size: 12,950 Bytes
784ea73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
#!/usr/bin/env python3
"""Red-flag rule engine + scanner for patient questions.

Rules classify an utterance into alert findings (mode=alert) vs grounded.
Produces red_flags[] entries for the router, and lets us measure coverage
over the real patient-question dataset.
"""
import json
import re

# ---------- A. duration / temporal flags ----------
_DUR_RE = [
    # 持续/反复 + 数字时间 → 慢性化红旗 (>=2周 or 反复次数>=3)
    (r"(?:持续|一直|老是|总是|反复|很多|几个月|半年|一年|长期)",
     "chronicity"),
    (r"\d+开头的数字度[dD天周月年数]", "chronicity"),  # placeholder fallback
    # 急性新发
    (r"(?:突然|突发|骤然|一下子|猛地|几分钟内|几小时内)", "acuteness"),
    # 进行性加重
    (r"(?:越来越(?:严重|疼|痛|差)|进行性|加重|恶化|变差|恶化)", "progression"),
]

# numeric-duration → chronicity
#   - >=2 weeks OR >=1 month/year → chronic (例外: 孕周/年龄/发烧"X天"短时长)
#   - Chinese + Arabic numerals both supported
_CN_NUM = {"一":1,"两":2,"二":2,"三":3,"四":4,"五":5,"六":6,"七":7,"八":8,"九":9,"十":10,"半":0.5}
_DUR_UNIT = r"(?:个)?(周|星期|天|日|个月|月|年)"
_DUR_RE = re.compile(r"([0-9]{1,3}(?:\.[0-9])?|[一二两三四五六七八九十半]+?)\s*" + _DUR_UNIT)


def _parse_cn_num(s):
    if s.replace(".", "", 1).isdigit():
        return float(s)
    if s == "半":
        return 0.5
    total = 0
    if "十" in s:
        a, _, b = s.partition("十")
        total = (_CN_NUM.get(a, 0) if a else 1) * 10 + _CN_NUM.get(b, 0)
    else:
        total = sum(_CN_NUM.get(ch, 0) for ch in s)
    return total


def _duration_flags(text):
    flags = []
    lo = text.lower()
    seen = set()
    for m in _DUR_RE.finditer(text):
        n = _parse_cn_num(m.group(1))
        unit = m.group(2)
        seg_start = max(0, m.start() - 14)
        ctx = text[seg_start:m.start()]
        # skip pregnancy/gestation duration
        if re.search(r"(?:怀孕|孕|妊娠|孕周|胎)", ctx):
            continue
        # skip age ("2岁" handled separately; "半岁/月龄" may appear but not chronic)
        if re.search(r"(?:岁|出生|生后|月龄)", ctx) and unit in ("周", "天", "个月"):
            continue
        is_chronic = False
        if unit in ("年",):
            is_chronic = True
        elif unit in ("个月", "月"):
            is_chronic = n >= 1
        elif unit in ("周", "星期"):
            is_chronic = n >= 2
        elif unit in ("天", "日"):
            # 发热/咳嗽短时长不算慢性,但>=2周(>=14天)或伴随"持续"算
            if n >= 14:
                is_chronic = True
            elif re.search(r"(?:持续|一直|老是|总是|反复)", ctx) and n >= 7:
                is_chronic = True
        if is_chronic and "chronicity" not in seen:
            flags.append(({"type": "chronicity", "severity": "high"}, "duration:" + m.group(0)))
            seen.add("chronicity")
    # recurrence (Chinese/Arabic)
    rec = re.compile(r"([0-9]{1,2}|[一二两三四五六七八九十]+)\s*次")
    for m in rec.finditer(text):
        n = _parse_cn_num(m.group(1))
        if n >= 3 and re.search(r"反复|复发|老是|常|每次|每个月|经常", text):
            flags.append(({"type": "chronicity", "severity": "high"}, "recurrent:" + m.group(0)))
            break
    if re.search(r"(?:突然|突发|骤然|一下子|猛地|爆裂)", lo):
        flags.append(({"type": "acuteness", "severity": "high"}, "acute-onset"))
    if re.search(r"(?:越来越(?:严重|疼|痛|差)|进行性加重|加重|恶化)", lo):
        flags.append(({"type": "progression", "severity": "high"}, "worsening"))
    return flags


# ---------- B. dangerous-signal entity flags ----------
_ENTITY_RULES = [
    (r"(?:剧烈|此生最痛|爆裂样|刀劈样).{0,6}(头痛|头疼)", "redflag_pain", "worst-ever-headache"),
    (r"(?:突?发)?(?:言语不清|说话.*?含糊|口齿不清|嘴巴歪|口角歪斜|面瘫|单侧肢体无力|一侧手脚麻木|吐字不清|视物重影|构音障碍)", "neuro", "acute-neuro-defecit"),
    (r"(?:胸痛|胸闷).{0,10}(?:压榨|憋闷|放射|冷汗|濒死|到|放射)", "cardiac", "chest-pain"),
    (r"呼吸困难|气促|喘不上气|憋气", "respiratory", "dyspnea"),
    (r"(?:呕血|吐血|咯血|黑便|柏油样便)", "gi_bleed", "gi-bleeding"),
    (r"(?:高热|高烧|发烧|发热)\D{0,6}(?:39(?:\.\d)?|[4-9]\d)\s*(?:度|℃|°c|摄氏度)|发烧?(?:39(?:\.\d)?|[4-9]\d)\s*度", "fever", "high-fever"),
    (r"脖子僵硬|颈项强直|抽搐|惊厥|意识(?:模糊|不清|丧失)|昏(?:迷|睡)", "neuro_alert", "meningitis-seizure-altered"),
    (r"(?:血尿|无尿|少尿)", "renal", "hematuria"),
    (r"(?:莫名|不明原因|突然|止不住).{0,6}(?:出血|流血)", "bleed", "spontaneous-bleeding"),
    (r"(?:突然)?心慌|心悸|心跳快.{0,6}(?:闷|痛|晕|歇)|(?:心慌|心悸).{0,6}(?:冒汗|冷汗|晕)", "cardiac", "palpitations"),
    (r"血压高\D{0,8}(?:一直|总是|老是|持续)|(?:一直|总是|老是|持续)\D{0,6}血压(?:高|150|\d{3}/)", "chronicity", "sustained-hypertension"),
    (r"(?:头晕|头昏).{0,8}(?:站不稳|晕倒|摔倒|天旋地转|走不了)", "neuro", "dizziness-unsteady"),
    (r"(?:吃什么都吐|一直吐|不停吐|反复呕吐|吐个不停|吃什么吐什么)", "gi", "persistent-vomiting"),
    (r"(?:背痛|腰痛)\D{0,10}(?:发热|发烧)|(?:发热|发烧)\D{0,4}(?:背痛|腰痛)", "infect", "back-pain-with-fever"),
    (r"(?:皮疹|荨麻疹).{0,8}(?:唇舌(?:肿|发麻)|喉头|呼吸困难)|(?:速发过敏|过敏性休克)", "anaphylaxis", "anaphylaxis"),
]


def _entity_flags(text):
    flags = []
    for pat, cat, tag in _ENTITY_RULES:
        if re.search(pat, text, re.IGNORECASE):
            flags.append(({"type": cat, "severity": "high"}, tag))
    return flags


# ---------- C. population / status flags ----------
_POP_RULES = [
    (r"怀孕|孕妇|妊娠", "pregnancy"),
    (r"(?:婴?儿|新生(?:儿|宝宝))|不足?\d*个?月?宝宝", "infant"),
    (r"\d+\s*(?:岁|周岁)", "elderly_anyage"),
    (r"\d\s*(?:岁|周岁|个月大|月龄)", "child_anyage"),
    (r"化疗|放疗|移植|免疫抑制|免疫力差|hiv|艾滋病", "immunocompromised"),
    (r"抗凝|抗血小板|华法林|阿司匹林|氯吡格雷|利伐沙班|达比加群", "anticoagulant"),
    (r"刚(?:手术|做完手术|出院)", "recent_surgery"),
]


def _pop_flags(text):
    flags = []
    child_age = None
    for pat, tag in _POP_RULES:
        m = re.search(pat, text, re.IGNORECASE)
        if m:
            if tag == "elderly_anyage":
                for am in re.finditer(r"(\d+)\s*(?:岁|周岁)", text):
                    if int(am.group(1)) >= 75:
                        flags.append(({"type": "population", "severity": "medium"}, "elderly:" + am.group(0)))
                        break
            elif tag == "child_anyage":
                for am in re.finditer(r"(\d)\s*(?:岁|周岁)", text):
                    child_age = int(am.group(1))
                    break
            elif tag == "infant":
                flags.append(({"type": "population", "severity": "medium"}, "infant"))
            else:
                flags.append(({"type": "population", "severity": "medium"}, tag))
    if child_age is not None and child_age <= 12:
        flags.append(({"type": "population", "severity": "medium"}, f"child:{child_age}岁"))
    return flags


# ---------- intent + category (minimal, for coverage stats) ----------
# Keys MUST match the 30 per-category FAISS index dirs in data/rag/ so the
# router output aligns with multi-RAG lookups.
_CATEGORY_KEYS = {
    "brain-and-nerves": [r"headache|头痛|头疼|偏头痛|脑|神经|癫痫|中风|stroke|头晕|失眠"],
    "heart-and-blood-vessel-disease": [r"心|heart|cardiac|血压|胸痛|blood pressure|心慌|心悸"],
    "diabetes": [r"糖尿|diabetes|血糖|insulin|胰岛素"],
    "pregnancy-and-childbirth": [r"怀孕|孕|pregnan|分娩|生产|baby|宝宝"],
    "childrens-health": [r"小儿|儿童|child|婴儿|幼童|新生儿"],
    "cancer": [r"癌|cancer|tumor|肿瘤|chemotherapy|化疗"],
    "allergies-and-asthma": [r"哮喘|asthma|过敏|allerg|鼻炎|花粉|喷嚏"],
    "mental-health": [r"焦虑|抑郁|depress|anxiet|情绪|精神|恐慌|惊恐"],
    "bones-joints-and-muscles": [r"关节炎|关节|骨|osteo|骨刺|腰背痛|膝盖|肩|肌肉"],
    "gastrointestinal-system": [r"胃|肠|消化|腹痛|反酸|胃食|diarrhea|便秘|nausea|腹胀"],
    "lung-disease": [r"肺|呼吸|咳嗽|cough|lung|慢性阻塞|copd|哮喘"],
    "skin-hair-and-nails": [r"皮肤|皮疹|skin|湿疹|荨麻疹|脱发|指甲"],
    "kidneys-and-urinary-system": [r"肾|尿|kidney|膀胱|泌尿|结石"],
    "blood-disorders": [r"贫血|anemia|血小|凝血|出血|血友病"],
    "infections-and-vaccines": [r"感染|传染|vaccin|疫苗|流感|flu|发烧|发热"],
    "womens-health-issues": [r"月经|卵巢|子宫|乳房|breast|menopaus|妇科|绝经|痛经"],
    "mens-health-issues": [r"前列腺|阳痿|睾丸|prostat|男性"],
    "eyes-and-vision": [r"眼|eye|视力|青光眼|白内障|近视"],
    "ear-nose-and-throat": [r"耳|鼻|喉|耳痛|耳鸣|咽|扁桃体"],
    "hormones": [r"甲状腺|thyroid|激素|内分泌|睾酮|雌激素"],
    "sleep": [r"失眠|睡眠|打鼾|sleep|insomnia"],
    "senior-health": [r"老年|老|跌|痴呆|失禁|骨质疏松|髋|老人"],
    "travel-health": [r"旅行|出差|travel|疫苗"],
    "diet-and-weight": [r"饮食|减肥|体重|diet|营养|肥胖"],
    "liver-disease": [r"肝|liver|肝炎|肝硬化|转氨酶"],
    "arthritis": [r"风湿|arthritis|类风湿|痛风"],
    "autoimmune-disease": [r"自身免疫|autoimmun|狼疮|lupus"],
    "surgery": [r"手术|surgery|术后|切口|麻醉"],
    "hiv-and-aids": [r"\bhiv|aids|艾滋病"],
    "general-health": [r".*"],
}
_ORDER = [c for c in _CATEGORY_KEYS if c != "general-health"] + ["general-health"]

_INTENT_RULES = [
    ("self_care", r"(?:自己|在家|非药|免吃药|缓解|舒缓|不做手术|自行|anything.*on my own|how to feel better|do to feel better)"),
    ("when_seek_care", r"(?:就医|看医生|急诊|去医院|call.*doctor|see.*doctor|何时.*(?:就医|看)|emergency|什么时候.*医院)"),
    ("symptoms", r"(?:症状|symptom)"),
    ("cause", r"(?:原因|导致|造成|cause|why.*(?:得|有|需要))"),
    ("diagnosis", r"(?:检查|诊断|测试|确诊|test|screen|检测)"),
    ("treatment", r"(?:治疗|treat|用药|吃药|方案|procedure|手术|therapy)"),
    ("prevention", r"(?:预防|prevent|避免|防范|keep from)"),
    ("prognosis", r"(?:恢复|预后|康复|会(?:不会)?好|能否.*好|after|手术后|恢复期)"),
    ("safety_risk", r"(?:安全|副作用|风险|危险|side effect|complicat)"),
    ("diet", r"(?:饮食|吃|食物|diet|food|营养)"),
    ("lifestyle", r"(?:运动|锻炼|exercise|活动量|生活方式)"),
    ("definition", r""),
]


def scan(text):
    """Return (red_flags, sub_intents) for a patient utterance."""
    red = []
    for data, tag in _duration_flags(text):
        data["trigger"] = tag
        red.append(data)
    for data, tag in _entity_flags(text):
        data["trigger"] = tag
        red.append(data)
    for data, tag in _pop_flags(text):
        data["trigger"] = tag
        red.append(data)
    # severity cap: population flags alone don't force alert
    has_alert = any(f["type"] in ("chronicity", "acuteness", "progression") or
                   f["type"] not in ("population",) for f in red)
    mode = "alert" if has_alert else "grounded"

    # category
    lo = text.lower()
    cat = "general-health"
    for c in _ORDER:
        if any(re.search(p, lo) for p in _CATEGORY_KEYS[c]):
            cat = c
            break
    # intent
    intent = "definition"
    for it, pat in _INTENT_RULES:
        if pat and re.search(pat, lo):
            intent = it
            break
    subs = [{"intent": intent, "entity": "", "category": cat,
             "level": "basics", "tmpl": intent, "risk": "high" if red else "low"}]
    return red, subs, mode


def main():
    rows = [json.loads(l) for l in open("data/patient_questions_clean.jsonl", encoding="utf-8")]
    n_alert = n_ground = 0
    alert_by_type = {}
    print(f"total questions: {len(rows)}")
    for r in rows:
        red, _, mode = scan(r["question"])
        if mode == "alert":
            n_alert += 1
            for f in red:
                alert_by_type[f["type"]] = alert_by_type.get(f["type"], 0) + 1
        else:
            n_ground += 1
    print(f"mode=alert : {n_alert} ({100*n_alert/len(rows):.1f}%)")
    print(f"mode=ground: {n_ground} ({100*n_ground/len(rows):.1f}%)")
    print("alert by type:")
    for k, v in sorted(alert_by_type.items(), key=lambda x: -x[1]):
        print(f"  {v:5d}  {k}")


if __name__ == "__main__":
    main()