File size: 10,843 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
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
"""
Core data models for the Decision Engine.

All models are immutable dataclasses to prevent accidental mutation
during safety-critical evaluation pipelines.
"""

from __future__ import annotations

import enum
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional


# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------

class ConfidenceTier(enum.Enum):
    """Confidence tier for taxonomy trigger matches."""
    HIGH = "high"
    MEDIUM = "medium"
    LOW = "low"
    NONE = "none"

    def __ge__(self, other: ConfidenceTier) -> bool:
        order = {self.HIGH: 3, self.MEDIUM: 2, self.LOW: 1, self.NONE: 0}
        return order[self] >= order[other]

    def __gt__(self, other: ConfidenceTier) -> bool:
        order = {self.HIGH: 3, self.MEDIUM: 2, self.LOW: 1, self.NONE: 0}
        return order[self] > order[other]

    def __le__(self, other: ConfidenceTier) -> bool:
        order = {self.HIGH: 3, self.MEDIUM: 2, self.LOW: 1, self.NONE: 0}
        return order[self] <= order[other]

    def __lt__(self, other: ConfidenceTier) -> bool:
        order = {self.HIGH: 3, self.MEDIUM: 2, self.LOW: 1, self.NONE: 0}
        return order[self] < order[other]


class RiskClass(enum.Enum):
    """Clinical risk classification tier."""
    R0 = "R0"  # No clinical concern
    R1 = "R1"  # Low-level concern, routine follow-up
    R2 = "R2"  # Moderate concern, warm transfer to nurse
    R3 = "R3"  # Emergent, instruct to call 911 + transfer

    @property
    def is_emergent(self) -> bool:
        return self == RiskClass.R3

    @property
    def requires_nurse(self) -> bool:
        return self in (RiskClass.R2, RiskClass.R3)

    @property
    def severity_rank(self) -> int:
        return {self.R0: 0, self.R1: 1, self.R2: 2, self.R3: 3}[self]

    def __ge__(self, other: RiskClass) -> bool:
        return self.severity_rank >= other.severity_rank

    def __gt__(self, other: RiskClass) -> bool:
        return self.severity_rank > other.severity_rank

    def __le__(self, other: RiskClass) -> bool:
        return self.severity_rank <= other.severity_rank

    def __lt__(self, other: RiskClass) -> bool:
        return self.severity_rank < other.severity_rank


class MatchType(enum.Enum):
    """Type of lexical match that fired."""
    PHRASE = "phrase"
    REGEX = "regex"
    PARTIAL = "partial"


class NegationAction(enum.Enum):
    """What to do when negation is detected."""
    SUPPRESS = "suppress"              # Remove domain nomination entirely
    DOWNGRADE_CONFIDENCE = "downgrade_confidence"  # Drop confidence one tier


class TurnOutcome(enum.Enum):
    """Possible outcomes after a conversational turn."""
    PROCEED = "proceed"
    CLARIFY = "clarify"
    ESCALATE = "escalate"
    HANDOFF = "handoff"
    END = "end"


class FlowType(enum.Enum):
    """Type of conversation flow."""
    SCREENING = "screening"
    ESCALATION = "escalation"
    CLARIFICATION = "clarification"
    RESOLUTION = "resolution"
    HANDOFF = "handoff"
    PRIMITIVE = "primitive"


class CallPhase(enum.Enum):
    """Phase of the current call."""
    SESSION_START = "session_start"
    ACTIVE_CALL = "active_call"
    ENDED = "ended"


# ---------------------------------------------------------------------------
# Trigger & Match Models
# ---------------------------------------------------------------------------

@dataclass(frozen=True)
class TriggerMatch:
    """A single lexical match from a taxonomy trigger."""
    domain: str
    confidence_tier: ConfidenceTier
    match_type: MatchType
    matched_text: str          # The phrase/pattern that matched
    matched_span: str          # The actual text span from patient input
    priority: int              # Domain priority from triggers.yaml


@dataclass(frozen=True)
class NegationResult:
    """Result of negation analysis on a domain match."""
    is_negated: bool
    action: NegationAction
    matched_pattern: str = ""  # Which negation pattern matched
    original_confidence: ConfidenceTier = ConfidenceTier.NONE
    adjusted_confidence: ConfidenceTier = ConfidenceTier.NONE


@dataclass(frozen=True)
class DomainNomination:
    """A nominated clinical domain with combined confidence from ML + lexical signals."""
    domain: str
    display_name: str
    confidence_tier: ConfidenceTier
    priority: int                          # From triggers.yaml priority field
    target_risk_class: Optional[RiskClass] # Default risk from rules.yaml
    trigger_matches: tuple                 # Tuple of TriggerMatch (frozen)
    negation_result: Optional[NegationResult] = None
    ml_label: Optional[str] = None         # DriveHealthBERT predicted label
    ml_confidence: Optional[float] = None  # DriveHealthBERT probability
    recommended_flow: Optional[str] = None # From rules.yaml decision_rules

    @property
    def is_negated(self) -> bool:
        return self.negation_result is not None and self.negation_result.is_negated

    @property
    def is_safety_critical(self) -> bool:
        """R3 domains or hard-escalate domains."""
        return self.target_risk_class == RiskClass.R3


