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,
    )