File size: 13,046 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
324
325
326
327
"""
Domain Nomination Layer.

Combines DriveHealthBERT ML output with taxonomy trigger matches to produce
a ranked list of clinical domain nominations.

The nomination algorithm:
  1. Run taxonomy triggers against the text → domain match results
  2. Receive DriveHealthBERT label + probabilities
  3. Fuse signals: ML confidence reinforces or weakens trigger confidence
  4. Apply hard-escalate rules (suicidal_ideation, homicidal_ideation)
  5. Rank by: (a) effective_confidence tier, (b) domain priority, (c) ML prob
  6. Select recommended_flow from domain rules based on confidence tier
  7. Return sorted list of DomainNomination objects

Safety invariants:
  - Hard-escalate domains ALWAYS nominate at HIGH confidence
  - Negated domains are included but marked (caller decides)
  - If ML says ESCALATION but no triggers match, still nominate with
    a generic "unclassified_escalation" domain
  - If triggers match a safety domain but ML says non-escalation,
    the trigger OVERRIDES the ML (fail-open)
"""

from __future__ import annotations

import logging
from typing import Any, Dict, List, Optional, Set, Tuple

from decision.engine.config_loader import DecisionConfigLoader
from decision.engine.models import (
    ConfidenceTier,
    DomainNomination,
    NegationResult,
    RiskClass,
    TriggerMatch,
)
from decision.engine.trigger_engine import DomainMatchResult, TaxonomyTriggerEngine

logger = logging.getLogger("decision.domain_nomination")

# DriveHealthBERT label → taxonomy domain mapping hints
# Used when ML label alone doesn't resolve to a specific domain
_ML_LABEL_DOMAIN_HINTS: Dict[str, List[str]] = {
    "SCHEDULING": ["scheduling", "scheduling_barrier", "missed_followup"],
    "MEDICATION": ["medication", "medication_nonadherence"],
    "SYMPTOM_CHECK": [],  # Too broad — rely on triggers
    "BILLING": [],         # No taxonomy domain for billing yet
    "GENERAL": [],
    "ESCALATION": [],      # Rely on triggers to determine which domain
}


