Spaces:
Sleeping
Sleeping
File size: 16,586 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 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 | """
Risk Classifier.
Evaluates domain nominations against global risk escalation rules to
produce a final RiskAssessment with R0/R1/R2/R3 classification.
Evaluation pipeline:
1. Check hard-escalate domains (suicidal_ideation → always R3)
2. For each nominated domain, evaluate risk_escalation_rules
3. Apply domain suppression rules (R1 wound_concern suppresses R2 wound_infection)
4. Select highest risk class across all active nominations
5. Determine recommended action (proceed / clarify / escalate / handoff)
Safety invariants:
- Hard-escalate domains CANNOT be suppressed
- R3 can never be downgraded by suppression
- When no rules match, default to the domain's target_risk_class
- When no target_risk_class exists, default to R1 (fail-open)
- Context-aware rules require explicit context; they fail closed (don't fire)
when context is unavailable
"""
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,
RiskAssessment,
RiskClass,
RiskRuleMatch,
SuppressionResult,
TurnOutcome,
)
logger = logging.getLogger("decision.risk_classifier")
class RiskClassifier:
"""
Classifies risk from domain nominations using global rules.
Usage:
classifier = RiskClassifier(config)
assessment = classifier.assess(
nominations=nominations,
slot_values={"shortness_of_breath": "yes"},
patient_context=None, # Phase 3: FHIR data
)
"""
def __init__(self, config: DecisionConfigLoader):
self._config = config
self._hard_escalate: Set[str] = config.hard_escalate_domains
self._safety_precedence: List[str] = config.safety_precedence
self._risk_rules: List[Dict[str, Any]] = config.risk_escalation_rules
self._suppression_rules: List[Dict[str, Any]] = config.domain_suppression_rules
def assess(
self,
nominations: List[DomainNomination],
slot_values: Optional[Dict[str, str]] = None,
patient_context: Optional[Dict[str, Any]] = None,
ml_label: Optional[str] = None,
ml_confidence: Optional[float] = None,
) -> RiskAssessment:
"""
Produce a complete risk assessment from domain nominations.
Args:
nominations: Ranked domain nominations from DomainNominator
slot_values: Currently filled slots (from flow execution)
patient_context: FHIR-derived patient context (Phase 3)
ml_label: Original DriveHealthBERT label
ml_confidence: Original DriveHealthBERT confidence
Returns:
RiskAssessment with final risk class, matched rules, etc.
"""
if not nominations:
return self._empty_assessment(ml_label, ml_confidence)
slot_values = slot_values or {}
active_nominations = [n for n in nominations if not n.is_negated]
# Step 1: Check hard-escalate domains
hard_escalate = False
hard_domain = None
for nom in active_nominations:
if nom.domain in self._hard_escalate:
hard_escalate = True
hard_domain = nom.domain
logger.warning(
"HARD ESCALATE triggered: domain=%s", nom.domain
)
break
# Step 2: Evaluate risk rules for each nomination
all_rule_matches: List[RiskRuleMatch] = []
for nom in active_nominations:
matches = self._evaluate_rules_for_domain(
nom.domain, slot_values, patient_context
)
all_rule_matches.extend(matches)
# Step 3: Apply domain suppression
suppressions: List[SuppressionResult] = []
suppressed_domains: Set[str] = set()
if not hard_escalate:
suppressions, suppressed_domains = self._evaluate_suppressions(
active_nominations
)
# Step 4: Determine highest risk class
risk_class = self._determine_risk_class(
nominations=active_nominations,
rule_matches=all_rule_matches,
suppressed_domains=suppressed_domains,
hard_escalate=hard_escalate,
hard_domain=hard_domain,
)
# Step 5: Determine primary domain
primary_domain = self._select_primary_domain(
active_nominations, suppressed_domains, hard_domain
)
# Step 6: Determine recommended action
recommended_action = self._determine_action(risk_class)
# Step 7: Determine recommended flow from primary domain
recommended_flow = None
for nom in active_nominations:
if nom.domain == primary_domain and nom.recommended_flow:
recommended_flow = nom.recommended_flow
break
# Safety override flag
safety_override = any(
nom.confidence_tier >= ConfidenceTier.HIGH
and nom.domain in set(self._safety_precedence)
and nom.domain not in suppressed_domains
for nom in active_nominations
)
return RiskAssessment(
risk_class=risk_class,
primary_domain=primary_domain,
domain_nominations=tuple(nominations),
matched_rules=tuple(all_rule_matches),
suppressions=tuple(suppressions),
hard_escalate=hard_escalate,
safety_override=safety_override,
ml_label=ml_label,
ml_confidence=ml_confidence,
recommended_flow=recommended_flow,
recommended_action=recommended_action,
)
# ------------------------------------------------------------------
# Internal evaluation
# ------------------------------------------------------------------
def _evaluate_rules_for_domain(
self,
domain: str,
slot_values: Dict[str, str],
patient_context: Optional[Dict[str, Any]],
) -> List[RiskRuleMatch]:
"""Evaluate all global risk rules for a specific domain."""
matches = []
for rule in self._risk_rules:
rule_id = rule.get("id", "unknown")
conditions = rule.get("if", {})
result = rule.get("then", {})
# Check domain match
rule_domain = conditions.get("domain")
if rule_domain != domain:
continue
# Check slot conditions
slots_present = conditions.get("slots_present", [])
slot_value_conditions = conditions.get("slot_values", {})
context_conditions = conditions.get("context")
# All required slots must be present
slots_ok = all(
slot_name in slot_values for slot_name in slots_present
)
# All slot value conditions must match
values_ok = all(
slot_values.get(k) == v
for k, v in slot_value_conditions.items()
)
# Context conditions (Phase 3)
context_ok = True
if context_conditions:
if patient_context is None:
# Fail closed: context required but not available
context_ok = False
else:
context_ok = self._evaluate_context_conditions(
context_conditions, patient_context
)
if slots_ok and values_ok and context_ok:
risk_str = result.get("risk_class", "R1")
try:
risk = RiskClass(risk_str)
except ValueError:
risk = RiskClass.R1
matches.append(
RiskRuleMatch(
rule_id=rule_id,
domain=domain,
risk_class=risk,
conditions_met=slot_value_conditions,
context_conditions=context_conditions,
)
)
return matches
def _evaluate_context_conditions(
self,
conditions: Dict[str, Any],
patient_context: Dict[str, Any],
) -> bool:
"""
Evaluate FHIR context conditions against patient context.
Phase 3: Full implementation. Currently supports:
- patient_has_condition: list of ICD-10 patterns
- medication_count_above: int threshold
"""
# patient_has_condition: check ICD-10 codes
required_conditions = conditions.get("patient_has_condition", [])
if required_conditions:
patient_icd_codes = patient_context.get("active_conditions", [])
if not self._match_icd_patterns(required_conditions, patient_icd_codes):
return False
# medication_count_above: check polypharmacy
med_threshold = conditions.get("medication_count_above")
if med_threshold is not None:
med_count = patient_context.get("active_medication_count", 0)
if med_count <= med_threshold:
return False
return True
@staticmethod
def _match_icd_patterns(
patterns: List[str], patient_codes: List[str]
) -> bool:
"""Check if any patient ICD-10 code matches any required pattern."""
import re
for pattern in patterns:
# Convert ICD-10 wildcard to regex (e.g., "I50.*" → "I50\..*")
regex = pattern.replace(".", r"\.").replace("*", ".*")
for code in patient_codes:
if re.match(regex, code, re.IGNORECASE):
return True
return False
def _evaluate_suppressions(
self, nominations: List[DomainNomination]
) -> Tuple[List[SuppressionResult], Set[str]]:
"""
Evaluate domain suppression rules.
A lower-acuity domain firing at high confidence can suppress
a higher-acuity domain at low/medium confidence to reduce
false escalations.
"""
suppressions: List[SuppressionResult] = []
suppressed: Set[str] = set()
active_domains = {n.domain: n for n in nominations}
for rule in self._suppression_rules:
suppressor_name = rule.get("suppressor")
suppressed_list = rule.get("suppressed_domains", [])
condition = rule.get("condition", {})
reason = rule.get("reason", "")
suppressor = active_domains.get(suppressor_name)
if not suppressor:
continue
# Check suppressor minimum confidence
min_conf_str = condition.get("suppressor_min_confidence", "medium")
min_conf = self._str_to_confidence(min_conf_str)
if suppressor.confidence_tier < min_conf:
continue
# Check suppressed domains
max_conf_str = condition.get("suppressed_max_confidence", "medium")
max_conf = self._str_to_confidence(max_conf_str)
for target_name in suppressed_list:
target = active_domains.get(target_name)
if not target:
continue
# SAFETY: Never suppress hard-escalate domains
if target_name in self._hard_escalate:
continue
# Only suppress if target is at or below max confidence
if target.confidence_tier <= max_conf:
suppressed.add(target_name)
suppressions.append(
SuppressionResult(
suppressed=True,
suppressor_domain=suppressor_name,
rule_reason=reason,
)
)
logger.info(
"Domain suppression: %s suppresses %s (reason: %s)",
suppressor_name,
target_name,
reason,
)
return suppressions, suppressed
def _determine_risk_class(
self,
nominations: List[DomainNomination],
rule_matches: List[RiskRuleMatch],
suppressed_domains: Set[str],
hard_escalate: bool,
hard_domain: Optional[str],
) -> RiskClass:
"""Determine the highest applicable risk class."""
# Hard escalate always wins
if hard_escalate:
return RiskClass.R3
# Collect all risk classes from rule matches (excluding suppressed)
risk_candidates: List[RiskClass] = []
for rm in rule_matches:
if rm.domain not in suppressed_domains:
risk_candidates.append(rm.risk_class)
# ALSO consider target_risk_class from nominations (even when rules
# matched for other domains). Previously this was a fallback that
# only ran when zero rules matched, which let a benign R1 rule on
# domain A mask a critical R3 target on domain B.
for nom in nominations:
if nom.domain in suppressed_domains:
continue
if nom.is_negated:
continue
if nom.target_risk_class:
# For high-confidence matches on safety domains, use target risk
if nom.confidence_tier >= ConfidenceTier.HIGH:
risk_candidates.append(nom.target_risk_class)
elif nom.confidence_tier >= ConfidenceTier.MEDIUM:
# Medium confidence: one tier below target, minimum R1
downgraded = self._downgrade_risk(nom.target_risk_class)
risk_candidates.append(downgraded)
else:
# Low confidence: two tiers below or R1
risk_candidates.append(RiskClass.R1)
if risk_candidates:
return max(risk_candidates)
# Absolute fallback: if we have any non-negated nomination, R1
if any(not n.is_negated for n in nominations):
return RiskClass.R1
return RiskClass.R0
def _select_primary_domain(
self,
nominations: List[DomainNomination],
suppressed_domains: Set[str],
hard_domain: Optional[str],
) -> Optional[str]:
"""Select the primary domain from active nominations."""
if hard_domain:
return hard_domain
# Use safety precedence order for tie-breaking
precedence_set = set(self._safety_precedence)
for nom in nominations:
if nom.domain in suppressed_domains:
continue
if nom.is_negated:
continue
return nom.domain # Already sorted by confidence + priority
# All negated or suppressed
return nominations[0].domain if nominations else None
@staticmethod
def _determine_action(risk_class: RiskClass) -> TurnOutcome:
"""Map risk class to recommended turn outcome."""
if risk_class == RiskClass.R3:
return TurnOutcome.ESCALATE
elif risk_class == RiskClass.R2:
return TurnOutcome.HANDOFF
elif risk_class == RiskClass.R1:
return TurnOutcome.PROCEED
return TurnOutcome.PROCEED
@staticmethod
def _downgrade_risk(risk: RiskClass) -> RiskClass:
"""Downgrade risk by one tier, minimum R1."""
if risk == RiskClass.R3:
return RiskClass.R2
elif risk == RiskClass.R2:
return RiskClass.R1
return RiskClass.R1
@staticmethod
def _str_to_confidence(s: str) -> ConfidenceTier:
try:
return ConfidenceTier(s)
except ValueError:
return ConfidenceTier.MEDIUM
def _empty_assessment(
self,
ml_label: Optional[str] = None,
ml_confidence: Optional[float] = None,
) -> RiskAssessment:
"""Return a default R0 assessment when no nominations exist."""
return RiskAssessment(
risk_class=RiskClass.R0,
primary_domain=None,
domain_nominations=(),
matched_rules=(),
suppressions=(),
hard_escalate=False,
safety_override=False,
ml_label=ml_label,
ml_confidence=ml_confidence,
recommended_flow=None,
recommended_action=TurnOutcome.PROCEED,
)
|