Spaces:
Sleeping
Sleeping
File size: 14,289 Bytes
af61b34 | 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 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 | """
Temporal Preprocessor for Clinical Intent Classification.
Rule-based preprocessing layer that runs BEFORE the ML model to handle
temporal context that BERT struggles with:
1. BUT-clause reversals: "I felt better but now the pain is back"
β Extracts the active complaint, discards the resolved prefix.
2. Proxy/third-party urgency: "My mom says her chest hurts really bad"
β Detects active urgency even when reported through a third party.
3. Still-active markers after past tense: "I had pain and it's getting worse"
β Flags as active despite past-tense opener.
Returns:
TemporalSignal with:
- processed_text: cleaned text for model input
- is_active_emergency: True if active urgency detected despite temporal markers
- temporal_tag: "active" | "resolved" | "mixed" | "neutral"
- confidence: how confident the preprocessor is in its tag
- reason: human-readable explanation
"""
import re
from dataclasses import dataclass, field
from typing import Optional, List
@dataclass
class TemporalSignal:
"""Result of temporal preprocessing."""
processed_text: str # Text after preprocessing (may be modified)
original_text: str # Original unmodified text
is_active_emergency: bool # True if active urgency detected
temporal_tag: str # "active" | "resolved" | "mixed" | "neutral"
confidence: float # 0.0-1.0 confidence in the tag
reason: Optional[str] = None # Human-readable explanation
proxy_report: bool = False # True if someone reporting for another person
matched_rules: List[str] = field(default_factory=list)
# ββ BUT-clause reversal patterns ββ
# These indicate the speaker HAD a resolved condition but it's NOW active again
BUT_CLAUSE_PATTERNS = [
# "felt better but now..."
re.compile(
r"(?P<resolved>.+?)\b(?:but|however|except|although|though|yet)\b\s+"
r"(?P<active>(?:now|today|currently|right now|at this moment|this morning|tonight|again)"
r".+)",
re.IGNORECASE
),
# "was fine but started..."
re.compile(
r"(?P<resolved>.+?)\b(?:but|however|except)\b\s+"
r"(?P<active>(?:it'?s?|the pain|the symptoms?|i'?m|i am|i feel|i have|it has|they)"
r"\s+(?:back|returned|worse|coming back|started|acting up|flaring).+)",
re.IGNORECASE
),
# "...but now I can't..."
re.compile(
r"(?P<resolved>.+?)\b(?:but|however|except)\b\s+"
r"(?P<active>(?:now\s+)?i\s+(?:can'?t|cannot|am having|have|feel|'m).+)",
re.IGNORECASE
),
# "...but then I started / but woke up with / but noticed..."
re.compile(
r"(?P<resolved>.+?)\b(?:but|however|except)\b\s+"
r"(?P<active>(?:then|woke up|noticed|started|began|suddenly|this morning)\b.+)",
re.IGNORECASE
),
]
# ββ General contrastive marker ββ
# Detects ANY contrastive clause to split and re-evaluate
CONTRASTIVE_MARKER = re.compile(
r"\b(?:but|however|yet|still|though|although|except)\b",
re.IGNORECASE
)
# ββ Still-active markers ββ
# If these appear AFTER past-tense language, the condition is STILL active
STILL_ACTIVE_PATTERNS = [
re.compile(r"\b(?:still|again|keeps?|won'?t stop|getting worse|not getting better)\b", re.IGNORECASE),
re.compile(r"\b(?:came back|come back|coming back|returned|recurring|back again)\b", re.IGNORECASE),
re.compile(r"\b(?:right now|at this moment|currently|presently|as we speak)\b", re.IGNORECASE),
re.compile(r"\b(?:worse than|worse today|worse now|escalating|intensifying)\b", re.IGNORECASE),
re.compile(r"\b(?:i'?m (?:still |now )?(?:having|experiencing))\b", re.IGNORECASE),
re.compile(r"\bi'?m feeling\b(?!\s*(?:better|fine|good|okay|ok|great|alright|normal|well))", re.IGNORECASE),
re.compile(r"\b(?:it'?s (?:back|worse|not going away|spreading))\b", re.IGNORECASE),
re.compile(r"\b(?:hasn'?t (?:gone away|stopped|improved|gotten better))\b", re.IGNORECASE),
]
# ββ Past-tense / resolved markers ββ
PAST_TENSE_PATTERNS = [
re.compile(r"\bi (?:had|used to have|was having|experienced|went through|suffered)\b", re.IGNORECASE),
re.compile(r"\b(?:last (?:week|month|year|night|time)|yesterday|\d+ (?:days?|weeks?|months?|years?) ago)\b", re.IGNORECASE),
re.compile(r"\b(?:in the past|previously|formerly|back in|when i was)\b", re.IGNORECASE),
]
RESOLVED_PATTERNS = [
re.compile(r"\b(?:feeling (?:much )?better|improved|resolved|went away|gone now)\b", re.IGNORECASE),
re.compile(r"\b(?:no longer|stopped|cleared up|fine now|okay now|recovered)\b", re.IGNORECASE),
re.compile(r"\b(?:been treated|got (?:it )?checked|under control|managed|stable)\b", re.IGNORECASE),
re.compile(r"\b(?:i'?m fine|i am fine|i'?m okay|i'?m ok|i'?m good|i'?m alright)\b", re.IGNORECASE),
re.compile(r"\b(?:checked (?:me |it )?out|doctor (?:checked|said|cleared)|all clear)\b", re.IGNORECASE),
]
# ββ Proxy/third-party reporting patterns ββ
PROXY_PATTERNS = [
re.compile(
r"\b(?:my\s+)?(?:mom|mother|dad|father|wife|husband|son|daughter|child|kid|"
r"grandma|grandmother|grandpa|grandfather|brother|sister|friend|neighbor|"
r"partner|spouse|baby|toddler|infant)\b",
re.IGNORECASE
),
]
# Active urgency markers that override third-party suppression
PROXY_ACTIVE_URGENCY = [
re.compile(r"\b(?:says?|told me|telling me|texted|called|screaming)\b.*\b(?:hurts?|pain|can'?t breathe|bleeding|fell|fainted|passed out|unconscious|chest|heart|seizure)\b", re.IGNORECASE),
re.compile(r"\b(?:hurts?|pain|can'?t breathe|bleeding|fell|fainted|passed out|unconscious|chest|heart|seizure)\b.*\b(?:really|very|so|extremely|terribly|awful|bad)\b", re.IGNORECASE),
re.compile(r"\b(?:need|needs?|rush|hurry|emergency|ambulance|911|help)\b", re.IGNORECASE),
re.compile(r"\b(?:right now|just now|just happened|happening)\b", re.IGNORECASE),
]
# ββ Intermittent/recurring symptom patterns ββ
# Ambiguous intermittent phrasing with dangerous symptoms should bias toward
# escalation unless the patient's record shows chronic, stable baseline.
INTERMITTENT_ACTIVE = [
re.compile(r"\b(?:sometimes|occasionally|on and off|comes and goes|every now and then|once in a while|from time to time|intermittent)\b.*\b(?:trouble breathing|chest pain|can'?t breathe|difficulty breathing|hard to breathe|dizzy|faint|pass out|black out|seizure|heart races|heart pounds|palpitation)\b", re.IGNORECASE),
re.compile(r"\b(?:trouble breathing|chest pain|can'?t breathe|difficulty breathing|hard to breathe|dizzy|faint|pass out|black out|seizure|heart races|heart pounds|palpitation)\b.*\b(?:sometimes|occasionally|on and off|comes and goes|every now and then|once in a while|from time to time|intermittent)\b", re.IGNORECASE),
re.compile(r"\b(?:sometimes|occasionally|on and off|every now and then)\b.*\b(?:pain in my chest|pressure in my chest|tightness in my chest|left arm goes numb|vision goes|lose consciousness|feel like i.{0,10}(?:faint|pass out))\b", re.IGNORECASE),
]
def preprocess_temporal(text: str, context: Optional[str] = None) -> TemporalSignal:
"""
Analyze and preprocess text for temporal context before ML classification.
This is the main entry point. Call this BEFORE tokenization/inference.
Args:
text: Patient's current utterance
context: Optional conversation history
Returns:
TemporalSignal with preprocessing results
"""
original = text
matched_rules = []
# ββ Step 1: Check for BUT-clause reversals ββ
for i, pattern in enumerate(BUT_CLAUSE_PATTERNS):
m = pattern.search(text)
if m:
active_part = m.group("active").strip()
if len(active_part) >= 10: # Sanity check: active part is meaningful
matched_rules.append(f"but_clause_reversal_{i}")
return TemporalSignal(
processed_text=active_part,
original_text=original,
is_active_emergency=True,
temporal_tag="active",
confidence=0.90,
reason=f"BUT-clause reversal detected: resolved prefix discarded, active complaint extracted: '{active_part[:60]}'",
matched_rules=matched_rules,
)
# ββ Step 2: Check for proxy/third-party with active urgency ββ
is_proxy = any(p.search(text) for p in PROXY_PATTERNS)
if is_proxy:
has_active_urgency = any(p.search(text) for p in PROXY_ACTIVE_URGENCY)
if has_active_urgency:
matched_rules.append("proxy_active_urgency")
return TemporalSignal(
processed_text=text,
original_text=original,
is_active_emergency=True,
temporal_tag="active",
confidence=0.85,
reason="Third-party report with active urgency β treat as emergency",
proxy_report=True,
matched_rules=matched_rules,
)
# ββ Step 3: Check for intermittent symptoms with dangerous conditions ββ
# Moved BEFORE past-tense analysis: "sometimes I have chest pain" should
# bias toward escalation regardless of temporal framing.
for p in INTERMITTENT_ACTIVE:
if p.search(text):
matched_rules.append("intermittent_dangerous")
return TemporalSignal(
processed_text=text,
original_text=original,
is_active_emergency=True,
temporal_tag="active",
confidence=0.75,
reason="Intermittent/recurring dangerous symptom β should escalate",
matched_rules=matched_rules,
)
# ββ Step 4: Check for past-tense + still-active markers ββ
has_past = any(p.search(text) for p in PAST_TENSE_PATTERNS)
has_still_active = any(p.search(text) for p in STILL_ACTIVE_PATTERNS)
has_resolved = any(p.search(text) for p in RESOLVED_PATTERNS)
if has_past and has_still_active:
matched_rules.append("past_but_still_active")
return TemporalSignal(
processed_text=text,
original_text=original,
is_active_emergency=True,
temporal_tag="active",
confidence=0.85,
reason="Past-tense language detected BUT still-active markers present β active condition",
matched_rules=matched_rules,
)
# ββ Step 5: Contrastive clause analysis ββ
# When but/however/yet splits a sentence, prioritize the post-contrastive
# clause β contrast often reverses clinical polarity.
contrastive_match = CONTRASTIVE_MARKER.search(text)
if contrastive_match and has_past:
post_contrastive = text[contrastive_match.end():].strip()
# Check if post-contrastive clause contains resolution markers
post_has_resolved = any(p.search(post_contrastive) for p in RESOLVED_PATTERNS)
# Check if post-contrastive clause contains active markers
post_has_active = any(p.search(post_contrastive) for p in STILL_ACTIVE_PATTERNS)
if post_has_active and not post_has_resolved:
# "I had chest pain yesterday but it's getting worse" β active
matched_rules.append("contrastive_post_active")
return TemporalSignal(
processed_text=text,
original_text=original,
is_active_emergency=True,
temporal_tag="active",
confidence=0.90,
reason=f"Contrastive clause reversal: post-contrastive is active β '{post_contrastive[:50]}'",
matched_rules=matched_rules,
)
if post_has_resolved and not post_has_active:
# "I had chest pain yesterday but I'm feeling better now"
# β resolved, but app.py enforces R2 minimum for R3 domains
matched_rules.append("contrastive_post_resolved")
return TemporalSignal(
processed_text=text,
original_text=original,
is_active_emergency=False,
temporal_tag="resolved",
confidence=0.80,
reason="Past symptom with contrastive resolution β resolved but warrants clinical follow-up",
matched_rules=matched_rules,
)
# ββ Step 6: Past + explicit resolution (no contrastive) ββ
if has_past and has_resolved and not has_still_active:
matched_rules.append("past_and_resolved")
return TemporalSignal(
processed_text=text,
original_text=original,
is_active_emergency=False,
temporal_tag="resolved",
confidence=0.80,
reason="Past-tense + resolution markers, no active signals β resolved but warrants follow-up",
matched_rules=matched_rules,
)
# ββ Step 7: Past tense only β NO resolution evidence ββ
# SAFETY: Past tense alone does NOT mean resolved. "I had chest pain
# yesterday" without saying they're better is a RED FLAG, not a reason
# to suppress. Tag as neutral and let the safety system handle it.
# Proxy reports are also NOT automatically suppressed β caregivers are
# authoritative reporters of acute symptoms.
if has_past and not has_still_active and not has_resolved:
matched_rules.append("past_no_resolution")
return TemporalSignal(
processed_text=text,
original_text=original,
is_active_emergency=False,
temporal_tag="neutral",
confidence=0.50,
reason="Past-tense language but NO resolution evidence β cannot confirm resolved",
proxy_report=is_proxy,
matched_rules=matched_rules,
)
# ββ Step 8: No temporal signals detected β neutral ββ
return TemporalSignal(
processed_text=text,
original_text=original,
is_active_emergency=False,
temporal_tag="neutral",
confidence=0.5,
reason="No temporal signals detected",
matched_rules=matched_rules,
)
|