class DomainNominator:
    """
    Produces ranked domain nominations from ML + lexical signals.

    Usage:
        nominator = DomainNominator(config, trigger_engine)
        nominations = nominator.nominate(
            text="my chest is killing me",
            ml_label="ESCALATION",
            ml_probabilities={"ESCALATION": 0.92, ...},
        )
    """

    def __init__(
        self,
        config: DecisionConfigLoader,
        trigger_engine: TaxonomyTriggerEngine,
    ):
        self._config = config
        self._trigger_engine = trigger_engine
        self._hard_escalate: Set[str] = config.hard_escalate_domains
        self._safety_precedence: List[str] = config.safety_precedence

    def nominate(
        self,
        text: str,
        ml_label: Optional[str] = None,
        ml_probabilities: Optional[Dict[str, float]] = None,
    ) -> List[DomainNomination]:
        """
        Produce ranked domain nominations for the given text.

        Args:
            text: Patient utterance (original, NOT context-prepended)
            ml_label: DriveHealthBERT predicted label (e.g., "ESCALATION")
            ml_probabilities: Full probability distribution from DriveHealthBERT

        Returns:
            List of DomainNomination sorted by:
              1. Effective confidence tier (HIGH > MEDIUM > LOW)
              2. Domain priority (higher = more urgent)
              3. ML probability (if available)
        """
        ml_confidence = None
        if ml_probabilities and ml_label:
            ml_confidence = ml_probabilities.get(ml_label, 0.0)

        # Step 1: Run taxonomy triggers
        trigger_results = self._trigger_engine.match_all(text)

        # Step 2: Build nominations from trigger results
        nominations: List[DomainNomination] = []

        for domain_name, match_result in trigger_results.items():
            nomination = self._build_nomination(
                domain_name=domain_name,
                match_result=match_result,
                ml_label=ml_label,
                ml_confidence=ml_confidence,
            )
            nominations.append(nomination)

        # Step 3: Handle hard-escalate domains
        #   If any hard-escalate domain matched (even at LOW), force HIGH
        for nom in nominations:
            if nom.domain in self._hard_escalate and not nom.is_negated:
                nominations = [
                    self._force_high_confidence(n) if n.domain == nom.domain else n
                    for n in nominations
                ]

        # Step 4: Handle ML-only signals (ML says ESCALATION but no triggers)
        if ml_label == "ESCALATION" and ml_confidence and ml_confidence > 0.5:
            has_safety_trigger = any(
                not n.is_negated and n.confidence_tier >= ConfidenceTier.LOW
                for n in nominations
                if n.domain in set(self._safety_precedence)
            )
            # Check if the ML is confused by negated safety keywords:
            # If safety domains DID match but were ALL negated, the ML is
            # likely reacting to the keywords themselves (e.g., "I'm NOT
            # suicidal" triggers ML ESCALATION because it sees "suicidal").
            # In this case, trust the explicit negation over the ML signal.
            has_negated_safety = any(
                n.is_negated
                for n in nominations
                if n.domain in set(self._safety_precedence)
            )
            if not has_safety_trigger and not has_negated_safety:
                # ML detected escalation but no specific domain matched
                # (and no negated safety domains either)
                # Create a generic nomination so it's not silently dropped
                nominations.append(
                    DomainNomination(
                        domain="unclassified_escalation",
                        display_name="Unclassified Escalation",
                        confidence_tier=self._ml_prob_to_tier(ml_confidence),
                        priority=50,  # Middle priority
                        target_risk_class=RiskClass.R2,
                        trigger_matches=(),
                        negation_result=None,
                        ml_label=ml_label,
                        ml_confidence=ml_confidence,
                        recommended_flow="handoff",
                    )
                )

        # Step 5: Handle ML reinforcement for non-escalation labels
        if ml_label and ml_label != "ESCALATION":
            hint_domains = _ML_LABEL_DOMAIN_HINTS.get(ml_label, [])
            for nom in nominations:
                if nom.domain in hint_domains and ml_confidence:
                    # ML confirms trigger match — boost confidence if ML is strong
                    if ml_confidence > 0.8 and nom.confidence_tier < ConfidenceTier.HIGH:
                        nominations = [
                            self._boost_confidence(n) if n.domain == nom.domain else n
                            for n in nominations
                        ]

        # Step 6: Resolve recommended_flow for each nomination
        nominations = [self._resolve_flow(n) for n in nominations]

        # Step 7: Sort
        nominations.sort(key=self._nomination_sort_key, reverse=True)

        if nominations:
            logger.info(
                "Domain nominations for text (len=%d): %s",
                len(text),
                [(n.domain, n.confidence_tier.value, n.priority) for n in nominations[:5]],
            )

        return nominations

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _build_nomination(
        self,
        domain_name: str,
        match_result: DomainMatchResult,
        ml_label: Optional[str],
        ml_confidence: Optional[float],
    ) -> DomainNomination:
        """Build a DomainNomination from a DomainMatchResult."""
        # Get target risk class from rules.yaml
        target_risk_str = self._config.get_domain_target_risk(domain_name)
        target_risk = None
        if target_risk_str:
            try:
                target_risk = RiskClass(target_risk_str)
            except ValueError:
                pass

        return DomainNomination(
            domain=domain_name,
            display_name=domain_name.replace("_", " ").title(),
            confidence_tier=match_result.effective_confidence,
            priority=match_result.priority,
            target_risk_class=target_risk,
            trigger_matches=match_result.matches,
            negation_result=match_result.negation_result,
            ml_label=ml_label,
            ml_confidence=ml_confidence,
        )

    def _resolve_flow(self, nomination: DomainNomination) -> DomainNomination:
        """Determine recommended_flow from domain decision rules."""
        if nomination.recommended_flow:
            return nomination

        rules = self._config.get_domain_decision_rules(nomination.domain)
        if not rules:
            return nomination

        # Find first matching rule based on confidence tier
        for rule in rules:
            conditions = rule.get("if", {})
            conf_required = conditions.get("confidence")
            if conf_required and conf_required == nomination.confidence_tier.value:
                flow = rule.get("then", {}).get("flow")
                if flow:
                    return DomainNomination(
                        domain=nomination.domain,
                        display_name=nomination.display_name,
                        confidence_tier=nomination.confidence_tier,
                        priority=nomination.priority,
                        target_risk_class=nomination.target_risk_class,
                        trigger_matches=nomination.trigger_matches,
                        negation_result=nomination.negation_result,
                        ml_label=nomination.ml_label,
                        ml_confidence=nomination.ml_confidence,
                        recommended_flow=flow,
                    )

        return nomination

    def _force_high_confidence(
        self, nomination: DomainNomination
    ) -> DomainNomination:
        """Force a nomination to HIGH confidence (for hard-escalate domains)."""
        if nomination.confidence_tier == ConfidenceTier.HIGH:
            return nomination
        logger.warning(
            "HARD ESCALATE: Forcing %s to HIGH confidence (was %s)",
            nomination.domain,
            nomination.confidence_tier.value,
        )
        return DomainNomination(
            domain=nomination.domain,
            display_name=nomination.display_name,
            confidence_tier=ConfidenceTier.HIGH,
            priority=nomination.priority,
            target_risk_class=nomination.target_risk_class,
            trigger_matches=nomination.trigger_matches,
            negation_result=nomination.negation_result,
            ml_label=nomination.ml_label,
            ml_confidence=nomination.ml_confidence,
            recommended_flow=nomination.recommended_flow,
        )

    def _boost_confidence(
        self, nomination: DomainNomination
    ) -> DomainNomination:
        """Boost confidence by one tier when ML reinforces trigger."""
        new_tier = nomination.confidence_tier
        if new_tier == ConfidenceTier.LOW:
            new_tier = ConfidenceTier.MEDIUM
        elif new_tier == ConfidenceTier.MEDIUM:
            new_tier = ConfidenceTier.HIGH
        if new_tier == nomination.confidence_tier:
            return nomination
        return DomainNomination(
            domain=nomination.domain,
            display_name=nomination.display_name,
            confidence_tier=new_tier,
            priority=nomination.priority,
            target_risk_class=nomination.target_risk_class,
            trigger_matches=nomination.trigger_matches,
            negation_result=nomination.negation_result,
            ml_label=nomination.ml_label,
            ml_confidence=nomination.ml_confidence,
            recommended_flow=nomination.recommended_flow,
        )

    @staticmethod
    def _ml_prob_to_tier(prob: float) -> ConfidenceTier:
        """Map ML probability to a confidence tier."""
        if prob >= 0.8:
            return ConfidenceTier.HIGH
        elif prob >= 0.5:
            return ConfidenceTier.MEDIUM
        elif prob >= 0.3:
            return ConfidenceTier.LOW
        return ConfidenceTier.NONE

    @staticmethod
    def _nomination_sort_key(
        nom: DomainNomination,
    ) -> Tuple[int, int, float]:
        """Sort key: (confidence_rank, priority, ml_confidence)."""
        conf_rank = {
            ConfidenceTier.HIGH: 3,
            ConfidenceTier.MEDIUM: 2,
            ConfidenceTier.LOW: 1,
            ConfidenceTier.NONE: 0,
        }
        return (
            conf_rank.get(nom.confidence_tier, 0),
            nom.priority,
            nom.ml_confidence or 0.0,
        )