# ---------------------------------------------------------------------------
# Risk Assessment Models
# ---------------------------------------------------------------------------

@dataclass(frozen=True)
class RiskRuleMatch:
    """A risk escalation rule that fired."""
    rule_id: str
    domain: str
    risk_class: RiskClass
    conditions_met: Dict[str, Any] = field(default_factory=dict)
    context_conditions: Optional[Dict[str, Any]] = None


@dataclass(frozen=True)
class SuppressionResult:
    """Result of domain suppression evaluation."""
    suppressed: bool
    suppressor_domain: Optional[str] = None
    rule_reason: Optional[str] = None


@dataclass(frozen=True)
class RiskAssessment:
    """Complete risk assessment for a patient utterance."""
    # Primary result
    risk_class: RiskClass
    primary_domain: Optional[str]
    # All nominated domains (ranked by priority)
    domain_nominations: tuple           # Tuple of DomainNomination
    # Which rules fired
    matched_rules: tuple                # Tuple of RiskRuleMatch
    # Suppression results
    suppressions: tuple = ()            # Tuple of SuppressionResult
    # Metadata
    hard_escalate: bool = False         # suicidal_ideation / homicidal_ideation
    safety_override: bool = False       # Lexical safety signal regardless of ML
    ml_label: Optional[str] = None
    ml_confidence: Optional[float] = None
    recommended_flow: Optional[str] = None
    recommended_action: Optional[TurnOutcome] = None

    @property
    def requires_911(self) -> bool:
        return self.risk_class == RiskClass.R3

    @property
    def requires_nurse_transfer(self) -> bool:
        return self.risk_class >= RiskClass.R2

    def to_dict(self) -> Dict[str, Any]:
        """Serialize for API response."""
        return {
            "risk_class": self.risk_class.value,
            "primary_domain": self.primary_domain,
            "requires_911": self.requires_911,
            "requires_nurse_transfer": self.requires_nurse_transfer,
            "hard_escalate": self.hard_escalate,
            "safety_override": self.safety_override,
            "ml_label": self.ml_label,
            "ml_confidence": self.ml_confidence,
            "recommended_flow": self.recommended_flow,
            "recommended_action": self.recommended_action.value if self.recommended_action else None,
            "domain_nominations": [
                {
                    "domain": n.domain,
                    "confidence_tier": n.confidence_tier.value,
                    "priority": n.priority,
                    "target_risk_class": n.target_risk_class.value if n.target_risk_class else None,
                    "is_negated": n.is_negated,
                    "recommended_flow": n.recommended_flow,
                    "trigger_match_count": len(n.trigger_matches),
                }
                for n in self.domain_nominations
            ],
            "matched_rules": [
                {
                    "rule_id": r.rule_id,
                    "domain": r.domain,
                    "risk_class": r.risk_class.value,
                }
                for r in self.matched_rules
            ],
        }


# ---------------------------------------------------------------------------
# Flow Execution Models (Phase 2 — stubs for forward compatibility)
# ---------------------------------------------------------------------------

@dataclass(frozen=True)
class ResponseSpec:
    """LLM response constraints for a flow step."""
    spec_id: str
    goal: str
    tone: str
    must_include: tuple = ()
    must_ask: tuple = ()
    must_not: tuple = ()
    max_questions: int = 0


@dataclass(frozen=True)
class FlowStep:
    """A single step in a conversation flow."""
    step_number: int
    step_type: str              # "ask", "respond", "handoff"
    response_spec: ResponseSpec
    collects: tuple = ()        # Slot names to extract
    handoff_target: Optional[str] = None
    handoff_urgency: Optional[str] = None


@dataclass(frozen=True)
class ConversationFlow:
    """A complete conversation flow definition."""
    flow_id: str
    flow_type: FlowType
    domain: Optional[str]
    required_slots: tuple = ()
    steps: tuple = ()           # Tuple of FlowStep
    exit_rules: Dict[str, str] = field(default_factory=dict)


# ---------------------------------------------------------------------------
# Session State Models (Phase 2 — stubs for forward compatibility)
# ---------------------------------------------------------------------------

@dataclass
class SlotState:
    """Mutable slot tracker for an active conversation."""
    slots: Dict[str, Any] = field(default_factory=dict)

    def set_slot(self, name: str, value: Any) -> None:
        self.slots[name] = value

    def get_slot(self, name: str) -> Optional[Any]:
        return self.slots.get(name)

    def has_slot(self, name: str) -> bool:
        return name in self.slots

    def has_all(self, slot_names: List[str]) -> bool:
        return all(name in self.slots for name in slot_names)


@dataclass
class SessionState:
    """Mutable state for an active call session."""
    session_id: str
    patient_id: Optional[str] = None
    tenant_id: Optional[str] = None
    call_phase: CallPhase = CallPhase.SESSION_START
    current_flow: Optional[str] = None
    current_step: int = 0
    agenda_position: int = 0
    slots: SlotState = field(default_factory=SlotState)
    domain_history: List[str] = field(default_factory=list)
    risk_history: List[RiskAssessment] = field(default_factory=list)
    turn_count: int = 0