Spaces:
Sleeping
Sleeping
That guy James Bond :) commited on
Commit ·
af61b34
1
Parent(s): 8309405
Deploy Medical Intent Escalation API
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +2 -1
- .gitignore +3 -0
- Dockerfile +31 -0
- README.md +26 -5
- artifacts/config/decision_policy.json +71 -0
- artifacts/onnx_int8/config.json +44 -0
- artifacts/onnx_int8/decision_policy.json +71 -0
- artifacts/onnx_int8/export_metadata.json +9 -0
- artifacts/onnx_int8/label_mappings.json +18 -0
- artifacts/onnx_int8/model.onnx +3 -0
- artifacts/onnx_int8/model.onnx.data +3 -0
- artifacts/onnx_int8/model_preprocessed.onnx +3 -0
- artifacts/onnx_int8/quantization_metadata.json +12 -0
- artifacts/onnx_int8/special_tokens_map.json +37 -0
- artifacts/onnx_int8/tokenizer.json +0 -0
- artifacts/onnx_int8/tokenizer_config.json +14 -0
- artifacts/onnx_int8/training_metadata.json +21 -0
- artifacts/onnx_int8/vocab.txt +0 -0
- config/streaming_intent.yaml +117 -0
- config/train_config.yaml +87 -0
- config/train_config_augmented.yaml +80 -0
- config/train_config_v2.yaml +69 -0
- config/train_config_v3.yaml +74 -0
- config/train_config_v4.yaml +74 -0
- config/train_config_v5.yaml +71 -0
- config/train_config_v6.yaml +75 -0
- decision/context/fhir_mappings.yaml +61 -0
- decision/engine/__init__.py +55 -0
- decision/engine/config_loader.py +472 -0
- decision/engine/domain_nomination.py +326 -0
- decision/engine/fhir_context.py +506 -0
- decision/engine/flow_engine.py +763 -0
- decision/engine/journey_orchestrator.py +478 -0
- decision/engine/models.py +322 -0
- decision/engine/risk_classifier.py +456 -0
- decision/engine/tenant_manager.py +237 -0
- decision/engine/training_data_generator.py +373 -0
- decision/engine/trigger_engine.py +323 -0
- decision/global/global_rules.yaml +666 -0
- decision/journeys/annual_wellness_visit.yaml +58 -0
- decision/journeys/gap_closure.yaml +91 -0
- decision/journeys/health_risk_assessment.yaml +45 -0
- decision/journeys/post_discharge_engagement.yaml +120 -0
- decision/journeys/pre_op_optimization.yaml +83 -0
- decision/orchestrator/conflict_resolution.yaml +79 -0
- decision/orchestrator/contact_caps.yaml +16 -0
- decision/orchestrator/enrollment_rules.yaml +53 -0
- decision/orchestrator/wedge_priority.yaml +16 -0
- decision/orchestrator/wedge_suppression.yaml +78 -0
- decision/primitives/clarify.yaml +23 -0
.gitattributes
CHANGED
|
@@ -33,6 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
-
*.
|
| 37 |
*.docx filter=lfs diff=lfs merge=lfs -text
|
| 38 |
*.xlsx filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.onnx.data filter=lfs diff=lfs merge=lfs -text
|
| 37 |
*.docx filter=lfs diff=lfs merge=lfs -text
|
| 38 |
*.xlsx filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
*.pdf filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
*.pyc
|
Dockerfile
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# System dependencies
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
build-essential \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# Install only serving dependencies (no torch needed — ONNX Runtime only)
|
| 11 |
+
COPY requirements-serve.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements-serve.txt
|
| 13 |
+
|
| 14 |
+
# Copy application code
|
| 15 |
+
COPY service/ service/
|
| 16 |
+
COPY decision/ decision/
|
| 17 |
+
COPY streaming_intent/ streaming_intent/
|
| 18 |
+
COPY config/ config/
|
| 19 |
+
COPY list/ list/
|
| 20 |
+
COPY artifacts/onnx_int8/ artifacts/onnx_int8/
|
| 21 |
+
COPY artifacts/config/ artifacts/config/
|
| 22 |
+
|
| 23 |
+
# HF Spaces expects port 7860
|
| 24 |
+
ENV PORT=7860
|
| 25 |
+
ENV PYTHONUNBUFFERED=1
|
| 26 |
+
ENV REQUIRE_AUTH=false
|
| 27 |
+
ENV DISABLE_DOCS=false
|
| 28 |
+
|
| 29 |
+
EXPOSE 7860
|
| 30 |
+
|
| 31 |
+
CMD ["uvicorn", "service.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,11 +1,32 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
-
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Medical Intent Escalation
|
| 3 |
+
emoji: 🏥
|
| 4 |
+
colorFrom: blue
|
| 5 |
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
app_port: 7860
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Medical Intent Escalation
|
| 13 |
+
|
| 14 |
+
Real-time clinical intent classification and safety escalation API powered by DriveHealthBERT.
|
| 15 |
+
|
| 16 |
+
## Endpoints
|
| 17 |
+
|
| 18 |
+
- **POST /predict** — Classify medical intent from text
|
| 19 |
+
- **POST /predict/clinical** — Full clinical risk assessment (R0–R3)
|
| 20 |
+
- **POST /conversation/start** — Start a clinical conversation session
|
| 21 |
+
- **POST /conversation/turn** — Process a patient turn
|
| 22 |
+
- **POST /stream/chunk** — Process streaming STT chunks with SPRT decisions
|
| 23 |
+
- **GET /health** — Health check
|
| 24 |
+
- **GET /docs** — Interactive Swagger UI
|
| 25 |
+
|
| 26 |
+
## Quick Test
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
curl -X POST "https://YOUR-SPACE.hf.space/predict" \
|
| 30 |
+
-H "Content-Type: application/json" \
|
| 31 |
+
-d '{"text": "I have been having chest pain"}'
|
| 32 |
+
```
|
artifacts/config/decision_policy.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"version": "2.0.0",
|
| 3 |
+
"created_at": "2026-03-06T04:20:52.670734+00:00",
|
| 4 |
+
"escalation_policy": {
|
| 5 |
+
"escalation_label": "ESCALATION",
|
| 6 |
+
"escalation_threshold": 0.95,
|
| 7 |
+
"description": "If P(ESCALATION) >= 0.950, escalate regardless of argmax. This prioritizes recall for safety-critical escalation detection."
|
| 8 |
+
},
|
| 9 |
+
"confidence_policy": {
|
| 10 |
+
"min_confidence": 0.3,
|
| 11 |
+
"fallback_label": "OTHER",
|
| 12 |
+
"description": "If max probability < 0.3, route to OTHER for review."
|
| 13 |
+
},
|
| 14 |
+
"calibration_metrics": {
|
| 15 |
+
"threshold": 0.95,
|
| 16 |
+
"recall": 0.9696969696969697,
|
| 17 |
+
"precision": 0.898876404494382,
|
| 18 |
+
"fpr": 0.016498625114573784,
|
| 19 |
+
"tp": 160,
|
| 20 |
+
"fp": 18,
|
| 21 |
+
"tn": 1073,
|
| 22 |
+
"fn": 5,
|
| 23 |
+
"recall_ci": {
|
| 24 |
+
"point_estimate": 0.9702812759764813,
|
| 25 |
+
"ci_lower": 0.9433872701218056,
|
| 26 |
+
"ci_upper": 0.9938281072483526,
|
| 27 |
+
"confidence_level": 0.95
|
| 28 |
+
},
|
| 29 |
+
"precision_ci": {
|
| 30 |
+
"point_estimate": 0.9000275789258342,
|
| 31 |
+
"ci_lower": 0.8563822440343247,
|
| 32 |
+
"ci_upper": 0.9408681214421253,
|
| 33 |
+
"confidence_level": 0.95
|
| 34 |
+
},
|
| 35 |
+
"fpr_ci": {
|
| 36 |
+
"point_estimate": 0.016454061300607874,
|
| 37 |
+
"ci_lower": 0.009250480061420503,
|
| 38 |
+
"ci_upper": 0.02428254680618841,
|
| 39 |
+
"confidence_level": 0.95
|
| 40 |
+
}
|
| 41 |
+
},
|
| 42 |
+
"decision_logic": [
|
| 43 |
+
"1. If P(ESCALATION) >= 0.950 \u2192 ESCALATE",
|
| 44 |
+
"2. Else if max(P) < 0.3 \u2192 route to OTHER",
|
| 45 |
+
"3. Else \u2192 return argmax label"
|
| 46 |
+
],
|
| 47 |
+
"drift_monitoring": {
|
| 48 |
+
"enabled": true,
|
| 49 |
+
"baseline_metrics": {
|
| 50 |
+
"recall": 0.9696969696969697,
|
| 51 |
+
"fpr": 0.016498625114573784,
|
| 52 |
+
"calibration_date": "2026-03-06T04:20:52.670756+00:00"
|
| 53 |
+
},
|
| 54 |
+
"alert_thresholds": {
|
| 55 |
+
"min_recall": 0.9,
|
| 56 |
+
"max_fpr": 0.3,
|
| 57 |
+
"recall_drop_from_baseline": 0.05,
|
| 58 |
+
"fpr_increase_from_baseline": 0.1
|
| 59 |
+
},
|
| 60 |
+
"revalidation_policy": {
|
| 61 |
+
"sample_size": 100,
|
| 62 |
+
"revalidation_interval_days": 30,
|
| 63 |
+
"auto_recalibrate": false
|
| 64 |
+
},
|
| 65 |
+
"safety_guardrails": {
|
| 66 |
+
"never_lower_recall_below": 0.85,
|
| 67 |
+
"never_raise_threshold_above": 0.7,
|
| 68 |
+
"require_human_approval": true
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}
|
artifacts/onnx_int8/config.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"add_cross_attention": false,
|
| 3 |
+
"architectures": [
|
| 4 |
+
"BertForSequenceClassification"
|
| 5 |
+
],
|
| 6 |
+
"attention_probs_dropout_prob": 0.1,
|
| 7 |
+
"bos_token_id": null,
|
| 8 |
+
"classifier_dropout": null,
|
| 9 |
+
"dtype": "float32",
|
| 10 |
+
"eos_token_id": null,
|
| 11 |
+
"hidden_act": "gelu",
|
| 12 |
+
"hidden_dropout_prob": 0.1,
|
| 13 |
+
"hidden_size": 768,
|
| 14 |
+
"id2label": {
|
| 15 |
+
"0": "ESCALATION",
|
| 16 |
+
"1": "APPOINTMENT",
|
| 17 |
+
"2": "MEDICATION",
|
| 18 |
+
"3": "SYMPTOM_CHECK",
|
| 19 |
+
"4": "GENERAL_INQUIRY",
|
| 20 |
+
"5": "BILLING"
|
| 21 |
+
},
|
| 22 |
+
"initializer_range": 0.02,
|
| 23 |
+
"intermediate_size": 3072,
|
| 24 |
+
"is_decoder": false,
|
| 25 |
+
"label2id": {
|
| 26 |
+
"APPOINTMENT": 1,
|
| 27 |
+
"BILLING": 5,
|
| 28 |
+
"ESCALATION": 0,
|
| 29 |
+
"GENERAL_INQUIRY": 4,
|
| 30 |
+
"MEDICATION": 2,
|
| 31 |
+
"SYMPTOM_CHECK": 3
|
| 32 |
+
},
|
| 33 |
+
"layer_norm_eps": 1e-12,
|
| 34 |
+
"max_position_embeddings": 512,
|
| 35 |
+
"model_type": "bert",
|
| 36 |
+
"num_attention_heads": 12,
|
| 37 |
+
"num_hidden_layers": 12,
|
| 38 |
+
"pad_token_id": 0,
|
| 39 |
+
"tie_word_embeddings": true,
|
| 40 |
+
"transformers_version": "5.0.0",
|
| 41 |
+
"type_vocab_size": 2,
|
| 42 |
+
"use_cache": false,
|
| 43 |
+
"vocab_size": 28996
|
| 44 |
+
}
|
artifacts/onnx_int8/decision_policy.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"version": "2.0.0",
|
| 3 |
+
"created_at": "2026-02-19T06:04:01.530506+00:00",
|
| 4 |
+
"escalation_policy": {
|
| 5 |
+
"escalation_label": "ESCALATION",
|
| 6 |
+
"escalation_threshold": 0.8069999999999998,
|
| 7 |
+
"description": "If P(ESCALATION) >= 0.807, escalate regardless of argmax. This prioritizes recall for safety-critical escalation detection."
|
| 8 |
+
},
|
| 9 |
+
"confidence_policy": {
|
| 10 |
+
"min_confidence": 0.3,
|
| 11 |
+
"fallback_label": "OTHER",
|
| 12 |
+
"description": "If max probability < 0.3, route to OTHER for review."
|
| 13 |
+
},
|
| 14 |
+
"calibration_metrics": {
|
| 15 |
+
"threshold": 0.8069999999999998,
|
| 16 |
+
"recall": 0.9515151515151515,
|
| 17 |
+
"precision": 0.9401197604790419,
|
| 18 |
+
"fpr": 0.00916590284142988,
|
| 19 |
+
"tp": 157,
|
| 20 |
+
"fp": 10,
|
| 21 |
+
"tn": 1081,
|
| 22 |
+
"fn": 8,
|
| 23 |
+
"recall_ci": {
|
| 24 |
+
"point_estimate": 0.9522554476419556,
|
| 25 |
+
"ci_lower": 0.9182282243473766,
|
| 26 |
+
"ci_upper": 0.9817073170731707,
|
| 27 |
+
"confidence_level": 0.95
|
| 28 |
+
},
|
| 29 |
+
"precision_ci": {
|
| 30 |
+
"point_estimate": 0.9408976664848623,
|
| 31 |
+
"ci_lower": 0.9015464082389816,
|
| 32 |
+
"ci_upper": 0.9719292994412722,
|
| 33 |
+
"confidence_level": 0.95
|
| 34 |
+
},
|
| 35 |
+
"fpr_ci": {
|
| 36 |
+
"point_estimate": 0.009122676909917872,
|
| 37 |
+
"ci_lower": 0.0045207956600361665,
|
| 38 |
+
"ci_upper": 0.015582034830430797,
|
| 39 |
+
"confidence_level": 0.95
|
| 40 |
+
}
|
| 41 |
+
},
|
| 42 |
+
"decision_logic": [
|
| 43 |
+
"1. If P(ESCALATION) >= 0.807 \u2192 ESCALATE",
|
| 44 |
+
"2. Else if max(P) < 0.3 \u2192 route to OTHER",
|
| 45 |
+
"3. Else \u2192 return argmax label"
|
| 46 |
+
],
|
| 47 |
+
"drift_monitoring": {
|
| 48 |
+
"enabled": true,
|
| 49 |
+
"baseline_metrics": {
|
| 50 |
+
"recall": 0.9515151515151515,
|
| 51 |
+
"fpr": 0.00916590284142988,
|
| 52 |
+
"calibration_date": "2026-02-19T06:04:01.530524+00:00"
|
| 53 |
+
},
|
| 54 |
+
"alert_thresholds": {
|
| 55 |
+
"min_recall": 0.9,
|
| 56 |
+
"max_fpr": 0.3,
|
| 57 |
+
"recall_drop_from_baseline": 0.05,
|
| 58 |
+
"fpr_increase_from_baseline": 0.1
|
| 59 |
+
},
|
| 60 |
+
"revalidation_policy": {
|
| 61 |
+
"sample_size": 100,
|
| 62 |
+
"revalidation_interval_days": 30,
|
| 63 |
+
"auto_recalibrate": false
|
| 64 |
+
},
|
| 65 |
+
"safety_guardrails": {
|
| 66 |
+
"never_lower_recall_below": 0.85,
|
| 67 |
+
"never_raise_threshold_above": 0.7,
|
| 68 |
+
"require_human_approval": true
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}
|
artifacts/onnx_int8/export_metadata.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"source_model": "artifacts\\pytorch\\latest",
|
| 3 |
+
"export_timestamp": "2026-02-09T05:34:58.146488+00:00",
|
| 4 |
+
"opset_version": 14,
|
| 5 |
+
"max_length": 64,
|
| 6 |
+
"format": "ONNX",
|
| 7 |
+
"quantization": null,
|
| 8 |
+
"provider": "CPUExecutionProvider"
|
| 9 |
+
}
|
artifacts/onnx_int8/label_mappings.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"label2id": {
|
| 3 |
+
"ESCALATION": 0,
|
| 4 |
+
"APPOINTMENT": 1,
|
| 5 |
+
"MEDICATION": 2,
|
| 6 |
+
"SYMPTOM_CHECK": 3,
|
| 7 |
+
"GENERAL_INQUIRY": 4,
|
| 8 |
+
"BILLING": 5
|
| 9 |
+
},
|
| 10 |
+
"id2label": {
|
| 11 |
+
"0": "ESCALATION",
|
| 12 |
+
"1": "APPOINTMENT",
|
| 13 |
+
"2": "MEDICATION",
|
| 14 |
+
"3": "SYMPTOM_CHECK",
|
| 15 |
+
"4": "GENERAL_INQUIRY",
|
| 16 |
+
"5": "BILLING"
|
| 17 |
+
}
|
| 18 |
+
}
|
artifacts/onnx_int8/model.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:839432f5a2f74ab12c1832182e6473386467ab1d44f812789854129080f54200
|
| 3 |
+
size 433351691
|
artifacts/onnx_int8/model.onnx.data
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2eca618854e0f1521bd8b19ed895062e990e19f5878c103e78a5504c76aa8de9
|
| 3 |
+
size 433270784
|
artifacts/onnx_int8/model_preprocessed.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2a8c406501f56f3345e2d0a59e37ffa30a428b134625bfa9805d961bfcbc414c
|
| 3 |
+
size 57448739
|
artifacts/onnx_int8/quantization_metadata.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"source_model": "artifacts\\onnx",
|
| 3 |
+
"quantization_timestamp": "2026-02-19T06:03:17.727140+00:00",
|
| 4 |
+
"quantization_type": "dynamic",
|
| 5 |
+
"weight_type": "INT8",
|
| 6 |
+
"is_quantized": true,
|
| 7 |
+
"original_size_mb": 414.67,
|
| 8 |
+
"quantized_size_mb": 413.28,
|
| 9 |
+
"compression_ratio": 1.0,
|
| 10 |
+
"provider": "CPUExecutionProvider",
|
| 11 |
+
"warning": null
|
| 12 |
+
}
|
artifacts/onnx_int8/special_tokens_map.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cls_token": {
|
| 3 |
+
"content": "[CLS]",
|
| 4 |
+
"lstrip": false,
|
| 5 |
+
"normalized": false,
|
| 6 |
+
"rstrip": false,
|
| 7 |
+
"single_word": false
|
| 8 |
+
},
|
| 9 |
+
"mask_token": {
|
| 10 |
+
"content": "[MASK]",
|
| 11 |
+
"lstrip": false,
|
| 12 |
+
"normalized": false,
|
| 13 |
+
"rstrip": false,
|
| 14 |
+
"single_word": false
|
| 15 |
+
},
|
| 16 |
+
"pad_token": {
|
| 17 |
+
"content": "[PAD]",
|
| 18 |
+
"lstrip": false,
|
| 19 |
+
"normalized": false,
|
| 20 |
+
"rstrip": false,
|
| 21 |
+
"single_word": false
|
| 22 |
+
},
|
| 23 |
+
"sep_token": {
|
| 24 |
+
"content": "[SEP]",
|
| 25 |
+
"lstrip": false,
|
| 26 |
+
"normalized": false,
|
| 27 |
+
"rstrip": false,
|
| 28 |
+
"single_word": false
|
| 29 |
+
},
|
| 30 |
+
"unk_token": {
|
| 31 |
+
"content": "[UNK]",
|
| 32 |
+
"lstrip": false,
|
| 33 |
+
"normalized": false,
|
| 34 |
+
"rstrip": false,
|
| 35 |
+
"single_word": false
|
| 36 |
+
}
|
| 37 |
+
}
|
artifacts/onnx_int8/tokenizer.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
artifacts/onnx_int8/tokenizer_config.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"backend": "tokenizers",
|
| 3 |
+
"cls_token": "[CLS]",
|
| 4 |
+
"do_lower_case": false,
|
| 5 |
+
"is_local": true,
|
| 6 |
+
"mask_token": "[MASK]",
|
| 7 |
+
"model_max_length": 1000000000000000019884624838656,
|
| 8 |
+
"pad_token": "[PAD]",
|
| 9 |
+
"sep_token": "[SEP]",
|
| 10 |
+
"strip_accents": null,
|
| 11 |
+
"tokenize_chinese_chars": true,
|
| 12 |
+
"tokenizer_class": "BertTokenizer",
|
| 13 |
+
"unk_token": "[UNK]"
|
| 14 |
+
}
|
artifacts/onnx_int8/training_metadata.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"base_model": "emilyalsentzer/Bio_ClinicalBERT",
|
| 3 |
+
"max_length": 64,
|
| 4 |
+
"num_labels": 6,
|
| 5 |
+
"labels": [
|
| 6 |
+
"ESCALATION",
|
| 7 |
+
"APPOINTMENT",
|
| 8 |
+
"MEDICATION",
|
| 9 |
+
"SYMPTOM_CHECK",
|
| 10 |
+
"GENERAL_INQUIRY",
|
| 11 |
+
"BILLING"
|
| 12 |
+
],
|
| 13 |
+
"training_config": {
|
| 14 |
+
"epochs": 8,
|
| 15 |
+
"batch_size": 16,
|
| 16 |
+
"learning_rate": 2e-05,
|
| 17 |
+
"max_length": 64
|
| 18 |
+
},
|
| 19 |
+
"timestamp": "2026-02-19T05:59:29.871942+00:00",
|
| 20 |
+
"seed": 42
|
| 21 |
+
}
|
artifacts/onnx_int8/vocab.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
config/streaming_intent.yaml
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Streaming Intent Router Configuration
|
| 2 |
+
# HMM-style belief updating for STT chunk processing
|
| 3 |
+
|
| 4 |
+
intents:
|
| 5 |
+
- ESCALATION
|
| 6 |
+
- APPOINTMENT
|
| 7 |
+
- MEDICATION
|
| 8 |
+
- SYMPTOM_CHECK
|
| 9 |
+
- BILLING
|
| 10 |
+
- GENERAL_INQUIRY
|
| 11 |
+
- OTHER
|
| 12 |
+
|
| 13 |
+
# Prior distribution (initial belief state)
|
| 14 |
+
prior:
|
| 15 |
+
ESCALATION: 0.05
|
| 16 |
+
APPOINTMENT: 0.15
|
| 17 |
+
MEDICATION: 0.15
|
| 18 |
+
SYMPTOM_CHECK: 0.15
|
| 19 |
+
BILLING: 0.10
|
| 20 |
+
GENERAL_INQUIRY: 0.15
|
| 21 |
+
OTHER: 0.25
|
| 22 |
+
|
| 23 |
+
# Transition matrix T[i,j] = P(intent_t=j | intent_{t-1}=i)
|
| 24 |
+
# Rows must sum to 1.0
|
| 25 |
+
# High diagonal = "stickiness" (intent tends to persist)
|
| 26 |
+
transition_matrix:
|
| 27 |
+
ESCALATION:
|
| 28 |
+
ESCALATION: 0.90
|
| 29 |
+
APPOINTMENT: 0.01
|
| 30 |
+
MEDICATION: 0.02
|
| 31 |
+
SYMPTOM_CHECK: 0.02
|
| 32 |
+
BILLING: 0.01
|
| 33 |
+
GENERAL_INQUIRY: 0.02
|
| 34 |
+
OTHER: 0.02
|
| 35 |
+
APPOINTMENT:
|
| 36 |
+
ESCALATION: 0.02
|
| 37 |
+
APPOINTMENT: 0.80
|
| 38 |
+
MEDICATION: 0.03
|
| 39 |
+
SYMPTOM_CHECK: 0.03
|
| 40 |
+
BILLING: 0.07
|
| 41 |
+
GENERAL_INQUIRY: 0.03
|
| 42 |
+
OTHER: 0.02
|
| 43 |
+
MEDICATION:
|
| 44 |
+
ESCALATION: 0.04
|
| 45 |
+
APPOINTMENT: 0.03
|
| 46 |
+
MEDICATION: 0.82
|
| 47 |
+
SYMPTOM_CHECK: 0.04
|
| 48 |
+
BILLING: 0.02
|
| 49 |
+
GENERAL_INQUIRY: 0.03
|
| 50 |
+
OTHER: 0.02
|
| 51 |
+
SYMPTOM_CHECK:
|
| 52 |
+
ESCALATION: 0.08
|
| 53 |
+
APPOINTMENT: 0.04
|
| 54 |
+
MEDICATION: 0.05
|
| 55 |
+
SYMPTOM_CHECK: 0.75
|
| 56 |
+
BILLING: 0.02
|
| 57 |
+
GENERAL_INQUIRY: 0.03
|
| 58 |
+
OTHER: 0.03
|
| 59 |
+
BILLING:
|
| 60 |
+
ESCALATION: 0.01
|
| 61 |
+
APPOINTMENT: 0.05
|
| 62 |
+
MEDICATION: 0.02
|
| 63 |
+
SYMPTOM_CHECK: 0.02
|
| 64 |
+
BILLING: 0.85
|
| 65 |
+
GENERAL_INQUIRY: 0.03
|
| 66 |
+
OTHER: 0.02
|
| 67 |
+
GENERAL_INQUIRY:
|
| 68 |
+
ESCALATION: 0.03
|
| 69 |
+
APPOINTMENT: 0.05
|
| 70 |
+
MEDICATION: 0.05
|
| 71 |
+
SYMPTOM_CHECK: 0.05
|
| 72 |
+
BILLING: 0.04
|
| 73 |
+
GENERAL_INQUIRY: 0.75
|
| 74 |
+
OTHER: 0.03
|
| 75 |
+
OTHER:
|
| 76 |
+
ESCALATION: 0.02
|
| 77 |
+
APPOINTMENT: 0.08
|
| 78 |
+
MEDICATION: 0.08
|
| 79 |
+
SYMPTOM_CHECK: 0.08
|
| 80 |
+
BILLING: 0.06
|
| 81 |
+
GENERAL_INQUIRY: 0.08
|
| 82 |
+
OTHER: 0.60
|
| 83 |
+
|
| 84 |
+
# Emission model parameters
|
| 85 |
+
emission:
|
| 86 |
+
alpha: 1.5 # Sharpening exponent for DriveHealthBERT probabilities
|
| 87 |
+
epsilon: 1.0e-8 # Smoothing to avoid zero probabilities
|
| 88 |
+
|
| 89 |
+
# Decision thresholds
|
| 90 |
+
thresholds:
|
| 91 |
+
theta_hi: 0.85 # Immediate escalation threshold
|
| 92 |
+
theta_med: 0.60 # Consecutive-steps escalation threshold
|
| 93 |
+
theta_lock: 0.70 # Commit threshold for non-escalation intents
|
| 94 |
+
K: 3 # Required consecutive steps for stability
|
| 95 |
+
|
| 96 |
+
# Rolling window parameters
|
| 97 |
+
window:
|
| 98 |
+
max_tokens: 64 # Default max tokens in rolling window
|
| 99 |
+
max_tokens_limit: 128 # Hard limit for max_tokens
|
| 100 |
+
|
| 101 |
+
# Debounce parameters
|
| 102 |
+
debounce:
|
| 103 |
+
debounce_ms: 150 # Minimum ms between updates (unless is_final)
|
| 104 |
+
min_change_chars: 3 # Minimum character change to trigger update
|
| 105 |
+
|
| 106 |
+
# Model parameters
|
| 107 |
+
model:
|
| 108 |
+
max_length: 48 # Max sequence length for BioClinicalBERT inference (reduced for latency)
|
| 109 |
+
|
| 110 |
+
# SPRT (Sequential Probability Ratio Test) parameters
|
| 111 |
+
# Used for statistically-principled escalation decisions
|
| 112 |
+
sprt:
|
| 113 |
+
alpha: 0.05 # Type I error target (false escalation rate)
|
| 114 |
+
beta: 0.10 # Type II error target (missed escalation rate)
|
| 115 |
+
p0: 0.20 # H0: baseline escalation probability (non-escalation)
|
| 116 |
+
p1: 0.60 # H1: expected escalation probability (true escalation)
|
| 117 |
+
# Note: Run scripts/evaluate_sprt_streaming.py --estimate_p0_p1 to tune these
|
config/train_config.yaml
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DCE Intent BioClinicalBERT Training Configuration
|
| 2 |
+
# ============================================
|
| 3 |
+
# Version: 2.0.0
|
| 4 |
+
# Last Updated: 2026-02-08
|
| 5 |
+
|
| 6 |
+
# Model Configuration
|
| 7 |
+
# Using BioClinicalBERT for medical domain accuracy
|
| 8 |
+
# Reduced max_length to 48 to maintain low latency
|
| 9 |
+
model:
|
| 10 |
+
base_model: "emilyalsentzer/Bio_ClinicalBERT"
|
| 11 |
+
max_length: 48 # Reduced from 64 to compensate for larger model
|
| 12 |
+
num_labels: null # Auto-detected from dataset
|
| 13 |
+
|
| 14 |
+
# Training Hyperparameters
|
| 15 |
+
training:
|
| 16 |
+
learning_rate: 3.0e-5
|
| 17 |
+
epochs: 15
|
| 18 |
+
batch_size: 16
|
| 19 |
+
eval_batch_size: 32
|
| 20 |
+
warmup_ratio: 0.1
|
| 21 |
+
weight_decay: 0.01
|
| 22 |
+
gradient_accumulation_steps: 1
|
| 23 |
+
max_grad_norm: 1.0
|
| 24 |
+
|
| 25 |
+
# Optimizer
|
| 26 |
+
optimizer:
|
| 27 |
+
type: "adamw"
|
| 28 |
+
betas: [0.9, 0.999]
|
| 29 |
+
eps: 1.0e-8
|
| 30 |
+
|
| 31 |
+
# Scheduler
|
| 32 |
+
scheduler:
|
| 33 |
+
type: "linear" # linear warmup then linear decay
|
| 34 |
+
|
| 35 |
+
# Data Paths
|
| 36 |
+
data:
|
| 37 |
+
train_path: "data/examples/train.jsonl"
|
| 38 |
+
val_path: "data/examples/val.jsonl"
|
| 39 |
+
test_path: "data/examples/test.jsonl"
|
| 40 |
+
text_column: "text"
|
| 41 |
+
label_column: "label"
|
| 42 |
+
|
| 43 |
+
# Output Configuration
|
| 44 |
+
output:
|
| 45 |
+
base_dir: "artifacts"
|
| 46 |
+
pytorch_dir: "artifacts/pytorch"
|
| 47 |
+
save_steps: 30
|
| 48 |
+
eval_steps: 15
|
| 49 |
+
logging_steps: 15
|
| 50 |
+
save_total_limit: 2
|
| 51 |
+
|
| 52 |
+
# Reproducibility
|
| 53 |
+
seed: 42
|
| 54 |
+
deterministic: true
|
| 55 |
+
|
| 56 |
+
# Logging
|
| 57 |
+
logging:
|
| 58 |
+
level: "INFO"
|
| 59 |
+
log_to_file: true
|
| 60 |
+
log_file: "artifacts/logs/training.log"
|
| 61 |
+
|
| 62 |
+
# Intent Schema (Tier 1 - Required)
|
| 63 |
+
intent_schema:
|
| 64 |
+
tier1:
|
| 65 |
+
- "ESCALATION" # Urgent medical escalation
|
| 66 |
+
- "APPOINTMENT" # Schedule/reschedule appointments
|
| 67 |
+
- "MEDICATION" # Medication-related queries
|
| 68 |
+
- "SYMPTOM_CHECK" # Symptom assessment
|
| 69 |
+
- "GENERAL_INQUIRY" # General health questions
|
| 70 |
+
- "BILLING" # Billing/insurance questions
|
| 71 |
+
- "OTHER" # Fallback category
|
| 72 |
+
tier2: # Optional sub-categories
|
| 73 |
+
ESCALATION:
|
| 74 |
+
- "CHEST_PAIN"
|
| 75 |
+
- "BREATHING_DIFFICULTY"
|
| 76 |
+
- "SEVERE_BLEEDING"
|
| 77 |
+
- "LOSS_OF_CONSCIOUSNESS"
|
| 78 |
+
- "OTHER_EMERGENCY"
|
| 79 |
+
|
| 80 |
+
# Evaluation Metrics
|
| 81 |
+
metrics:
|
| 82 |
+
primary: "macro_f1"
|
| 83 |
+
track:
|
| 84 |
+
- "accuracy"
|
| 85 |
+
- "macro_f1"
|
| 86 |
+
- "per_class_recall"
|
| 87 |
+
escalation_label: "ESCALATION" # Label to track recall for
|
config/train_config_augmented.yaml
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DCE Intent BioClinicalBERT Training Configuration - Augmented Dataset
|
| 2 |
+
# ======================================================================
|
| 3 |
+
# Version: 2.1.0
|
| 4 |
+
# Last Updated: 2026-02-13
|
| 5 |
+
# Description: Training config using merged dataset with disease symptom data
|
| 6 |
+
|
| 7 |
+
# Model Configuration
|
| 8 |
+
model:
|
| 9 |
+
base_model: "emilyalsentzer/Bio_ClinicalBERT"
|
| 10 |
+
max_length: 48
|
| 11 |
+
num_labels: null # Auto-detected from dataset
|
| 12 |
+
|
| 13 |
+
# Training Hyperparameters
|
| 14 |
+
# Slightly adjusted for larger dataset
|
| 15 |
+
training:
|
| 16 |
+
learning_rate: 2.5e-5 # Slightly lower LR for larger dataset
|
| 17 |
+
epochs: 12 # Slightly fewer epochs since more data
|
| 18 |
+
batch_size: 16
|
| 19 |
+
eval_batch_size: 32
|
| 20 |
+
warmup_ratio: 0.1
|
| 21 |
+
weight_decay: 0.01
|
| 22 |
+
gradient_accumulation_steps: 1
|
| 23 |
+
max_grad_norm: 1.0
|
| 24 |
+
|
| 25 |
+
# Optimizer
|
| 26 |
+
optimizer:
|
| 27 |
+
type: "adamw"
|
| 28 |
+
betas: [0.9, 0.999]
|
| 29 |
+
eps: 1.0e-8
|
| 30 |
+
|
| 31 |
+
# Scheduler
|
| 32 |
+
scheduler:
|
| 33 |
+
type: "linear"
|
| 34 |
+
|
| 35 |
+
# Data Paths - Using merged augmented dataset
|
| 36 |
+
data:
|
| 37 |
+
train_path: "data/merged/train.jsonl"
|
| 38 |
+
val_path: "data/merged/val.jsonl"
|
| 39 |
+
test_path: "data/merged/test.jsonl"
|
| 40 |
+
text_column: "text"
|
| 41 |
+
label_column: "label"
|
| 42 |
+
|
| 43 |
+
# Output Configuration
|
| 44 |
+
output:
|
| 45 |
+
base_dir: "artifacts"
|
| 46 |
+
pytorch_dir: "artifacts/pytorch"
|
| 47 |
+
save_steps: 50
|
| 48 |
+
eval_steps: 25
|
| 49 |
+
logging_steps: 25
|
| 50 |
+
save_total_limit: 2
|
| 51 |
+
|
| 52 |
+
# Reproducibility
|
| 53 |
+
seed: 42
|
| 54 |
+
deterministic: true
|
| 55 |
+
|
| 56 |
+
# Logging
|
| 57 |
+
logging:
|
| 58 |
+
level: "INFO"
|
| 59 |
+
log_to_file: true
|
| 60 |
+
log_file: "artifacts/logs/training_augmented.log"
|
| 61 |
+
|
| 62 |
+
# Intent Schema
|
| 63 |
+
intent_schema:
|
| 64 |
+
tier1:
|
| 65 |
+
- "ESCALATION"
|
| 66 |
+
- "APPOINTMENT"
|
| 67 |
+
- "MEDICATION"
|
| 68 |
+
- "SYMPTOM_CHECK"
|
| 69 |
+
- "GENERAL_INQUIRY"
|
| 70 |
+
- "BILLING"
|
| 71 |
+
- "OTHER"
|
| 72 |
+
|
| 73 |
+
# Evaluation Metrics
|
| 74 |
+
metrics:
|
| 75 |
+
primary: "macro_f1"
|
| 76 |
+
track:
|
| 77 |
+
- "accuracy"
|
| 78 |
+
- "macro_f1"
|
| 79 |
+
- "per_class_recall"
|
| 80 |
+
escalation_label: "ESCALATION"
|
config/train_config_v2.yaml
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DCE Intent BioClinicalBERT Training Configuration - v2 with Hard Negatives
|
| 2 |
+
# ===========================================================================
|
| 3 |
+
# Version: 2.2.0
|
| 4 |
+
# Description: Training with hard negative examples to reduce false positives
|
| 5 |
+
|
| 6 |
+
model:
|
| 7 |
+
base_model: "emilyalsentzer/Bio_ClinicalBERT"
|
| 8 |
+
max_length: 48
|
| 9 |
+
num_labels: null
|
| 10 |
+
|
| 11 |
+
training:
|
| 12 |
+
learning_rate: 2.0e-5 # Slightly lower for fine-tuning
|
| 13 |
+
epochs: 10
|
| 14 |
+
batch_size: 16
|
| 15 |
+
eval_batch_size: 32
|
| 16 |
+
warmup_ratio: 0.1
|
| 17 |
+
weight_decay: 0.01
|
| 18 |
+
gradient_accumulation_steps: 1
|
| 19 |
+
max_grad_norm: 1.0
|
| 20 |
+
|
| 21 |
+
optimizer:
|
| 22 |
+
type: "adamw"
|
| 23 |
+
betas: [0.9, 0.999]
|
| 24 |
+
eps: 1.0e-8
|
| 25 |
+
|
| 26 |
+
scheduler:
|
| 27 |
+
type: "linear"
|
| 28 |
+
|
| 29 |
+
# Using merged_v2 with hard negatives
|
| 30 |
+
data:
|
| 31 |
+
train_path: "data/merged_v2/train.jsonl"
|
| 32 |
+
val_path: "data/merged_v2/val.jsonl"
|
| 33 |
+
test_path: "data/merged_v2/test.jsonl"
|
| 34 |
+
text_column: "text"
|
| 35 |
+
label_column: "label"
|
| 36 |
+
|
| 37 |
+
output:
|
| 38 |
+
base_dir: "artifacts"
|
| 39 |
+
pytorch_dir: "artifacts/pytorch"
|
| 40 |
+
save_steps: 50
|
| 41 |
+
eval_steps: 25
|
| 42 |
+
logging_steps: 25
|
| 43 |
+
save_total_limit: 2
|
| 44 |
+
|
| 45 |
+
seed: 42
|
| 46 |
+
deterministic: true
|
| 47 |
+
|
| 48 |
+
logging:
|
| 49 |
+
level: "INFO"
|
| 50 |
+
log_to_file: true
|
| 51 |
+
log_file: "artifacts/logs/training_v2.log"
|
| 52 |
+
|
| 53 |
+
intent_schema:
|
| 54 |
+
tier1:
|
| 55 |
+
- "ESCALATION"
|
| 56 |
+
- "APPOINTMENT"
|
| 57 |
+
- "MEDICATION"
|
| 58 |
+
- "SYMPTOM_CHECK"
|
| 59 |
+
- "GENERAL_INQUIRY"
|
| 60 |
+
- "BILLING"
|
| 61 |
+
- "OTHER"
|
| 62 |
+
|
| 63 |
+
metrics:
|
| 64 |
+
primary: "macro_f1"
|
| 65 |
+
track:
|
| 66 |
+
- "accuracy"
|
| 67 |
+
- "macro_f1"
|
| 68 |
+
- "per_class_recall"
|
| 69 |
+
escalation_label: "ESCALATION"
|
config/train_config_v3.yaml
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DCE Intent BioClinicalBERT Training Configuration - v3 Taxonomy Augmented
|
| 2 |
+
# ===========================================================================
|
| 3 |
+
# Version: 3.0.0
|
| 4 |
+
# Description: Retrain with taxonomy-augmented data from 66 clinical domains
|
| 5 |
+
# Changes from v2:
|
| 6 |
+
# - Merged 1,720 taxonomy-generated samples (balanced, deduplicated)
|
| 7 |
+
# - Better coverage of minority classes (GENERAL_INQUIRY, APPOINTMENT, MEDICATION)
|
| 8 |
+
# - Negation examples to reduce false positive escalations
|
| 9 |
+
# - Slightly lower LR for fine-tuning on augmented mix
|
| 10 |
+
|
| 11 |
+
model:
|
| 12 |
+
base_model: "emilyalsentzer/Bio_ClinicalBERT"
|
| 13 |
+
max_length: 48
|
| 14 |
+
num_labels: null
|
| 15 |
+
|
| 16 |
+
training:
|
| 17 |
+
learning_rate: 2.0e-5
|
| 18 |
+
epochs: 6
|
| 19 |
+
batch_size: 16
|
| 20 |
+
eval_batch_size: 32
|
| 21 |
+
warmup_ratio: 0.1
|
| 22 |
+
weight_decay: 0.01
|
| 23 |
+
gradient_accumulation_steps: 1
|
| 24 |
+
max_grad_norm: 1.0
|
| 25 |
+
|
| 26 |
+
optimizer:
|
| 27 |
+
type: "adamw"
|
| 28 |
+
betas: [0.9, 0.999]
|
| 29 |
+
eps: 1.0e-8
|
| 30 |
+
|
| 31 |
+
scheduler:
|
| 32 |
+
type: "linear"
|
| 33 |
+
|
| 34 |
+
# Using merged_v3 with taxonomy augmentation
|
| 35 |
+
data:
|
| 36 |
+
train_path: "data/merged_v3/train.jsonl"
|
| 37 |
+
val_path: "data/merged_v3/val.jsonl"
|
| 38 |
+
test_path: "data/merged_v3/test.jsonl"
|
| 39 |
+
text_column: "text"
|
| 40 |
+
label_column: "label"
|
| 41 |
+
|
| 42 |
+
output:
|
| 43 |
+
base_dir: "artifacts"
|
| 44 |
+
pytorch_dir: "artifacts/pytorch"
|
| 45 |
+
save_steps: 200
|
| 46 |
+
eval_steps: 100
|
| 47 |
+
logging_steps: 50
|
| 48 |
+
save_total_limit: 3
|
| 49 |
+
|
| 50 |
+
seed: 42
|
| 51 |
+
deterministic: true
|
| 52 |
+
|
| 53 |
+
logging:
|
| 54 |
+
level: "INFO"
|
| 55 |
+
log_to_file: true
|
| 56 |
+
log_file: "artifacts/logs/training_v3.log"
|
| 57 |
+
|
| 58 |
+
intent_schema:
|
| 59 |
+
tier1:
|
| 60 |
+
- "ESCALATION"
|
| 61 |
+
- "APPOINTMENT"
|
| 62 |
+
- "MEDICATION"
|
| 63 |
+
- "SYMPTOM_CHECK"
|
| 64 |
+
- "GENERAL_INQUIRY"
|
| 65 |
+
- "BILLING"
|
| 66 |
+
- "OTHER"
|
| 67 |
+
|
| 68 |
+
metrics:
|
| 69 |
+
primary: "macro_f1"
|
| 70 |
+
track:
|
| 71 |
+
- "accuracy"
|
| 72 |
+
- "macro_f1"
|
| 73 |
+
- "per_class_recall"
|
| 74 |
+
escalation_label: "ESCALATION"
|
config/train_config_v4.yaml
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DCE Intent BioClinicalBERT Training Configuration - v4 Severity-Relabeled
|
| 2 |
+
# ===========================================================================
|
| 3 |
+
# Version: 4.0.0
|
| 4 |
+
# Description: Retrain with severity-based relabeling, merged OTHER, R3 augmentation
|
| 5 |
+
# Changes from v3:
|
| 6 |
+
# - ESCALATION/SYMPTOM_CHECK relabeled based on R3 severity rules (1,783 fixes)
|
| 7 |
+
# - OTHER merged into GENERAL_INQUIRY
|
| 8 |
+
# - 632 new R3-specific ESCALATION samples with distinct urgent phrasing
|
| 9 |
+
# - max_length increased to 64 (was 48) for edge-case multi-symptom text
|
| 10 |
+
# - Reduced escalation_weight, using focal loss for better hard-example handling
|
| 11 |
+
# - 6 labels (no more OTHER)
|
| 12 |
+
|
| 13 |
+
model:
|
| 14 |
+
base_model: "emilyalsentzer/Bio_ClinicalBERT"
|
| 15 |
+
max_length: 64
|
| 16 |
+
num_labels: null
|
| 17 |
+
|
| 18 |
+
training:
|
| 19 |
+
learning_rate: 2.0e-5
|
| 20 |
+
epochs: 8
|
| 21 |
+
batch_size: 16
|
| 22 |
+
eval_batch_size: 32
|
| 23 |
+
warmup_ratio: 0.1
|
| 24 |
+
weight_decay: 0.01
|
| 25 |
+
gradient_accumulation_steps: 1
|
| 26 |
+
max_grad_norm: 1.0
|
| 27 |
+
|
| 28 |
+
optimizer:
|
| 29 |
+
type: "adamw"
|
| 30 |
+
betas: [0.9, 0.999]
|
| 31 |
+
eps: 1.0e-8
|
| 32 |
+
|
| 33 |
+
scheduler:
|
| 34 |
+
type: "linear"
|
| 35 |
+
|
| 36 |
+
data:
|
| 37 |
+
train_path: "data/merged_v4/train.jsonl"
|
| 38 |
+
val_path: "data/merged_v4/val.jsonl"
|
| 39 |
+
test_path: "data/merged_v4/test.jsonl"
|
| 40 |
+
text_column: "text"
|
| 41 |
+
label_column: "label"
|
| 42 |
+
|
| 43 |
+
output:
|
| 44 |
+
base_dir: "artifacts"
|
| 45 |
+
pytorch_dir: "artifacts/pytorch"
|
| 46 |
+
save_steps: 200
|
| 47 |
+
eval_steps: 100
|
| 48 |
+
logging_steps: 50
|
| 49 |
+
save_total_limit: 3
|
| 50 |
+
|
| 51 |
+
seed: 42
|
| 52 |
+
deterministic: true
|
| 53 |
+
|
| 54 |
+
logging:
|
| 55 |
+
level: "INFO"
|
| 56 |
+
log_to_file: true
|
| 57 |
+
log_file: "artifacts/logs/training_v4.log"
|
| 58 |
+
|
| 59 |
+
intent_schema:
|
| 60 |
+
tier1:
|
| 61 |
+
- "ESCALATION"
|
| 62 |
+
- "APPOINTMENT"
|
| 63 |
+
- "MEDICATION"
|
| 64 |
+
- "SYMPTOM_CHECK"
|
| 65 |
+
- "GENERAL_INQUIRY"
|
| 66 |
+
- "BILLING"
|
| 67 |
+
|
| 68 |
+
metrics:
|
| 69 |
+
primary: "macro_f1"
|
| 70 |
+
track:
|
| 71 |
+
- "accuracy"
|
| 72 |
+
- "macro_f1"
|
| 73 |
+
- "per_class_recall"
|
| 74 |
+
escalation_label: "ESCALATION"
|
config/train_config_v5.yaml
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DCE Intent BioClinicalBERT Training Configuration - v5 Temporal Context
|
| 2 |
+
# ===========================================================================
|
| 3 |
+
# Version: 5.0.0
|
| 4 |
+
# Description: v4 base + 1,180 temporal/historical context samples
|
| 5 |
+
# Changes from v4:
|
| 6 |
+
# - 820 past/resolved symptom samples (SYMPTOM_CHECK) - teaches past tense
|
| 7 |
+
# - 280 still-active-despite-past-framing samples (ESCALATION) - preserves recall
|
| 8 |
+
# - 80 third-party/informational samples (GENERAL_INQUIRY)
|
| 9 |
+
# - Total: 7,338 train + 1,256 val
|
| 10 |
+
|
| 11 |
+
model:
|
| 12 |
+
base_model: "emilyalsentzer/Bio_ClinicalBERT"
|
| 13 |
+
max_length: 64
|
| 14 |
+
num_labels: null
|
| 15 |
+
|
| 16 |
+
training:
|
| 17 |
+
learning_rate: 2.0e-5
|
| 18 |
+
epochs: 8
|
| 19 |
+
batch_size: 16
|
| 20 |
+
eval_batch_size: 32
|
| 21 |
+
warmup_ratio: 0.1
|
| 22 |
+
weight_decay: 0.01
|
| 23 |
+
gradient_accumulation_steps: 1
|
| 24 |
+
max_grad_norm: 1.0
|
| 25 |
+
|
| 26 |
+
optimizer:
|
| 27 |
+
type: "adamw"
|
| 28 |
+
betas: [0.9, 0.999]
|
| 29 |
+
eps: 1.0e-8
|
| 30 |
+
|
| 31 |
+
scheduler:
|
| 32 |
+
type: "linear"
|
| 33 |
+
|
| 34 |
+
data:
|
| 35 |
+
train_path: "data/merged_v5/train.jsonl"
|
| 36 |
+
val_path: "data/merged_v5/val.jsonl"
|
| 37 |
+
text_column: "text"
|
| 38 |
+
label_column: "label"
|
| 39 |
+
|
| 40 |
+
output:
|
| 41 |
+
base_dir: "artifacts"
|
| 42 |
+
pytorch_dir: "artifacts/pytorch"
|
| 43 |
+
save_steps: 200
|
| 44 |
+
eval_steps: 100
|
| 45 |
+
logging_steps: 50
|
| 46 |
+
save_total_limit: 3
|
| 47 |
+
|
| 48 |
+
seed: 42
|
| 49 |
+
deterministic: true
|
| 50 |
+
|
| 51 |
+
logging:
|
| 52 |
+
level: "INFO"
|
| 53 |
+
log_to_file: true
|
| 54 |
+
log_file: "artifacts/logs/training_v5.log"
|
| 55 |
+
|
| 56 |
+
intent_schema:
|
| 57 |
+
tier1:
|
| 58 |
+
- "ESCALATION"
|
| 59 |
+
- "APPOINTMENT"
|
| 60 |
+
- "MEDICATION"
|
| 61 |
+
- "SYMPTOM_CHECK"
|
| 62 |
+
- "GENERAL_INQUIRY"
|
| 63 |
+
- "BILLING"
|
| 64 |
+
|
| 65 |
+
metrics:
|
| 66 |
+
primary: "macro_f1"
|
| 67 |
+
track:
|
| 68 |
+
- "accuracy"
|
| 69 |
+
- "macro_f1"
|
| 70 |
+
- "per_class_recall"
|
| 71 |
+
escalation_label: "ESCALATION"
|
config/train_config_v6.yaml
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DCE Intent BioClinicalBERT Training Configuration - v6 Temporal Edge Cases
|
| 2 |
+
# ===========================================================================
|
| 3 |
+
# Version: 6.0.0
|
| 4 |
+
# Description: v5 base + 840 temporal edge case samples
|
| 5 |
+
# Changes from v5:
|
| 6 |
+
# - 300 BUT-clause reversal samples (ESCALATION) - "felt better but now it's back"
|
| 7 |
+
# - 300 proxy/third-party urgent reports (ESCALATION) - "my mom says chest hurts"
|
| 8 |
+
# - 90 proxy non-urgent (SYMPTOM_CHECK/GENERAL_INQUIRY) - "my friend had X last year"
|
| 9 |
+
# - 80 mixed temporal active (ESCALATION) - "had X and it hasn't gone away"
|
| 10 |
+
# - 20 intermittent dangerous (ESCALATION) - "sometimes I have trouble breathing"
|
| 11 |
+
# - 25 negated symptoms (SYMPTOM_CHECK) - "I don't have chest pain"
|
| 12 |
+
# - 25 informational (GENERAL_INQUIRY) - "what causes seizures"
|
| 13 |
+
# - Total: ~8,178 train + 1,256 val
|
| 14 |
+
|
| 15 |
+
model:
|
| 16 |
+
base_model: "emilyalsentzer/Bio_ClinicalBERT"
|
| 17 |
+
max_length: 64
|
| 18 |
+
num_labels: null
|
| 19 |
+
|
| 20 |
+
training:
|
| 21 |
+
learning_rate: 2.0e-5
|
| 22 |
+
epochs: 8
|
| 23 |
+
batch_size: 16
|
| 24 |
+
eval_batch_size: 32
|
| 25 |
+
warmup_ratio: 0.1
|
| 26 |
+
weight_decay: 0.01
|
| 27 |
+
gradient_accumulation_steps: 1
|
| 28 |
+
max_grad_norm: 1.0
|
| 29 |
+
|
| 30 |
+
optimizer:
|
| 31 |
+
type: "adamw"
|
| 32 |
+
betas: [0.9, 0.999]
|
| 33 |
+
eps: 1.0e-8
|
| 34 |
+
|
| 35 |
+
scheduler:
|
| 36 |
+
type: "linear"
|
| 37 |
+
|
| 38 |
+
data:
|
| 39 |
+
train_path: "data/merged_v6/train.jsonl"
|
| 40 |
+
val_path: "data/merged_v6/val.jsonl"
|
| 41 |
+
text_column: "text"
|
| 42 |
+
label_column: "label"
|
| 43 |
+
|
| 44 |
+
output:
|
| 45 |
+
base_dir: "artifacts"
|
| 46 |
+
pytorch_dir: "artifacts/pytorch"
|
| 47 |
+
save_steps: 200
|
| 48 |
+
eval_steps: 100
|
| 49 |
+
logging_steps: 50
|
| 50 |
+
save_total_limit: 3
|
| 51 |
+
|
| 52 |
+
seed: 42
|
| 53 |
+
deterministic: true
|
| 54 |
+
|
| 55 |
+
logging:
|
| 56 |
+
level: "INFO"
|
| 57 |
+
log_to_file: true
|
| 58 |
+
log_file: "artifacts/logs/training_v6.log"
|
| 59 |
+
|
| 60 |
+
intent_schema:
|
| 61 |
+
tier1:
|
| 62 |
+
- "ESCALATION"
|
| 63 |
+
- "APPOINTMENT"
|
| 64 |
+
- "MEDICATION"
|
| 65 |
+
- "SYMPTOM_CHECK"
|
| 66 |
+
- "GENERAL_INQUIRY"
|
| 67 |
+
- "BILLING"
|
| 68 |
+
|
| 69 |
+
metrics:
|
| 70 |
+
primary: "macro_f1"
|
| 71 |
+
track:
|
| 72 |
+
- "accuracy"
|
| 73 |
+
- "macro_f1"
|
| 74 |
+
- "per_class_recall"
|
| 75 |
+
escalation_label: "ESCALATION"
|
decision/context/fhir_mappings.yaml
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FHIR Resource Mappings for Patient Context
|
| 2 |
+
#
|
| 3 |
+
# Maps FHIR resources to PatientContext fields, organized by wedge.
|
| 4 |
+
# Used by PatientContextBuilder to fetch only what's needed (minimum necessary).
|
| 5 |
+
|
| 6 |
+
# Which FHIR resources to fetch per wedge (minimum necessary principle)
|
| 7 |
+
wedge_resources:
|
| 8 |
+
post_discharge_engagement:
|
| 9 |
+
- Patient
|
| 10 |
+
- Condition
|
| 11 |
+
- MedicationRequest
|
| 12 |
+
- Observation # labs + vitals
|
| 13 |
+
- AllergyIntolerance
|
| 14 |
+
- Encounter
|
| 15 |
+
- DocumentReference # discharge summary
|
| 16 |
+
- Appointment
|
| 17 |
+
pre_op_optimization:
|
| 18 |
+
- Patient
|
| 19 |
+
- Condition
|
| 20 |
+
- MedicationRequest
|
| 21 |
+
- AllergyIntolerance
|
| 22 |
+
- ServiceRequest # scheduled procedures
|
| 23 |
+
- Appointment
|
| 24 |
+
annual_wellness_visit:
|
| 25 |
+
- Patient
|
| 26 |
+
- Condition
|
| 27 |
+
- MedicationRequest
|
| 28 |
+
- Observation
|
| 29 |
+
- Immunization
|
| 30 |
+
- AllergyIntolerance
|
| 31 |
+
gap_closure:
|
| 32 |
+
- Patient
|
| 33 |
+
- Condition
|
| 34 |
+
- Immunization
|
| 35 |
+
- Observation # screening results
|
| 36 |
+
health_risk_assessment:
|
| 37 |
+
- Patient
|
| 38 |
+
- Condition
|
| 39 |
+
- MedicationRequest
|
| 40 |
+
- Observation # labs + screenings
|
| 41 |
+
- AllergyIntolerance
|
| 42 |
+
|
| 43 |
+
# Default lookback periods per resource type
|
| 44 |
+
lookback_days:
|
| 45 |
+
Observation_laboratory: 90
|
| 46 |
+
Observation_vital_signs: 30
|
| 47 |
+
MedicationRequest: 365
|
| 48 |
+
Condition: null # all active, no date filter
|
| 49 |
+
Encounter: 365
|
| 50 |
+
Immunization: 730
|
| 51 |
+
Appointment: 90 # upcoming only
|
| 52 |
+
|
| 53 |
+
# Freshness thresholds (hours) for staleness detection
|
| 54 |
+
freshness_thresholds:
|
| 55 |
+
MedicationRequest: 24
|
| 56 |
+
Condition: 168 # 7 days
|
| 57 |
+
Observation_laboratory: 48
|
| 58 |
+
Observation_vital_signs: 24
|
| 59 |
+
AllergyIntolerance: 720 # 30 days
|
| 60 |
+
Immunization: 720
|
| 61 |
+
Appointment: 12
|
decision/engine/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Decision Engine for DCE Intent Classification.
|
| 3 |
+
|
| 4 |
+
Phase 1: Safety Trifecta
|
| 5 |
+
- Taxonomy trigger matching (phrase, regex, partial, negation)
|
| 6 |
+
- Domain nomination (DriveHealthBERT + triggers → ranked domains)
|
| 7 |
+
- Risk classification (R0/R1/R2/R3)
|
| 8 |
+
|
| 9 |
+
Phase 2: Conversation Engine
|
| 10 |
+
- Flow execution (state machine, slot tracking)
|
| 11 |
+
- Streaming flow integration
|
| 12 |
+
|
| 13 |
+
Phase 3: Clinical Intelligence
|
| 14 |
+
- FHIR context-aware risk rules
|
| 15 |
+
- Multi-tenant configuration
|
| 16 |
+
- Journey orchestration
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from decision.engine.models import (
|
| 20 |
+
ConfidenceTier,
|
| 21 |
+
RiskClass,
|
| 22 |
+
TriggerMatch,
|
| 23 |
+
DomainNomination,
|
| 24 |
+
RiskAssessment,
|
| 25 |
+
NegationResult,
|
| 26 |
+
)
|
| 27 |
+
from decision.engine.trigger_engine import TaxonomyTriggerEngine
|
| 28 |
+
from decision.engine.domain_nomination import DomainNominator
|
| 29 |
+
from decision.engine.risk_classifier import RiskClassifier
|
| 30 |
+
from decision.engine.config_loader import DecisionConfigLoader
|
| 31 |
+
from decision.engine.flow_engine import FlowEngine, FlowResolver, TurnResult
|
| 32 |
+
from decision.engine.fhir_context import PatientContext, PatientContextBuilder
|
| 33 |
+
from decision.engine.tenant_manager import TenantManager, TenantConfig
|
| 34 |
+
from decision.engine.journey_orchestrator import JourneyOrchestrator
|
| 35 |
+
|
| 36 |
+
__all__ = [
|
| 37 |
+
"ConfidenceTier",
|
| 38 |
+
"RiskClass",
|
| 39 |
+
"TriggerMatch",
|
| 40 |
+
"DomainNomination",
|
| 41 |
+
"RiskAssessment",
|
| 42 |
+
"NegationResult",
|
| 43 |
+
"TaxonomyTriggerEngine",
|
| 44 |
+
"DomainNominator",
|
| 45 |
+
"RiskClassifier",
|
| 46 |
+
"DecisionConfigLoader",
|
| 47 |
+
"FlowEngine",
|
| 48 |
+
"FlowResolver",
|
| 49 |
+
"TurnResult",
|
| 50 |
+
"PatientContext",
|
| 51 |
+
"PatientContextBuilder",
|
| 52 |
+
"TenantManager",
|
| 53 |
+
"TenantConfig",
|
| 54 |
+
"JourneyOrchestrator",
|
| 55 |
+
]
|
decision/engine/config_loader.py
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration loader for the Decision Engine.
|
| 3 |
+
|
| 4 |
+
Loads and validates all YAML configuration from the decision/ folder:
|
| 5 |
+
- global/global_rules.yaml
|
| 6 |
+
- taxonomy/*/triggers.yaml, rules.yaml, flows/*.yaml
|
| 7 |
+
- primitives/*.yaml
|
| 8 |
+
- orchestrator/*.yaml
|
| 9 |
+
- context/fhir_mappings.yaml
|
| 10 |
+
- tenants/*/tenant.yaml
|
| 11 |
+
- journeys/*.yaml
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
import os
|
| 18 |
+
import re
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import Any, Dict, List, Optional, Set
|
| 21 |
+
|
| 22 |
+
import yaml
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger("decision.config_loader")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class DecisionConfigLoader:
|
| 28 |
+
"""Loads and caches all decision engine configuration from YAML files."""
|
| 29 |
+
|
| 30 |
+
def __init__(self, decision_dir: str | Path):
|
| 31 |
+
self.decision_dir = Path(decision_dir)
|
| 32 |
+
if not self.decision_dir.is_dir():
|
| 33 |
+
raise FileNotFoundError(
|
| 34 |
+
f"Decision config directory not found: {self.decision_dir}"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# Loaded config caches
|
| 38 |
+
self._global_rules: Optional[Dict[str, Any]] = None
|
| 39 |
+
self._taxonomy_triggers: Optional[Dict[str, Dict[str, Any]]] = None
|
| 40 |
+
self._taxonomy_rules: Optional[Dict[str, Dict[str, Any]]] = None
|
| 41 |
+
self._taxonomy_flows: Optional[Dict[str, Dict[str, Dict[str, Any]]]] = None
|
| 42 |
+
self._primitives: Optional[Dict[str, Dict[str, Any]]] = None
|
| 43 |
+
self._orchestrator: Optional[Dict[str, Dict[str, Any]]] = None
|
| 44 |
+
self._fhir_mappings: Optional[Dict[str, Any]] = None
|
| 45 |
+
self._tenants: Optional[Dict[str, Dict[str, Any]]] = None
|
| 46 |
+
self._journeys: Optional[Dict[str, Dict[str, Any]]] = None
|
| 47 |
+
|
| 48 |
+
# ------------------------------------------------------------------
|
| 49 |
+
# Public API
|
| 50 |
+
# ------------------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
def load_all(self) -> None:
|
| 53 |
+
"""Load all configuration files. Call once at startup."""
|
| 54 |
+
logger.info("Loading decision engine configuration from %s", self.decision_dir)
|
| 55 |
+
self._load_global_rules()
|
| 56 |
+
self._load_taxonomy()
|
| 57 |
+
self._load_primitives()
|
| 58 |
+
self._load_orchestrator()
|
| 59 |
+
self._load_fhir_mappings()
|
| 60 |
+
self._load_tenants()
|
| 61 |
+
self._load_journeys()
|
| 62 |
+
self._validate()
|
| 63 |
+
logger.info(
|
| 64 |
+
"Decision config loaded: %d domains, %d primitives, %d tenants, %d journeys",
|
| 65 |
+
len(self._taxonomy_triggers or {}),
|
| 66 |
+
len(self._primitives or {}),
|
| 67 |
+
len(self._tenants or {}),
|
| 68 |
+
len(self._journeys or {}),
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
@property
|
| 72 |
+
def global_rules(self) -> Dict[str, Any]:
|
| 73 |
+
if self._global_rules is None:
|
| 74 |
+
self._load_global_rules()
|
| 75 |
+
return self._global_rules
|
| 76 |
+
|
| 77 |
+
@property
|
| 78 |
+
def taxonomy_triggers(self) -> Dict[str, Dict[str, Any]]:
|
| 79 |
+
"""domain_name -> triggers.yaml content"""
|
| 80 |
+
if self._taxonomy_triggers is None:
|
| 81 |
+
self._load_taxonomy()
|
| 82 |
+
return self._taxonomy_triggers
|
| 83 |
+
|
| 84 |
+
@property
|
| 85 |
+
def taxonomy_rules(self) -> Dict[str, Dict[str, Any]]:
|
| 86 |
+
"""domain_name -> rules.yaml content"""
|
| 87 |
+
if self._taxonomy_rules is None:
|
| 88 |
+
self._load_taxonomy()
|
| 89 |
+
return self._taxonomy_rules
|
| 90 |
+
|
| 91 |
+
@property
|
| 92 |
+
def taxonomy_flows(self) -> Dict[str, Dict[str, Dict[str, Any]]]:
|
| 93 |
+
"""domain_name -> {flow_id -> flow.yaml content}"""
|
| 94 |
+
if self._taxonomy_flows is None:
|
| 95 |
+
self._load_taxonomy()
|
| 96 |
+
return self._taxonomy_flows
|
| 97 |
+
|
| 98 |
+
@property
|
| 99 |
+
def primitives(self) -> Dict[str, Dict[str, Any]]:
|
| 100 |
+
if self._primitives is None:
|
| 101 |
+
self._load_primitives()
|
| 102 |
+
return self._primitives
|
| 103 |
+
|
| 104 |
+
@property
|
| 105 |
+
def orchestrator(self) -> Dict[str, Dict[str, Any]]:
|
| 106 |
+
if self._orchestrator is None:
|
| 107 |
+
self._load_orchestrator()
|
| 108 |
+
return self._orchestrator
|
| 109 |
+
|
| 110 |
+
@property
|
| 111 |
+
def fhir_mappings(self) -> Dict[str, Any]:
|
| 112 |
+
if self._fhir_mappings is None:
|
| 113 |
+
self._load_fhir_mappings()
|
| 114 |
+
return self._fhir_mappings
|
| 115 |
+
|
| 116 |
+
@property
|
| 117 |
+
def tenants(self) -> Dict[str, Dict[str, Any]]:
|
| 118 |
+
if self._tenants is None:
|
| 119 |
+
self._load_tenants()
|
| 120 |
+
return self._tenants
|
| 121 |
+
|
| 122 |
+
@property
|
| 123 |
+
def journeys(self) -> Dict[str, Dict[str, Any]]:
|
| 124 |
+
if self._journeys is None:
|
| 125 |
+
self._load_journeys()
|
| 126 |
+
return self._journeys
|
| 127 |
+
|
| 128 |
+
# ------------------------------------------------------------------
|
| 129 |
+
# Derived accessors
|
| 130 |
+
# ------------------------------------------------------------------
|
| 131 |
+
|
| 132 |
+
@property
|
| 133 |
+
def safety_precedence(self) -> List[str]:
|
| 134 |
+
"""Ordered list of domains by clinical acuity (highest first)."""
|
| 135 |
+
return self.global_rules.get("safety_precedence", [])
|
| 136 |
+
|
| 137 |
+
@property
|
| 138 |
+
def hard_escalate_domains(self) -> Set[str]:
|
| 139 |
+
"""Domains that always escalate regardless of confidence.
|
| 140 |
+
|
| 141 |
+
Includes both explicitly listed domains from global_rules AND
|
| 142 |
+
any taxonomy domain whose rules.yaml declares target_risk_class: R3.
|
| 143 |
+
"""
|
| 144 |
+
explicit = set(self.global_rules.get("hard_escalate_domains", []))
|
| 145 |
+
# Auto-include every domain with target_risk_class R3
|
| 146 |
+
for domain_name, rules in (self._taxonomy_rules or {}).items():
|
| 147 |
+
if rules.get("target_risk_class") == "R3":
|
| 148 |
+
explicit.add(domain_name)
|
| 149 |
+
return explicit
|
| 150 |
+
|
| 151 |
+
@property
|
| 152 |
+
def risk_escalation_rules(self) -> List[Dict[str, Any]]:
|
| 153 |
+
"""Risk escalation rule definitions from global_rules."""
|
| 154 |
+
return self.global_rules.get("risk_escalation_rules", [])
|
| 155 |
+
|
| 156 |
+
@property
|
| 157 |
+
def domain_suppression_rules(self) -> List[Dict[str, Any]]:
|
| 158 |
+
"""Domain suppression rules from global_rules."""
|
| 159 |
+
return self.global_rules.get("domain_suppression", [])
|
| 160 |
+
|
| 161 |
+
@property
|
| 162 |
+
def risk_class_descriptions(self) -> Dict[str, str]:
|
| 163 |
+
"""R0/R1/R2/R3 descriptions."""
|
| 164 |
+
return self.global_rules.get("risk_classes", {})
|
| 165 |
+
|
| 166 |
+
@property
|
| 167 |
+
def domain_names(self) -> List[str]:
|
| 168 |
+
"""All known domain names from taxonomy."""
|
| 169 |
+
return sorted(self.taxonomy_triggers.keys())
|
| 170 |
+
|
| 171 |
+
def get_tenant(self, tenant_id: str) -> Optional[Dict[str, Any]]:
|
| 172 |
+
"""Get tenant config by ID."""
|
| 173 |
+
return self.tenants.get(tenant_id)
|
| 174 |
+
|
| 175 |
+
def get_domain_priority(self, domain: str) -> int:
|
| 176 |
+
"""Get priority for a domain (higher = more urgent). Default 0."""
|
| 177 |
+
triggers = self.taxonomy_triggers.get(domain, {})
|
| 178 |
+
return triggers.get("priority", 0)
|
| 179 |
+
|
| 180 |
+
def get_domain_target_risk(self, domain: str) -> Optional[str]:
|
| 181 |
+
"""Get default target risk class for a domain from rules.yaml."""
|
| 182 |
+
rules = self.taxonomy_rules.get(domain, {})
|
| 183 |
+
return rules.get("target_risk_class")
|
| 184 |
+
|
| 185 |
+
def get_domain_decision_rules(self, domain: str) -> List[Dict[str, Any]]:
|
| 186 |
+
"""Get decision rules for a domain."""
|
| 187 |
+
rules = self.taxonomy_rules.get(domain, {})
|
| 188 |
+
return rules.get("decision_rules", [])
|
| 189 |
+
|
| 190 |
+
# ------------------------------------------------------------------
|
| 191 |
+
# Internal loaders
|
| 192 |
+
# ------------------------------------------------------------------
|
| 193 |
+
|
| 194 |
+
def _safe_load_yaml(self, path: Path) -> Dict[str, Any]:
|
| 195 |
+
"""Load a YAML file safely, returning empty dict on error."""
|
| 196 |
+
try:
|
| 197 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 198 |
+
data = yaml.safe_load(f)
|
| 199 |
+
return data if isinstance(data, dict) else {}
|
| 200 |
+
except Exception as e:
|
| 201 |
+
logger.warning("Failed to load YAML %s: %s", path, e)
|
| 202 |
+
return {}
|
| 203 |
+
|
| 204 |
+
def _load_global_rules(self) -> None:
|
| 205 |
+
path = self.decision_dir / "global" / "global_rules.yaml"
|
| 206 |
+
self._global_rules = self._safe_load_yaml(path)
|
| 207 |
+
if not self._global_rules:
|
| 208 |
+
logger.error("CRITICAL: global_rules.yaml is empty or missing at %s", path)
|
| 209 |
+
|
| 210 |
+
def _load_taxonomy(self) -> None:
|
| 211 |
+
self._taxonomy_triggers = {}
|
| 212 |
+
self._taxonomy_rules = {}
|
| 213 |
+
self._taxonomy_flows = {}
|
| 214 |
+
|
| 215 |
+
taxonomy_dir = self.decision_dir / "taxonomy"
|
| 216 |
+
if not taxonomy_dir.is_dir():
|
| 217 |
+
logger.warning("Taxonomy directory not found: %s", taxonomy_dir)
|
| 218 |
+
return
|
| 219 |
+
|
| 220 |
+
for domain_dir in sorted(taxonomy_dir.iterdir()):
|
| 221 |
+
if not domain_dir.is_dir():
|
| 222 |
+
continue
|
| 223 |
+
domain_name = domain_dir.name
|
| 224 |
+
|
| 225 |
+
# Load triggers.yaml
|
| 226 |
+
triggers_path = domain_dir / "triggers.yaml"
|
| 227 |
+
if triggers_path.is_file():
|
| 228 |
+
triggers = self._safe_load_yaml(triggers_path)
|
| 229 |
+
if triggers:
|
| 230 |
+
self._taxonomy_triggers[domain_name] = triggers
|
| 231 |
+
# Pre-compile regex patterns for performance
|
| 232 |
+
self._compile_trigger_regex(domain_name, triggers)
|
| 233 |
+
|
| 234 |
+
# Load rules.yaml
|
| 235 |
+
rules_path = domain_dir / "rules.yaml"
|
| 236 |
+
if rules_path.is_file():
|
| 237 |
+
rules = self._safe_load_yaml(rules_path)
|
| 238 |
+
if rules:
|
| 239 |
+
self._taxonomy_rules[domain_name] = rules
|
| 240 |
+
|
| 241 |
+
# Load flows/*.yaml
|
| 242 |
+
flows_dir = domain_dir / "flows"
|
| 243 |
+
if flows_dir.is_dir():
|
| 244 |
+
domain_flows = {}
|
| 245 |
+
for flow_file in sorted(flows_dir.iterdir()):
|
| 246 |
+
if flow_file.suffix in (".yaml", ".yml"):
|
| 247 |
+
flow_data = self._safe_load_yaml(flow_file)
|
| 248 |
+
if flow_data:
|
| 249 |
+
flow_id = flow_data.get("flow_id", flow_file.stem)
|
| 250 |
+
domain_flows[flow_id] = flow_data
|
| 251 |
+
if domain_flows:
|
| 252 |
+
self._taxonomy_flows[domain_name] = domain_flows
|
| 253 |
+
|
| 254 |
+
def _compile_trigger_regex(
|
| 255 |
+
self, domain_name: str, triggers: Dict[str, Any]
|
| 256 |
+
) -> None:
|
| 257 |
+
"""Pre-compile regex patterns in trigger definitions for performance."""
|
| 258 |
+
lexical = triggers.get("lexical_signals", {})
|
| 259 |
+
for tier_name in ("high", "medium", "low"):
|
| 260 |
+
tier = lexical.get(tier_name, {})
|
| 261 |
+
raw_patterns = tier.get("regex", [])
|
| 262 |
+
compiled = []
|
| 263 |
+
for pattern in raw_patterns:
|
| 264 |
+
try:
|
| 265 |
+
compiled.append(re.compile(pattern, re.IGNORECASE))
|
| 266 |
+
except re.error as e:
|
| 267 |
+
logger.warning(
|
| 268 |
+
"Invalid regex in %s/%s: %r → %s",
|
| 269 |
+
domain_name,
|
| 270 |
+
tier_name,
|
| 271 |
+
pattern,
|
| 272 |
+
e,
|
| 273 |
+
)
|
| 274 |
+
tier["_compiled_regex"] = compiled
|
| 275 |
+
|
| 276 |
+
# Compile negation patterns
|
| 277 |
+
negation = triggers.get("negation_handling", {})
|
| 278 |
+
neg_patterns = negation.get("patterns", [])
|
| 279 |
+
negation["_compiled_patterns"] = [
|
| 280 |
+
p.lower() for p in neg_patterns
|
| 281 |
+
]
|
| 282 |
+
|
| 283 |
+
def load_additional_taxonomy(self, taxonomy_dir: str | Path) -> int:
|
| 284 |
+
"""
|
| 285 |
+
Load additional taxonomy domains from a secondary directory.
|
| 286 |
+
|
| 287 |
+
Only loads domains that are NOT already present in the primary taxonomy.
|
| 288 |
+
Returns the number of new domains added.
|
| 289 |
+
"""
|
| 290 |
+
taxonomy_dir = Path(taxonomy_dir)
|
| 291 |
+
if not taxonomy_dir.is_dir():
|
| 292 |
+
logger.warning("Additional taxonomy directory not found: %s", taxonomy_dir)
|
| 293 |
+
return 0
|
| 294 |
+
|
| 295 |
+
if self._taxonomy_triggers is None:
|
| 296 |
+
self._taxonomy_triggers = {}
|
| 297 |
+
if self._taxonomy_rules is None:
|
| 298 |
+
self._taxonomy_rules = {}
|
| 299 |
+
if self._taxonomy_flows is None:
|
| 300 |
+
self._taxonomy_flows = {}
|
| 301 |
+
|
| 302 |
+
added = 0
|
| 303 |
+
for domain_dir in sorted(taxonomy_dir.iterdir()):
|
| 304 |
+
if not domain_dir.is_dir():
|
| 305 |
+
continue
|
| 306 |
+
domain_name = domain_dir.name
|
| 307 |
+
|
| 308 |
+
# Skip domains already loaded from primary taxonomy
|
| 309 |
+
if domain_name in self._taxonomy_triggers:
|
| 310 |
+
continue
|
| 311 |
+
|
| 312 |
+
# Load triggers.yaml
|
| 313 |
+
triggers_path = domain_dir / "triggers.yaml"
|
| 314 |
+
if triggers_path.is_file():
|
| 315 |
+
triggers = self._safe_load_yaml(triggers_path)
|
| 316 |
+
if triggers:
|
| 317 |
+
self._taxonomy_triggers[domain_name] = triggers
|
| 318 |
+
self._compile_trigger_regex(domain_name, triggers)
|
| 319 |
+
|
| 320 |
+
# Load rules.yaml
|
| 321 |
+
rules_path = domain_dir / "rules.yaml"
|
| 322 |
+
if rules_path.is_file():
|
| 323 |
+
rules = self._safe_load_yaml(rules_path)
|
| 324 |
+
if rules:
|
| 325 |
+
self._taxonomy_rules[domain_name] = rules
|
| 326 |
+
|
| 327 |
+
# Load flows/*.yaml
|
| 328 |
+
flows_dir = domain_dir / "flows"
|
| 329 |
+
if flows_dir.is_dir():
|
| 330 |
+
domain_flows = {}
|
| 331 |
+
for flow_file in sorted(flows_dir.iterdir()):
|
| 332 |
+
if flow_file.suffix in (".yaml", ".yml"):
|
| 333 |
+
flow_data = self._safe_load_yaml(flow_file)
|
| 334 |
+
if flow_data:
|
| 335 |
+
flow_id = flow_data.get("flow_id", flow_file.stem)
|
| 336 |
+
domain_flows[flow_id] = flow_data
|
| 337 |
+
if domain_flows:
|
| 338 |
+
self._taxonomy_flows[domain_name] = domain_flows
|
| 339 |
+
|
| 340 |
+
added += 1
|
| 341 |
+
|
| 342 |
+
if added:
|
| 343 |
+
logger.info(
|
| 344 |
+
"Loaded %d additional taxonomy domains from %s (total: %d)",
|
| 345 |
+
added, taxonomy_dir, len(self._taxonomy_triggers),
|
| 346 |
+
)
|
| 347 |
+
return added
|
| 348 |
+
|
| 349 |
+
def _load_primitives(self) -> None:
|
| 350 |
+
self._primitives = {}
|
| 351 |
+
prim_dir = self.decision_dir / "primitives"
|
| 352 |
+
if not prim_dir.is_dir():
|
| 353 |
+
return
|
| 354 |
+
for f in sorted(prim_dir.iterdir()):
|
| 355 |
+
if f.suffix in (".yaml", ".yml"):
|
| 356 |
+
data = self._safe_load_yaml(f)
|
| 357 |
+
if data:
|
| 358 |
+
flow_id = data.get("flow_id", f.stem)
|
| 359 |
+
self._primitives[flow_id] = data
|
| 360 |
+
|
| 361 |
+
def _load_orchestrator(self) -> None:
|
| 362 |
+
self._orchestrator = {}
|
| 363 |
+
orch_dir = self.decision_dir / "orchestrator"
|
| 364 |
+
if not orch_dir.is_dir():
|
| 365 |
+
return
|
| 366 |
+
for f in sorted(orch_dir.iterdir()):
|
| 367 |
+
if f.suffix in (".yaml", ".yml"):
|
| 368 |
+
data = self._safe_load_yaml(f)
|
| 369 |
+
if data:
|
| 370 |
+
self._orchestrator[f.stem] = data
|
| 371 |
+
|
| 372 |
+
def _load_fhir_mappings(self) -> None:
|
| 373 |
+
path = self.decision_dir / "context" / "fhir_mappings.yaml"
|
| 374 |
+
self._fhir_mappings = self._safe_load_yaml(path) if path.is_file() else {}
|
| 375 |
+
|
| 376 |
+
def _load_tenants(self) -> None:
|
| 377 |
+
self._tenants = {}
|
| 378 |
+
tenants_dir = self.decision_dir / "tenants"
|
| 379 |
+
if not tenants_dir.is_dir():
|
| 380 |
+
return
|
| 381 |
+
for tenant_dir in sorted(tenants_dir.iterdir()):
|
| 382 |
+
if not tenant_dir.is_dir():
|
| 383 |
+
continue
|
| 384 |
+
tenant_path = tenant_dir / "tenant.yaml"
|
| 385 |
+
if tenant_path.is_file():
|
| 386 |
+
data = self._safe_load_yaml(tenant_path)
|
| 387 |
+
if data:
|
| 388 |
+
tid = data.get("tenant_id", tenant_dir.name)
|
| 389 |
+
self._tenants[tid] = data
|
| 390 |
+
|
| 391 |
+
def _load_journeys(self) -> None:
|
| 392 |
+
self._journeys = {}
|
| 393 |
+
journeys_dir = self.decision_dir / "journeys"
|
| 394 |
+
if not journeys_dir.is_dir():
|
| 395 |
+
return
|
| 396 |
+
for f in sorted(journeys_dir.iterdir()):
|
| 397 |
+
if f.suffix in (".yaml", ".yml"):
|
| 398 |
+
data = self._safe_load_yaml(f)
|
| 399 |
+
if data:
|
| 400 |
+
wt = data.get("wedge_type", f.stem)
|
| 401 |
+
self._journeys[wt] = data
|
| 402 |
+
|
| 403 |
+
# ------------------------------------------------------------------
|
| 404 |
+
# Validation
|
| 405 |
+
# ------------------------------------------------------------------
|
| 406 |
+
|
| 407 |
+
def _validate(self) -> None:
|
| 408 |
+
"""Run basic validation checks on loaded configuration."""
|
| 409 |
+
errors = []
|
| 410 |
+
|
| 411 |
+
# Check global rules has required keys
|
| 412 |
+
required_global_keys = [
|
| 413 |
+
"safety_precedence",
|
| 414 |
+
"risk_classes",
|
| 415 |
+
"risk_escalation_rules",
|
| 416 |
+
"hard_escalate_domains",
|
| 417 |
+
]
|
| 418 |
+
for key in required_global_keys:
|
| 419 |
+
if key not in self.global_rules:
|
| 420 |
+
errors.append(f"global_rules.yaml missing required key: {key}")
|
| 421 |
+
|
| 422 |
+
# Check every domain in safety_precedence has triggers
|
| 423 |
+
for domain in self.safety_precedence:
|
| 424 |
+
if domain not in self.taxonomy_triggers:
|
| 425 |
+
errors.append(
|
| 426 |
+
f"safety_precedence domain '{domain}' has no triggers.yaml"
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
# Check hard_escalate domains exist
|
| 430 |
+
for domain in self.hard_escalate_domains:
|
| 431 |
+
if domain not in self.taxonomy_triggers:
|
| 432 |
+
errors.append(
|
| 433 |
+
f"hard_escalate domain '{domain}' has no triggers.yaml"
|
| 434 |
+
)
|
| 435 |
+
|
| 436 |
+
# Check risk escalation rules reference valid domains
|
| 437 |
+
for rule in self.risk_escalation_rules:
|
| 438 |
+
rule_domain = rule.get("if", {}).get("domain")
|
| 439 |
+
if rule_domain and rule_domain not in self.taxonomy_triggers:
|
| 440 |
+
errors.append(
|
| 441 |
+
f"risk rule '{rule.get('id')}' references unknown domain "
|
| 442 |
+
f"'{rule_domain}'"
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
# Check tenant wedge references
|
| 446 |
+
for tid, tenant in self.tenants.items():
|
| 447 |
+
for wedge in tenant.get("enabled_wedges", []):
|
| 448 |
+
# Map short names to journey keys
|
| 449 |
+
if wedge not in self.journeys:
|
| 450 |
+
# Try mapping common abbreviations
|
| 451 |
+
mapping = {
|
| 452 |
+
"pde": "pde",
|
| 453 |
+
"pre_op": "pre_op",
|
| 454 |
+
"awv": "awv",
|
| 455 |
+
"gap_closure": "gap_closure",
|
| 456 |
+
"hra": "hra",
|
| 457 |
+
}
|
| 458 |
+
if wedge not in mapping:
|
| 459 |
+
logger.debug(
|
| 460 |
+
"Tenant '%s' references wedge '%s' not in journeys",
|
| 461 |
+
tid,
|
| 462 |
+
wedge,
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
if errors:
|
| 466 |
+
for err in errors:
|
| 467 |
+
logger.warning("Config validation: %s", err)
|
| 468 |
+
logger.warning(
|
| 469 |
+
"Decision config has %d validation warning(s)", len(errors)
|
| 470 |
+
)
|
| 471 |
+
else:
|
| 472 |
+
logger.info("Decision config validation passed")
|
decision/engine/domain_nomination.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Domain Nomination Layer.
|
| 3 |
+
|
| 4 |
+
Combines DriveHealthBERT ML output with taxonomy trigger matches to produce
|
| 5 |
+
a ranked list of clinical domain nominations.
|
| 6 |
+
|
| 7 |
+
The nomination algorithm:
|
| 8 |
+
1. Run taxonomy triggers against the text → domain match results
|
| 9 |
+
2. Receive DriveHealthBERT label + probabilities
|
| 10 |
+
3. Fuse signals: ML confidence reinforces or weakens trigger confidence
|
| 11 |
+
4. Apply hard-escalate rules (suicidal_ideation, homicidal_ideation)
|
| 12 |
+
5. Rank by: (a) effective_confidence tier, (b) domain priority, (c) ML prob
|
| 13 |
+
6. Select recommended_flow from domain rules based on confidence tier
|
| 14 |
+
7. Return sorted list of DomainNomination objects
|
| 15 |
+
|
| 16 |
+
Safety invariants:
|
| 17 |
+
- Hard-escalate domains ALWAYS nominate at HIGH confidence
|
| 18 |
+
- Negated domains are included but marked (caller decides)
|
| 19 |
+
- If ML says ESCALATION but no triggers match, still nominate with
|
| 20 |
+
a generic "unclassified_escalation" domain
|
| 21 |
+
- If triggers match a safety domain but ML says non-escalation,
|
| 22 |
+
the trigger OVERRIDES the ML (fail-open)
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import logging
|
| 28 |
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
| 29 |
+
|
| 30 |
+
from decision.engine.config_loader import DecisionConfigLoader
|
| 31 |
+
from decision.engine.models import (
|
| 32 |
+
ConfidenceTier,
|
| 33 |
+
DomainNomination,
|
| 34 |
+
NegationResult,
|
| 35 |
+
RiskClass,
|
| 36 |
+
TriggerMatch,
|
| 37 |
+
)
|
| 38 |
+
from decision.engine.trigger_engine import DomainMatchResult, TaxonomyTriggerEngine
|
| 39 |
+
|
| 40 |
+
logger = logging.getLogger("decision.domain_nomination")
|
| 41 |
+
|
| 42 |
+
# DriveHealthBERT label → taxonomy domain mapping hints
|
| 43 |
+
# Used when ML label alone doesn't resolve to a specific domain
|
| 44 |
+
_ML_LABEL_DOMAIN_HINTS: Dict[str, List[str]] = {
|
| 45 |
+
"SCHEDULING": ["scheduling", "scheduling_barrier", "missed_followup"],
|
| 46 |
+
"MEDICATION": ["medication", "medication_nonadherence"],
|
| 47 |
+
"SYMPTOM_CHECK": [], # Too broad — rely on triggers
|
| 48 |
+
"BILLING": [], # No taxonomy domain for billing yet
|
| 49 |
+
"GENERAL": [],
|
| 50 |
+
"ESCALATION": [], # Rely on triggers to determine which domain
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class DomainNominator:
|
| 55 |
+
"""
|
| 56 |
+
Produces ranked domain nominations from ML + lexical signals.
|
| 57 |
+
|
| 58 |
+
Usage:
|
| 59 |
+
nominator = DomainNominator(config, trigger_engine)
|
| 60 |
+
nominations = nominator.nominate(
|
| 61 |
+
text="my chest is killing me",
|
| 62 |
+
ml_label="ESCALATION",
|
| 63 |
+
ml_probabilities={"ESCALATION": 0.92, ...},
|
| 64 |
+
)
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
def __init__(
|
| 68 |
+
self,
|
| 69 |
+
config: DecisionConfigLoader,
|
| 70 |
+
trigger_engine: TaxonomyTriggerEngine,
|
| 71 |
+
):
|
| 72 |
+
self._config = config
|
| 73 |
+
self._trigger_engine = trigger_engine
|
| 74 |
+
self._hard_escalate: Set[str] = config.hard_escalate_domains
|
| 75 |
+
self._safety_precedence: List[str] = config.safety_precedence
|
| 76 |
+
|
| 77 |
+
def nominate(
|
| 78 |
+
self,
|
| 79 |
+
text: str,
|
| 80 |
+
ml_label: Optional[str] = None,
|
| 81 |
+
ml_probabilities: Optional[Dict[str, float]] = None,
|
| 82 |
+
) -> List[DomainNomination]:
|
| 83 |
+
"""
|
| 84 |
+
Produce ranked domain nominations for the given text.
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
text: Patient utterance (original, NOT context-prepended)
|
| 88 |
+
ml_label: DriveHealthBERT predicted label (e.g., "ESCALATION")
|
| 89 |
+
ml_probabilities: Full probability distribution from DriveHealthBERT
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
List of DomainNomination sorted by:
|
| 93 |
+
1. Effective confidence tier (HIGH > MEDIUM > LOW)
|
| 94 |
+
2. Domain priority (higher = more urgent)
|
| 95 |
+
3. ML probability (if available)
|
| 96 |
+
"""
|
| 97 |
+
ml_confidence = None
|
| 98 |
+
if ml_probabilities and ml_label:
|
| 99 |
+
ml_confidence = ml_probabilities.get(ml_label, 0.0)
|
| 100 |
+
|
| 101 |
+
# Step 1: Run taxonomy triggers
|
| 102 |
+
trigger_results = self._trigger_engine.match_all(text)
|
| 103 |
+
|
| 104 |
+
# Step 2: Build nominations from trigger results
|
| 105 |
+
nominations: List[DomainNomination] = []
|
| 106 |
+
|
| 107 |
+
for domain_name, match_result in trigger_results.items():
|
| 108 |
+
nomination = self._build_nomination(
|
| 109 |
+
domain_name=domain_name,
|
| 110 |
+
match_result=match_result,
|
| 111 |
+
ml_label=ml_label,
|
| 112 |
+
ml_confidence=ml_confidence,
|
| 113 |
+
)
|
| 114 |
+
nominations.append(nomination)
|
| 115 |
+
|
| 116 |
+
# Step 3: Handle hard-escalate domains
|
| 117 |
+
# If any hard-escalate domain matched (even at LOW), force HIGH
|
| 118 |
+
for nom in nominations:
|
| 119 |
+
if nom.domain in self._hard_escalate and not nom.is_negated:
|
| 120 |
+
nominations = [
|
| 121 |
+
self._force_high_confidence(n) if n.domain == nom.domain else n
|
| 122 |
+
for n in nominations
|
| 123 |
+
]
|
| 124 |
+
|
| 125 |
+
# Step 4: Handle ML-only signals (ML says ESCALATION but no triggers)
|
| 126 |
+
if ml_label == "ESCALATION" and ml_confidence and ml_confidence > 0.5:
|
| 127 |
+
has_safety_trigger = any(
|
| 128 |
+
not n.is_negated and n.confidence_tier >= ConfidenceTier.LOW
|
| 129 |
+
for n in nominations
|
| 130 |
+
if n.domain in set(self._safety_precedence)
|
| 131 |
+
)
|
| 132 |
+
# Check if the ML is confused by negated safety keywords:
|
| 133 |
+
# If safety domains DID match but were ALL negated, the ML is
|
| 134 |
+
# likely reacting to the keywords themselves (e.g., "I'm NOT
|
| 135 |
+
# suicidal" triggers ML ESCALATION because it sees "suicidal").
|
| 136 |
+
# In this case, trust the explicit negation over the ML signal.
|
| 137 |
+
has_negated_safety = any(
|
| 138 |
+
n.is_negated
|
| 139 |
+
for n in nominations
|
| 140 |
+
if n.domain in set(self._safety_precedence)
|
| 141 |
+
)
|
| 142 |
+
if not has_safety_trigger and not has_negated_safety:
|
| 143 |
+
# ML detected escalation but no specific domain matched
|
| 144 |
+
# (and no negated safety domains either)
|
| 145 |
+
# Create a generic nomination so it's not silently dropped
|
| 146 |
+
nominations.append(
|
| 147 |
+
DomainNomination(
|
| 148 |
+
domain="unclassified_escalation",
|
| 149 |
+
display_name="Unclassified Escalation",
|
| 150 |
+
confidence_tier=self._ml_prob_to_tier(ml_confidence),
|
| 151 |
+
priority=50, # Middle priority
|
| 152 |
+
target_risk_class=RiskClass.R2,
|
| 153 |
+
trigger_matches=(),
|
| 154 |
+
negation_result=None,
|
| 155 |
+
ml_label=ml_label,
|
| 156 |
+
ml_confidence=ml_confidence,
|
| 157 |
+
recommended_flow="handoff",
|
| 158 |
+
)
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# Step 5: Handle ML reinforcement for non-escalation labels
|
| 162 |
+
if ml_label and ml_label != "ESCALATION":
|
| 163 |
+
hint_domains = _ML_LABEL_DOMAIN_HINTS.get(ml_label, [])
|
| 164 |
+
for nom in nominations:
|
| 165 |
+
if nom.domain in hint_domains and ml_confidence:
|
| 166 |
+
# ML confirms trigger match — boost confidence if ML is strong
|
| 167 |
+
if ml_confidence > 0.8 and nom.confidence_tier < ConfidenceTier.HIGH:
|
| 168 |
+
nominations = [
|
| 169 |
+
self._boost_confidence(n) if n.domain == nom.domain else n
|
| 170 |
+
for n in nominations
|
| 171 |
+
]
|
| 172 |
+
|
| 173 |
+
# Step 6: Resolve recommended_flow for each nomination
|
| 174 |
+
nominations = [self._resolve_flow(n) for n in nominations]
|
| 175 |
+
|
| 176 |
+
# Step 7: Sort
|
| 177 |
+
nominations.sort(key=self._nomination_sort_key, reverse=True)
|
| 178 |
+
|
| 179 |
+
if nominations:
|
| 180 |
+
logger.info(
|
| 181 |
+
"Domain nominations for text (len=%d): %s",
|
| 182 |
+
len(text),
|
| 183 |
+
[(n.domain, n.confidence_tier.value, n.priority) for n in nominations[:5]],
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
return nominations
|
| 187 |
+
|
| 188 |
+
# ------------------------------------------------------------------
|
| 189 |
+
# Internal helpers
|
| 190 |
+
# ------------------------------------------------------------------
|
| 191 |
+
|
| 192 |
+
def _build_nomination(
|
| 193 |
+
self,
|
| 194 |
+
domain_name: str,
|
| 195 |
+
match_result: DomainMatchResult,
|
| 196 |
+
ml_label: Optional[str],
|
| 197 |
+
ml_confidence: Optional[float],
|
| 198 |
+
) -> DomainNomination:
|
| 199 |
+
"""Build a DomainNomination from a DomainMatchResult."""
|
| 200 |
+
# Get target risk class from rules.yaml
|
| 201 |
+
target_risk_str = self._config.get_domain_target_risk(domain_name)
|
| 202 |
+
target_risk = None
|
| 203 |
+
if target_risk_str:
|
| 204 |
+
try:
|
| 205 |
+
target_risk = RiskClass(target_risk_str)
|
| 206 |
+
except ValueError:
|
| 207 |
+
pass
|
| 208 |
+
|
| 209 |
+
return DomainNomination(
|
| 210 |
+
domain=domain_name,
|
| 211 |
+
display_name=domain_name.replace("_", " ").title(),
|
| 212 |
+
confidence_tier=match_result.effective_confidence,
|
| 213 |
+
priority=match_result.priority,
|
| 214 |
+
target_risk_class=target_risk,
|
| 215 |
+
trigger_matches=match_result.matches,
|
| 216 |
+
negation_result=match_result.negation_result,
|
| 217 |
+
ml_label=ml_label,
|
| 218 |
+
ml_confidence=ml_confidence,
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
def _resolve_flow(self, nomination: DomainNomination) -> DomainNomination:
|
| 222 |
+
"""Determine recommended_flow from domain decision rules."""
|
| 223 |
+
if nomination.recommended_flow:
|
| 224 |
+
return nomination
|
| 225 |
+
|
| 226 |
+
rules = self._config.get_domain_decision_rules(nomination.domain)
|
| 227 |
+
if not rules:
|
| 228 |
+
return nomination
|
| 229 |
+
|
| 230 |
+
# Find first matching rule based on confidence tier
|
| 231 |
+
for rule in rules:
|
| 232 |
+
conditions = rule.get("if", {})
|
| 233 |
+
conf_required = conditions.get("confidence")
|
| 234 |
+
if conf_required and conf_required == nomination.confidence_tier.value:
|
| 235 |
+
flow = rule.get("then", {}).get("flow")
|
| 236 |
+
if flow:
|
| 237 |
+
return DomainNomination(
|
| 238 |
+
domain=nomination.domain,
|
| 239 |
+
display_name=nomination.display_name,
|
| 240 |
+
confidence_tier=nomination.confidence_tier,
|
| 241 |
+
priority=nomination.priority,
|
| 242 |
+
target_risk_class=nomination.target_risk_class,
|
| 243 |
+
trigger_matches=nomination.trigger_matches,
|
| 244 |
+
negation_result=nomination.negation_result,
|
| 245 |
+
ml_label=nomination.ml_label,
|
| 246 |
+
ml_confidence=nomination.ml_confidence,
|
| 247 |
+
recommended_flow=flow,
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
return nomination
|
| 251 |
+
|
| 252 |
+
def _force_high_confidence(
|
| 253 |
+
self, nomination: DomainNomination
|
| 254 |
+
) -> DomainNomination:
|
| 255 |
+
"""Force a nomination to HIGH confidence (for hard-escalate domains)."""
|
| 256 |
+
if nomination.confidence_tier == ConfidenceTier.HIGH:
|
| 257 |
+
return nomination
|
| 258 |
+
logger.warning(
|
| 259 |
+
"HARD ESCALATE: Forcing %s to HIGH confidence (was %s)",
|
| 260 |
+
nomination.domain,
|
| 261 |
+
nomination.confidence_tier.value,
|
| 262 |
+
)
|
| 263 |
+
return DomainNomination(
|
| 264 |
+
domain=nomination.domain,
|
| 265 |
+
display_name=nomination.display_name,
|
| 266 |
+
confidence_tier=ConfidenceTier.HIGH,
|
| 267 |
+
priority=nomination.priority,
|
| 268 |
+
target_risk_class=nomination.target_risk_class,
|
| 269 |
+
trigger_matches=nomination.trigger_matches,
|
| 270 |
+
negation_result=nomination.negation_result,
|
| 271 |
+
ml_label=nomination.ml_label,
|
| 272 |
+
ml_confidence=nomination.ml_confidence,
|
| 273 |
+
recommended_flow=nomination.recommended_flow,
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
def _boost_confidence(
|
| 277 |
+
self, nomination: DomainNomination
|
| 278 |
+
) -> DomainNomination:
|
| 279 |
+
"""Boost confidence by one tier when ML reinforces trigger."""
|
| 280 |
+
new_tier = nomination.confidence_tier
|
| 281 |
+
if new_tier == ConfidenceTier.LOW:
|
| 282 |
+
new_tier = ConfidenceTier.MEDIUM
|
| 283 |
+
elif new_tier == ConfidenceTier.MEDIUM:
|
| 284 |
+
new_tier = ConfidenceTier.HIGH
|
| 285 |
+
if new_tier == nomination.confidence_tier:
|
| 286 |
+
return nomination
|
| 287 |
+
return DomainNomination(
|
| 288 |
+
domain=nomination.domain,
|
| 289 |
+
display_name=nomination.display_name,
|
| 290 |
+
confidence_tier=new_tier,
|
| 291 |
+
priority=nomination.priority,
|
| 292 |
+
target_risk_class=nomination.target_risk_class,
|
| 293 |
+
trigger_matches=nomination.trigger_matches,
|
| 294 |
+
negation_result=nomination.negation_result,
|
| 295 |
+
ml_label=nomination.ml_label,
|
| 296 |
+
ml_confidence=nomination.ml_confidence,
|
| 297 |
+
recommended_flow=nomination.recommended_flow,
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
@staticmethod
|
| 301 |
+
def _ml_prob_to_tier(prob: float) -> ConfidenceTier:
|
| 302 |
+
"""Map ML probability to a confidence tier."""
|
| 303 |
+
if prob >= 0.8:
|
| 304 |
+
return ConfidenceTier.HIGH
|
| 305 |
+
elif prob >= 0.5:
|
| 306 |
+
return ConfidenceTier.MEDIUM
|
| 307 |
+
elif prob >= 0.3:
|
| 308 |
+
return ConfidenceTier.LOW
|
| 309 |
+
return ConfidenceTier.NONE
|
| 310 |
+
|
| 311 |
+
@staticmethod
|
| 312 |
+
def _nomination_sort_key(
|
| 313 |
+
nom: DomainNomination,
|
| 314 |
+
) -> Tuple[int, int, float]:
|
| 315 |
+
"""Sort key: (confidence_rank, priority, ml_confidence)."""
|
| 316 |
+
conf_rank = {
|
| 317 |
+
ConfidenceTier.HIGH: 3,
|
| 318 |
+
ConfidenceTier.MEDIUM: 2,
|
| 319 |
+
ConfidenceTier.LOW: 1,
|
| 320 |
+
ConfidenceTier.NONE: 0,
|
| 321 |
+
}
|
| 322 |
+
return (
|
| 323 |
+
conf_rank.get(nom.confidence_tier, 0),
|
| 324 |
+
nom.priority,
|
| 325 |
+
nom.ml_confidence or 0.0,
|
| 326 |
+
)
|
decision/engine/fhir_context.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FHIR Context Loader (Phase 3A).
|
| 3 |
+
|
| 4 |
+
Builds a PatientContext from FHIR R4 resources for context-aware risk rules.
|
| 5 |
+
|
| 6 |
+
Key features:
|
| 7 |
+
- Minimum necessary principle: only fetch resources needed for the active wedge
|
| 8 |
+
- Lookback periods: filter by date relevance
|
| 9 |
+
- Freshness detection: warn when clinical data is stale
|
| 10 |
+
- ICD-10 condition matching for risk rule evaluation
|
| 11 |
+
- Medication count for polypharmacy detection
|
| 12 |
+
- Pre-populated slot extraction for journey agendas
|
| 13 |
+
|
| 14 |
+
This module provides:
|
| 15 |
+
1. PatientContext dataclass — normalized patient data for risk evaluation
|
| 16 |
+
2. PatientContextBuilder — builds context from raw FHIR bundles
|
| 17 |
+
3. FHIRClient (abstract) — interface for FHIR data fetching (Phase 3 full impl)
|
| 18 |
+
|
| 19 |
+
Safety invariants:
|
| 20 |
+
- Missing context = fail closed (context-dependent rules don't fire)
|
| 21 |
+
- Stale data is flagged but still used (better than no data)
|
| 22 |
+
- PHI is never logged — only ICD codes and counts
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import logging
|
| 28 |
+
from dataclasses import dataclass, field
|
| 29 |
+
from datetime import datetime, timedelta, timezone
|
| 30 |
+
from typing import Any, Dict, List, Optional, Set
|
| 31 |
+
|
| 32 |
+
from decision.engine.config_loader import DecisionConfigLoader
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger("decision.fhir_context")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
# Patient Context
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
|
| 41 |
+
@dataclass
|
| 42 |
+
class PatientContext:
|
| 43 |
+
"""
|
| 44 |
+
Normalized patient context for clinical decision-making.
|
| 45 |
+
|
| 46 |
+
Built from FHIR resources. Used by RiskClassifier for context-aware rules.
|
| 47 |
+
"""
|
| 48 |
+
patient_id: str = ""
|
| 49 |
+
|
| 50 |
+
# Conditions (ICD-10 codes of active problems)
|
| 51 |
+
active_conditions: List[str] = field(default_factory=list)
|
| 52 |
+
# Convenience sets for common condition groups
|
| 53 |
+
has_chf: bool = False # I50.*
|
| 54 |
+
has_copd: bool = False # J44.*
|
| 55 |
+
has_diabetes: bool = False # E10.*, E11.*
|
| 56 |
+
has_ckd: bool = False # N18.*
|
| 57 |
+
has_cancer: bool = False # C00-C97
|
| 58 |
+
has_afib: bool = False # I48.*
|
| 59 |
+
has_depression: bool = False # F32.*, F33.*
|
| 60 |
+
has_anxiety: bool = False # F41.*
|
| 61 |
+
|
| 62 |
+
# Medications
|
| 63 |
+
active_medication_count: int = 0
|
| 64 |
+
active_medication_names: List[str] = field(default_factory=list)
|
| 65 |
+
is_on_anticoagulant: bool = False
|
| 66 |
+
is_on_insulin: bool = False
|
| 67 |
+
is_on_opioid: bool = False
|
| 68 |
+
|
| 69 |
+
# Demographics
|
| 70 |
+
age: Optional[int] = None
|
| 71 |
+
is_elderly: bool = False # 65+
|
| 72 |
+
is_very_elderly: bool = False # 75+
|
| 73 |
+
|
| 74 |
+
# Recent vitals
|
| 75 |
+
last_bp_systolic: Optional[float] = None
|
| 76 |
+
last_bp_diastolic: Optional[float] = None
|
| 77 |
+
last_heart_rate: Optional[float] = None
|
| 78 |
+
last_weight_kg: Optional[float] = None
|
| 79 |
+
last_glucose: Optional[float] = None
|
| 80 |
+
last_temperature: Optional[float] = None
|
| 81 |
+
|
| 82 |
+
# Recent encounter
|
| 83 |
+
days_since_discharge: Optional[int] = None
|
| 84 |
+
discharge_diagnosis_codes: List[str] = field(default_factory=list)
|
| 85 |
+
|
| 86 |
+
# Allergies
|
| 87 |
+
allergy_count: int = 0
|
| 88 |
+
drug_allergies: List[str] = field(default_factory=list)
|
| 89 |
+
|
| 90 |
+
# Appointments
|
| 91 |
+
has_upcoming_appointment: bool = False
|
| 92 |
+
next_appointment_days: Optional[int] = None
|
| 93 |
+
|
| 94 |
+
# Freshness / staleness warnings
|
| 95 |
+
stale_resources: List[str] = field(default_factory=list)
|
| 96 |
+
context_timestamp: Optional[datetime] = None
|
| 97 |
+
|
| 98 |
+
def to_risk_context(self) -> Dict[str, Any]:
|
| 99 |
+
"""
|
| 100 |
+
Convert to the dict format expected by RiskClassifier.assess(patient_context=...).
|
| 101 |
+
"""
|
| 102 |
+
return {
|
| 103 |
+
"patient_id": self.patient_id,
|
| 104 |
+
"active_conditions": self.active_conditions,
|
| 105 |
+
"active_medication_count": self.active_medication_count,
|
| 106 |
+
"has_chf": self.has_chf,
|
| 107 |
+
"has_copd": self.has_copd,
|
| 108 |
+
"has_diabetes": self.has_diabetes,
|
| 109 |
+
"has_ckd": self.has_ckd,
|
| 110 |
+
"has_cancer": self.has_cancer,
|
| 111 |
+
"has_afib": self.has_afib,
|
| 112 |
+
"has_depression": self.has_depression,
|
| 113 |
+
"has_anxiety": self.has_anxiety,
|
| 114 |
+
"is_on_anticoagulant": self.is_on_anticoagulant,
|
| 115 |
+
"is_on_insulin": self.is_on_insulin,
|
| 116 |
+
"is_on_opioid": self.is_on_opioid,
|
| 117 |
+
"age": self.age,
|
| 118 |
+
"is_elderly": self.is_elderly,
|
| 119 |
+
"is_very_elderly": self.is_very_elderly,
|
| 120 |
+
"days_since_discharge": self.days_since_discharge,
|
| 121 |
+
"stale_resources": self.stale_resources,
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ---------------------------------------------------------------------------
|
| 126 |
+
# Patient Context Builder
|
| 127 |
+
# ---------------------------------------------------------------------------
|
| 128 |
+
|
| 129 |
+
# ICD-10 pattern groups for condition detection
|
| 130 |
+
_CONDITION_GROUPS = {
|
| 131 |
+
"has_chf": ["I50"],
|
| 132 |
+
"has_copd": ["J44"],
|
| 133 |
+
"has_diabetes": ["E10", "E11"],
|
| 134 |
+
"has_ckd": ["N18"],
|
| 135 |
+
"has_cancer": [f"C{i:02d}" for i in range(98)], # C00-C97
|
| 136 |
+
"has_afib": ["I48"],
|
| 137 |
+
"has_depression": ["F32", "F33"],
|
| 138 |
+
"has_anxiety": ["F41"],
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
# Medication class detection (by name substring)
|
| 142 |
+
_ANTICOAGULANTS = {"warfarin", "coumadin", "heparin", "enoxaparin", "lovenox",
|
| 143 |
+
"apixaban", "eliquis", "rivaroxaban", "xarelto", "dabigatran", "pradaxa"}
|
| 144 |
+
_INSULINS = {"insulin", "humalog", "novolog", "lantus", "levemir", "tresiba",
|
| 145 |
+
"basaglar", "admelog", "fiasp", "toujeo"}
|
| 146 |
+
_OPIOIDS = {"oxycodone", "hydrocodone", "morphine", "fentanyl", "codeine",
|
| 147 |
+
"tramadol", "methadone", "hydromorphone", "oxymorphone", "dilaudid",
|
| 148 |
+
"percocet", "vicodin", "norco"}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class PatientContextBuilder:
|
| 152 |
+
"""
|
| 153 |
+
Builds a PatientContext from FHIR resource bundles.
|
| 154 |
+
|
| 155 |
+
Usage:
|
| 156 |
+
builder = PatientContextBuilder(config)
|
| 157 |
+
context = builder.build_from_fhir(
|
| 158 |
+
patient_id="P-123",
|
| 159 |
+
fhir_bundle=bundle_dict,
|
| 160 |
+
wedge_type="pde",
|
| 161 |
+
)
|
| 162 |
+
"""
|
| 163 |
+
|
| 164 |
+
def __init__(self, config: DecisionConfigLoader):
|
| 165 |
+
self._config = config
|
| 166 |
+
self._fhir_mappings = config.fhir_mappings
|
| 167 |
+
self._freshness_thresholds = self._fhir_mappings.get("freshness_thresholds", {})
|
| 168 |
+
|
| 169 |
+
def build_from_fhir(
|
| 170 |
+
self,
|
| 171 |
+
patient_id: str,
|
| 172 |
+
fhir_bundle: Dict[str, Any],
|
| 173 |
+
wedge_type: Optional[str] = None,
|
| 174 |
+
) -> PatientContext:
|
| 175 |
+
"""
|
| 176 |
+
Build PatientContext from a FHIR bundle response.
|
| 177 |
+
|
| 178 |
+
Args:
|
| 179 |
+
patient_id: Patient identifier
|
| 180 |
+
fhir_bundle: FHIR Bundle resource (or dict of resource lists)
|
| 181 |
+
wedge_type: Active wedge for minimum necessary filtering
|
| 182 |
+
|
| 183 |
+
Returns:
|
| 184 |
+
PatientContext ready for risk rule evaluation
|
| 185 |
+
"""
|
| 186 |
+
ctx = PatientContext(
|
| 187 |
+
patient_id=patient_id,
|
| 188 |
+
context_timestamp=datetime.now(timezone.utc),
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
# Extract resources from bundle
|
| 192 |
+
resources = self._extract_resources(fhir_bundle)
|
| 193 |
+
|
| 194 |
+
# Build context from each resource type
|
| 195 |
+
self._process_patient(ctx, resources.get("Patient", []))
|
| 196 |
+
self._process_conditions(ctx, resources.get("Condition", []))
|
| 197 |
+
self._process_medications(ctx, resources.get("MedicationRequest", []))
|
| 198 |
+
self._process_observations(ctx, resources.get("Observation", []))
|
| 199 |
+
self._process_allergies(ctx, resources.get("AllergyIntolerance", []))
|
| 200 |
+
self._process_encounters(ctx, resources.get("Encounter", []))
|
| 201 |
+
self._process_appointments(ctx, resources.get("Appointment", []))
|
| 202 |
+
|
| 203 |
+
# Check freshness
|
| 204 |
+
self._check_freshness(ctx, resources)
|
| 205 |
+
|
| 206 |
+
logger.info(
|
| 207 |
+
"PatientContext built: patient=%s conditions=%d meds=%d age=%s chf=%s copd=%s dm=%s",
|
| 208 |
+
patient_id,
|
| 209 |
+
len(ctx.active_conditions),
|
| 210 |
+
ctx.active_medication_count,
|
| 211 |
+
ctx.age,
|
| 212 |
+
ctx.has_chf,
|
| 213 |
+
ctx.has_copd,
|
| 214 |
+
ctx.has_diabetes,
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
return ctx
|
| 218 |
+
|
| 219 |
+
def build_from_dict(
|
| 220 |
+
self,
|
| 221 |
+
patient_id: str,
|
| 222 |
+
data: Dict[str, Any],
|
| 223 |
+
) -> PatientContext:
|
| 224 |
+
"""
|
| 225 |
+
Build PatientContext from a pre-processed dict (e.g., from API request).
|
| 226 |
+
|
| 227 |
+
This allows callers to pass patient context directly without FHIR.
|
| 228 |
+
"""
|
| 229 |
+
ctx = PatientContext(
|
| 230 |
+
patient_id=patient_id,
|
| 231 |
+
context_timestamp=datetime.now(timezone.utc),
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
ctx.active_conditions = data.get("active_conditions", [])
|
| 235 |
+
ctx.active_medication_count = data.get("active_medication_count", 0)
|
| 236 |
+
ctx.active_medication_names = data.get("active_medication_names", [])
|
| 237 |
+
ctx.age = data.get("age")
|
| 238 |
+
ctx.days_since_discharge = data.get("days_since_discharge")
|
| 239 |
+
|
| 240 |
+
# Derive flags from conditions
|
| 241 |
+
self._set_condition_flags(ctx)
|
| 242 |
+
|
| 243 |
+
# Derive flags from medications
|
| 244 |
+
self._set_medication_flags(ctx)
|
| 245 |
+
|
| 246 |
+
# Age flags
|
| 247 |
+
if ctx.age:
|
| 248 |
+
ctx.is_elderly = ctx.age >= 65
|
| 249 |
+
ctx.is_very_elderly = ctx.age >= 75
|
| 250 |
+
|
| 251 |
+
return ctx
|
| 252 |
+
|
| 253 |
+
# ------------------------------------------------------------------
|
| 254 |
+
# Resource processing
|
| 255 |
+
# ------------------------------------------------------------------
|
| 256 |
+
|
| 257 |
+
def _extract_resources(
|
| 258 |
+
self, bundle: Dict[str, Any]
|
| 259 |
+
) -> Dict[str, List[Dict[str, Any]]]:
|
| 260 |
+
"""Extract resources from a FHIR Bundle, grouped by resourceType."""
|
| 261 |
+
resources: Dict[str, List[Dict[str, Any]]] = {}
|
| 262 |
+
|
| 263 |
+
# Handle standard FHIR Bundle format
|
| 264 |
+
entries = bundle.get("entry", [])
|
| 265 |
+
for entry in entries:
|
| 266 |
+
resource = entry.get("resource", {})
|
| 267 |
+
rtype = resource.get("resourceType", "Unknown")
|
| 268 |
+
resources.setdefault(rtype, []).append(resource)
|
| 269 |
+
|
| 270 |
+
# Also handle flat dict format {resourceType: [resources]}
|
| 271 |
+
for key, value in bundle.items():
|
| 272 |
+
if key != "entry" and isinstance(value, list):
|
| 273 |
+
resources.setdefault(key, []).extend(value)
|
| 274 |
+
|
| 275 |
+
return resources
|
| 276 |
+
|
| 277 |
+
def _process_patient(
|
| 278 |
+
self, ctx: PatientContext, patients: List[Dict[str, Any]]
|
| 279 |
+
) -> None:
|
| 280 |
+
if not patients:
|
| 281 |
+
return
|
| 282 |
+
patient = patients[0]
|
| 283 |
+
birth_date = patient.get("birthDate")
|
| 284 |
+
if birth_date:
|
| 285 |
+
try:
|
| 286 |
+
dob = datetime.strptime(birth_date, "%Y-%m-%d")
|
| 287 |
+
today = datetime.now()
|
| 288 |
+
ctx.age = today.year - dob.year - ((today.month, today.day) < (dob.month, dob.day))
|
| 289 |
+
ctx.is_elderly = ctx.age >= 65
|
| 290 |
+
ctx.is_very_elderly = ctx.age >= 75
|
| 291 |
+
except (ValueError, TypeError):
|
| 292 |
+
pass
|
| 293 |
+
|
| 294 |
+
def _process_conditions(
|
| 295 |
+
self, ctx: PatientContext, conditions: List[Dict[str, Any]]
|
| 296 |
+
) -> None:
|
| 297 |
+
for condition in conditions:
|
| 298 |
+
# Only active conditions
|
| 299 |
+
clinical_status = condition.get("clinicalStatus", {})
|
| 300 |
+
if isinstance(clinical_status, dict):
|
| 301 |
+
codings = clinical_status.get("coding", [])
|
| 302 |
+
status_code = codings[0].get("code", "") if codings else ""
|
| 303 |
+
else:
|
| 304 |
+
status_code = str(clinical_status)
|
| 305 |
+
|
| 306 |
+
if status_code and status_code not in ("active", "recurrence", "relapse"):
|
| 307 |
+
continue
|
| 308 |
+
|
| 309 |
+
# Extract ICD-10 codes
|
| 310 |
+
code_concept = condition.get("code", {})
|
| 311 |
+
for coding in code_concept.get("coding", []):
|
| 312 |
+
system = coding.get("system", "")
|
| 313 |
+
code = coding.get("code", "")
|
| 314 |
+
if "icd" in system.lower() or code:
|
| 315 |
+
ctx.active_conditions.append(code)
|
| 316 |
+
|
| 317 |
+
self._set_condition_flags(ctx)
|
| 318 |
+
|
| 319 |
+
def _set_condition_flags(self, ctx: PatientContext) -> None:
|
| 320 |
+
"""Set boolean condition flags from ICD-10 codes."""
|
| 321 |
+
for flag_name, prefixes in _CONDITION_GROUPS.items():
|
| 322 |
+
has_condition = any(
|
| 323 |
+
any(code.startswith(prefix) for prefix in prefixes)
|
| 324 |
+
for code in ctx.active_conditions
|
| 325 |
+
)
|
| 326 |
+
setattr(ctx, flag_name, has_condition)
|
| 327 |
+
|
| 328 |
+
def _process_medications(
|
| 329 |
+
self, ctx: PatientContext, medications: List[Dict[str, Any]]
|
| 330 |
+
) -> None:
|
| 331 |
+
active_meds = []
|
| 332 |
+
for med in medications:
|
| 333 |
+
status = med.get("status", "")
|
| 334 |
+
if status not in ("active", "completed"):
|
| 335 |
+
continue
|
| 336 |
+
med_name = ""
|
| 337 |
+
med_concept = med.get("medicationCodeableConcept", {})
|
| 338 |
+
if med_concept:
|
| 339 |
+
med_name = med_concept.get("text", "")
|
| 340 |
+
if not med_name:
|
| 341 |
+
codings = med_concept.get("coding", [])
|
| 342 |
+
if codings:
|
| 343 |
+
med_name = codings[0].get("display", "")
|
| 344 |
+
if med_name:
|
| 345 |
+
active_meds.append(med_name)
|
| 346 |
+
|
| 347 |
+
ctx.active_medication_names = active_meds
|
| 348 |
+
ctx.active_medication_count = len(active_meds)
|
| 349 |
+
self._set_medication_flags(ctx)
|
| 350 |
+
|
| 351 |
+
def _set_medication_flags(self, ctx: PatientContext) -> None:
|
| 352 |
+
"""Set medication class flags from med names."""
|
| 353 |
+
names_lower = {n.lower() for n in ctx.active_medication_names}
|
| 354 |
+
ctx.is_on_anticoagulant = bool(names_lower & _ANTICOAGULANTS)
|
| 355 |
+
ctx.is_on_insulin = bool(names_lower & _INSULINS)
|
| 356 |
+
ctx.is_on_opioid = bool(names_lower & _OPIOIDS)
|
| 357 |
+
|
| 358 |
+
def _process_observations(
|
| 359 |
+
self, ctx: PatientContext, observations: List[Dict[str, Any]]
|
| 360 |
+
) -> None:
|
| 361 |
+
# Sort by date descending to get most recent first
|
| 362 |
+
observations.sort(
|
| 363 |
+
key=lambda o: o.get("effectiveDateTime", ""),
|
| 364 |
+
reverse=True,
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
+
for obs in observations:
|
| 368 |
+
code_concept = obs.get("code", {})
|
| 369 |
+
codings = code_concept.get("coding", [])
|
| 370 |
+
loinc_code = ""
|
| 371 |
+
for coding in codings:
|
| 372 |
+
if "loinc" in coding.get("system", "").lower():
|
| 373 |
+
loinc_code = coding.get("code", "")
|
| 374 |
+
break
|
| 375 |
+
|
| 376 |
+
value = obs.get("valueQuantity", {}).get("value")
|
| 377 |
+
if value is None:
|
| 378 |
+
continue
|
| 379 |
+
|
| 380 |
+
# Map LOINC codes to context fields (most recent only)
|
| 381 |
+
if loinc_code == "8480-6" and ctx.last_bp_systolic is None: # Systolic BP
|
| 382 |
+
ctx.last_bp_systolic = float(value)
|
| 383 |
+
elif loinc_code == "8462-4" and ctx.last_bp_diastolic is None: # Diastolic BP
|
| 384 |
+
ctx.last_bp_diastolic = float(value)
|
| 385 |
+
elif loinc_code == "8867-4" and ctx.last_heart_rate is None: # Heart rate
|
| 386 |
+
ctx.last_heart_rate = float(value)
|
| 387 |
+
elif loinc_code == "29463-7" and ctx.last_weight_kg is None: # Weight
|
| 388 |
+
ctx.last_weight_kg = float(value)
|
| 389 |
+
elif loinc_code in ("2339-0", "2345-7") and ctx.last_glucose is None: # Glucose
|
| 390 |
+
ctx.last_glucose = float(value)
|
| 391 |
+
elif loinc_code == "8310-5" and ctx.last_temperature is None: # Temperature
|
| 392 |
+
ctx.last_temperature = float(value)
|
| 393 |
+
|
| 394 |
+
def _process_allergies(
|
| 395 |
+
self, ctx: PatientContext, allergies: List[Dict[str, Any]]
|
| 396 |
+
) -> None:
|
| 397 |
+
active_allergies = [
|
| 398 |
+
a for a in allergies
|
| 399 |
+
if a.get("clinicalStatus", {}).get("coding", [{}])[0].get("code") == "active"
|
| 400 |
+
or not a.get("clinicalStatus")
|
| 401 |
+
]
|
| 402 |
+
ctx.allergy_count = len(active_allergies)
|
| 403 |
+
|
| 404 |
+
for allergy in active_allergies:
|
| 405 |
+
category = allergy.get("category", [])
|
| 406 |
+
if "medication" in category:
|
| 407 |
+
substance = allergy.get("code", {}).get("text", "")
|
| 408 |
+
if substance:
|
| 409 |
+
ctx.drug_allergies.append(substance)
|
| 410 |
+
|
| 411 |
+
def _process_encounters(
|
| 412 |
+
self, ctx: PatientContext, encounters: List[Dict[str, Any]]
|
| 413 |
+
) -> None:
|
| 414 |
+
# Find most recent discharge
|
| 415 |
+
encounters.sort(
|
| 416 |
+
key=lambda e: e.get("period", {}).get("end", ""),
|
| 417 |
+
reverse=True,
|
| 418 |
+
)
|
| 419 |
+
|
| 420 |
+
for enc in encounters:
|
| 421 |
+
period = enc.get("period", {})
|
| 422 |
+
end_date = period.get("end")
|
| 423 |
+
if not end_date:
|
| 424 |
+
continue
|
| 425 |
+
try:
|
| 426 |
+
discharge_dt = datetime.fromisoformat(end_date.replace("Z", "+00:00"))
|
| 427 |
+
now = datetime.now(timezone.utc)
|
| 428 |
+
delta = now - discharge_dt
|
| 429 |
+
ctx.days_since_discharge = delta.days
|
| 430 |
+
|
| 431 |
+
# Extract discharge diagnosis codes
|
| 432 |
+
diagnoses = enc.get("diagnosis", [])
|
| 433 |
+
for diag in diagnoses:
|
| 434 |
+
code = diag.get("condition", {}).get("reference", "")
|
| 435 |
+
if code:
|
| 436 |
+
ctx.discharge_diagnosis_codes.append(code)
|
| 437 |
+
break # Most recent only
|
| 438 |
+
except (ValueError, TypeError):
|
| 439 |
+
continue
|
| 440 |
+
|
| 441 |
+
def _process_appointments(
|
| 442 |
+
self, ctx: PatientContext, appointments: List[Dict[str, Any]]
|
| 443 |
+
) -> None:
|
| 444 |
+
now = datetime.now(timezone.utc)
|
| 445 |
+
for appt in appointments:
|
| 446 |
+
status = appt.get("status", "")
|
| 447 |
+
if status in ("cancelled", "noshow", "entered-in-error"):
|
| 448 |
+
continue
|
| 449 |
+
start = appt.get("start")
|
| 450 |
+
if not start:
|
| 451 |
+
continue
|
| 452 |
+
try:
|
| 453 |
+
appt_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
|
| 454 |
+
if appt_dt > now:
|
| 455 |
+
ctx.has_upcoming_appointment = True
|
| 456 |
+
delta = appt_dt - now
|
| 457 |
+
if ctx.next_appointment_days is None or delta.days < ctx.next_appointment_days:
|
| 458 |
+
ctx.next_appointment_days = delta.days
|
| 459 |
+
except (ValueError, TypeError):
|
| 460 |
+
continue
|
| 461 |
+
|
| 462 |
+
def _check_freshness(
|
| 463 |
+
self, ctx: PatientContext, resources: Dict[str, List[Dict[str, Any]]]
|
| 464 |
+
) -> None:
|
| 465 |
+
"""Check resource freshness against configured thresholds."""
|
| 466 |
+
now = datetime.now(timezone.utc)
|
| 467 |
+
|
| 468 |
+
for resource_type, threshold_hours in self._freshness_thresholds.items():
|
| 469 |
+
if threshold_hours is None:
|
| 470 |
+
continue
|
| 471 |
+
|
| 472 |
+
# Map compound types (e.g., "Observation_laboratory") to base type
|
| 473 |
+
base_type = resource_type.split("_")[0]
|
| 474 |
+
entries = resources.get(base_type, [])
|
| 475 |
+
if not entries:
|
| 476 |
+
continue
|
| 477 |
+
|
| 478 |
+
# Find most recent entry date
|
| 479 |
+
latest_date = None
|
| 480 |
+
for entry in entries:
|
| 481 |
+
date_str = (
|
| 482 |
+
entry.get("effectiveDateTime")
|
| 483 |
+
or entry.get("authoredOn")
|
| 484 |
+
or entry.get("recordedDate")
|
| 485 |
+
or entry.get("meta", {}).get("lastUpdated")
|
| 486 |
+
)
|
| 487 |
+
if date_str:
|
| 488 |
+
try:
|
| 489 |
+
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
| 490 |
+
if latest_date is None or dt > latest_date:
|
| 491 |
+
latest_date = dt
|
| 492 |
+
except (ValueError, TypeError):
|
| 493 |
+
continue
|
| 494 |
+
|
| 495 |
+
if latest_date:
|
| 496 |
+
hours_old = (now - latest_date).total_seconds() / 3600
|
| 497 |
+
if hours_old > threshold_hours:
|
| 498 |
+
ctx.stale_resources.append(
|
| 499 |
+
f"{resource_type} (last updated {hours_old:.0f}h ago, threshold {threshold_hours}h)"
|
| 500 |
+
)
|
| 501 |
+
logger.warning(
|
| 502 |
+
"Stale FHIR data: %s is %.0fh old (threshold: %dh)",
|
| 503 |
+
resource_type,
|
| 504 |
+
hours_old,
|
| 505 |
+
threshold_hours,
|
| 506 |
+
)
|
decision/engine/flow_engine.py
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Flow Execution Engine (Phase 2A).
|
| 3 |
+
|
| 4 |
+
Manages multi-step clinical conversation flows with:
|
| 5 |
+
- State machine for flow step progression
|
| 6 |
+
- Slot tracking and validation
|
| 7 |
+
- Response spec enforcement (must_include, must_not, max_questions)
|
| 8 |
+
- Primitive sequence execution (greeting → identity_verify → consent)
|
| 9 |
+
- Domain-specific flow routing from risk assessment
|
| 10 |
+
- Exit condition evaluation
|
| 11 |
+
|
| 12 |
+
Safety invariants:
|
| 13 |
+
- Escalation flows CANNOT be interrupted or rolled back
|
| 14 |
+
- Hard-escalate domains skip screening and go directly to escalate_r3
|
| 15 |
+
- If unclear at any step, fail open (escalate, not suppress)
|
| 16 |
+
- Response specs enforce must_not constraints — these are NEVER relaxed
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import logging
|
| 22 |
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
| 23 |
+
|
| 24 |
+
from decision.engine.config_loader import DecisionConfigLoader
|
| 25 |
+
from decision.engine.models import (
|
| 26 |
+
CallPhase,
|
| 27 |
+
ConfidenceTier,
|
| 28 |
+
ConversationFlow,
|
| 29 |
+
FlowStep,
|
| 30 |
+
FlowType,
|
| 31 |
+
ResponseSpec,
|
| 32 |
+
RiskAssessment,
|
| 33 |
+
RiskClass,
|
| 34 |
+
SessionState,
|
| 35 |
+
SlotState,
|
| 36 |
+
TurnOutcome,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
logger = logging.getLogger("decision.flow_engine")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
# Flow Resolution
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
|
| 46 |
+
class FlowResolver:
|
| 47 |
+
"""
|
| 48 |
+
Resolves which conversation flow to execute based on the current
|
| 49 |
+
session state, risk assessment, and domain rules.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
def __init__(self, config: DecisionConfigLoader):
|
| 53 |
+
self._config = config
|
| 54 |
+
self._primitives = config.primitives
|
| 55 |
+
self._taxonomy_flows = config.taxonomy_flows
|
| 56 |
+
self._taxonomy_rules = config.taxonomy_rules
|
| 57 |
+
self._primitive_sequence = config.global_rules.get("primitive_sequence", [])
|
| 58 |
+
self._fallback_flow = config.global_rules.get("fallback_flow", "primitives/clarify")
|
| 59 |
+
|
| 60 |
+
def resolve_initial_flow(self, session: SessionState) -> Optional[FlowDefinition]:
|
| 61 |
+
"""Resolve the first flow to execute when a call starts."""
|
| 62 |
+
if self._primitive_sequence:
|
| 63 |
+
first = self._primitive_sequence[0]
|
| 64 |
+
return self._load_primitive(first)
|
| 65 |
+
return None
|
| 66 |
+
|
| 67 |
+
def resolve_next_flow(
|
| 68 |
+
self,
|
| 69 |
+
session: SessionState,
|
| 70 |
+
assessment: Optional[RiskAssessment] = None,
|
| 71 |
+
current_exit: Optional[str] = None,
|
| 72 |
+
) -> Optional[FlowDefinition]:
|
| 73 |
+
"""
|
| 74 |
+
Resolve the next flow based on current state and exit condition.
|
| 75 |
+
|
| 76 |
+
Priority order:
|
| 77 |
+
1. Hard-escalate → escalate_r3 (immediate, cannot be overridden)
|
| 78 |
+
2. R3 assessment → domain escalate_r3 flow
|
| 79 |
+
3. R2 assessment → domain escalate_r2 flow
|
| 80 |
+
4. Explicit exit rule (on_complete, on_refused, etc.)
|
| 81 |
+
5. Primitive sequence (if still in opening)
|
| 82 |
+
6. Domain-specific recommended_flow from assessment
|
| 83 |
+
7. Fallback → clarify
|
| 84 |
+
"""
|
| 85 |
+
# 1. Hard escalate overrides everything
|
| 86 |
+
if assessment and assessment.hard_escalate:
|
| 87 |
+
domain = assessment.primary_domain or "suicidal_ideation"
|
| 88 |
+
flow = self._load_domain_flow(domain, "escalate_r3")
|
| 89 |
+
if flow:
|
| 90 |
+
logger.warning("HARD ESCALATE: routing to %s/escalate_r3", domain)
|
| 91 |
+
return flow
|
| 92 |
+
# Fallback to generic handoff
|
| 93 |
+
return self._load_primitive("handoff")
|
| 94 |
+
|
| 95 |
+
# 2-3. Risk-based routing
|
| 96 |
+
if assessment and assessment.risk_class == RiskClass.R3:
|
| 97 |
+
domain = assessment.primary_domain
|
| 98 |
+
if domain:
|
| 99 |
+
flow = self._load_domain_flow(domain, "escalate_r3")
|
| 100 |
+
if flow:
|
| 101 |
+
return flow
|
| 102 |
+
return self._load_primitive("handoff")
|
| 103 |
+
|
| 104 |
+
if assessment and assessment.risk_class == RiskClass.R2:
|
| 105 |
+
domain = assessment.primary_domain
|
| 106 |
+
if domain:
|
| 107 |
+
flow = self._load_domain_flow(domain, "escalate_r2")
|
| 108 |
+
if flow:
|
| 109 |
+
return flow
|
| 110 |
+
return self._load_primitive("handoff")
|
| 111 |
+
|
| 112 |
+
# 4. Explicit exit rule
|
| 113 |
+
if current_exit:
|
| 114 |
+
return self._resolve_exit(current_exit, session)
|
| 115 |
+
|
| 116 |
+
# 5. Check if still in primitive sequence
|
| 117 |
+
prim_flow = self._advance_primitive_sequence(session)
|
| 118 |
+
if prim_flow:
|
| 119 |
+
return prim_flow
|
| 120 |
+
|
| 121 |
+
# 6. Domain recommended flow
|
| 122 |
+
if assessment and assessment.recommended_flow:
|
| 123 |
+
domain = assessment.primary_domain
|
| 124 |
+
if domain:
|
| 125 |
+
flow = self._load_domain_flow(domain, assessment.recommended_flow)
|
| 126 |
+
if flow:
|
| 127 |
+
return flow
|
| 128 |
+
|
| 129 |
+
# 7. Fallback
|
| 130 |
+
return self._load_primitive("clarify")
|
| 131 |
+
|
| 132 |
+
def _resolve_exit(
|
| 133 |
+
self, exit_key: str, session: SessionState
|
| 134 |
+
) -> Optional[FlowDefinition]:
|
| 135 |
+
"""Resolve an exit rule like 'on_complete', 'on_refused', etc."""
|
| 136 |
+
# Special exit targets
|
| 137 |
+
if exit_key == "end":
|
| 138 |
+
return None # Session over
|
| 139 |
+
if exit_key == "re_evaluate":
|
| 140 |
+
return None # Signal to re-run assessment
|
| 141 |
+
if exit_key == "evaluate_risk":
|
| 142 |
+
return None # Signal to run risk evaluation
|
| 143 |
+
if exit_key == "evaluate_concern":
|
| 144 |
+
return None # Signal to evaluate a patient concern
|
| 145 |
+
|
| 146 |
+
# Try as primitive name
|
| 147 |
+
prim = self._load_primitive(exit_key)
|
| 148 |
+
if prim:
|
| 149 |
+
return prim
|
| 150 |
+
|
| 151 |
+
# Try as domain flow (format: "domain/flow_id" or just "flow_id")
|
| 152 |
+
if "/" in exit_key:
|
| 153 |
+
domain, flow_id = exit_key.split("/", 1)
|
| 154 |
+
return self._load_domain_flow(domain, flow_id)
|
| 155 |
+
|
| 156 |
+
return None
|
| 157 |
+
|
| 158 |
+
def _advance_primitive_sequence(
|
| 159 |
+
self, session: SessionState
|
| 160 |
+
) -> Optional[FlowDefinition]:
|
| 161 |
+
"""Check if the session still needs to complete primitive sequence."""
|
| 162 |
+
if session.call_phase != CallPhase.SESSION_START:
|
| 163 |
+
return None
|
| 164 |
+
|
| 165 |
+
completed = set(session.domain_history)
|
| 166 |
+
for prim_name in self._primitive_sequence:
|
| 167 |
+
if prim_name not in completed:
|
| 168 |
+
return self._load_primitive(prim_name)
|
| 169 |
+
|
| 170 |
+
# All primitives complete — advance to active call phase
|
| 171 |
+
return None
|
| 172 |
+
|
| 173 |
+
def _load_primitive(self, name: str) -> Optional[FlowDefinition]:
|
| 174 |
+
"""Load a primitive flow definition by name."""
|
| 175 |
+
data = self._primitives.get(name)
|
| 176 |
+
if not data:
|
| 177 |
+
return None
|
| 178 |
+
return FlowDefinition.from_yaml(data, source=f"primitives/{name}")
|
| 179 |
+
|
| 180 |
+
def _load_domain_flow(
|
| 181 |
+
self, domain: str, flow_id: str
|
| 182 |
+
) -> Optional[FlowDefinition]:
|
| 183 |
+
"""Load a domain-specific flow definition."""
|
| 184 |
+
domain_flows = self._taxonomy_flows.get(domain, {})
|
| 185 |
+
data = domain_flows.get(flow_id)
|
| 186 |
+
if not data:
|
| 187 |
+
return None
|
| 188 |
+
return FlowDefinition.from_yaml(data, source=f"taxonomy/{domain}/{flow_id}")
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# ---------------------------------------------------------------------------
|
| 192 |
+
# Flow Definition (parsed from YAML)
|
| 193 |
+
# ---------------------------------------------------------------------------
|
| 194 |
+
|
| 195 |
+
class FlowDefinition:
|
| 196 |
+
"""A parsed conversation flow with steps and exit rules."""
|
| 197 |
+
|
| 198 |
+
def __init__(
|
| 199 |
+
self,
|
| 200 |
+
flow_id: str,
|
| 201 |
+
flow_type: str,
|
| 202 |
+
domain: Optional[str],
|
| 203 |
+
required_slots: List[str],
|
| 204 |
+
steps: List[FlowStepDef],
|
| 205 |
+
exit_rules: Dict[str, str],
|
| 206 |
+
source: str = "",
|
| 207 |
+
):
|
| 208 |
+
self.flow_id = flow_id
|
| 209 |
+
self.flow_type = flow_type
|
| 210 |
+
self.domain = domain
|
| 211 |
+
self.required_slots = required_slots
|
| 212 |
+
self.steps = steps
|
| 213 |
+
self.exit_rules = exit_rules
|
| 214 |
+
self.source = source
|
| 215 |
+
|
| 216 |
+
@classmethod
|
| 217 |
+
def from_yaml(cls, data: Dict[str, Any], source: str = "") -> FlowDefinition:
|
| 218 |
+
"""Parse a flow definition from YAML data."""
|
| 219 |
+
flow_id = data.get("flow_id", "unknown")
|
| 220 |
+
flow_type = data.get("type", "primitive")
|
| 221 |
+
domain = data.get("domain")
|
| 222 |
+
required_slots = data.get("required_slots", [])
|
| 223 |
+
exit_rules = data.get("exit", {})
|
| 224 |
+
|
| 225 |
+
steps = []
|
| 226 |
+
for action in data.get("actions", []):
|
| 227 |
+
step = FlowStepDef.from_yaml(action)
|
| 228 |
+
steps.append(step)
|
| 229 |
+
|
| 230 |
+
return cls(
|
| 231 |
+
flow_id=flow_id,
|
| 232 |
+
flow_type=flow_type,
|
| 233 |
+
domain=domain,
|
| 234 |
+
required_slots=required_slots,
|
| 235 |
+
steps=steps,
|
| 236 |
+
exit_rules=exit_rules,
|
| 237 |
+
source=source,
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
@property
|
| 241 |
+
def step_count(self) -> int:
|
| 242 |
+
return len(self.steps)
|
| 243 |
+
|
| 244 |
+
def get_step(self, index: int) -> Optional[FlowStepDef]:
|
| 245 |
+
if 0 <= index < len(self.steps):
|
| 246 |
+
return self.steps[index]
|
| 247 |
+
return None
|
| 248 |
+
|
| 249 |
+
def __repr__(self) -> str:
|
| 250 |
+
return f"FlowDefinition({self.flow_id}, type={self.flow_type}, steps={self.step_count})"
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
class FlowStepDef:
|
| 254 |
+
"""A single step in a flow, parsed from YAML."""
|
| 255 |
+
|
| 256 |
+
def __init__(
|
| 257 |
+
self,
|
| 258 |
+
step_number: int,
|
| 259 |
+
step_type: str,
|
| 260 |
+
response_spec: ResponseSpecDef,
|
| 261 |
+
collects: List[str],
|
| 262 |
+
handoff_target: Optional[str] = None,
|
| 263 |
+
handoff_urgency: Optional[str] = None,
|
| 264 |
+
):
|
| 265 |
+
self.step_number = step_number
|
| 266 |
+
self.step_type = step_type
|
| 267 |
+
self.response_spec = response_spec
|
| 268 |
+
self.collects = collects
|
| 269 |
+
self.handoff_target = handoff_target
|
| 270 |
+
self.handoff_urgency = handoff_urgency
|
| 271 |
+
|
| 272 |
+
@classmethod
|
| 273 |
+
def from_yaml(cls, data: Dict[str, Any]) -> FlowStepDef:
|
| 274 |
+
step_number = data.get("step", 0)
|
| 275 |
+
step_type = data.get("type", "respond")
|
| 276 |
+
spec_data = data.get("response_spec", {})
|
| 277 |
+
response_spec = ResponseSpecDef.from_yaml(spec_data)
|
| 278 |
+
collects = data.get("collects", [])
|
| 279 |
+
handoff_target = data.get("target")
|
| 280 |
+
handoff_urgency = data.get("urgency")
|
| 281 |
+
|
| 282 |
+
return cls(
|
| 283 |
+
step_number=step_number,
|
| 284 |
+
step_type=step_type,
|
| 285 |
+
response_spec=response_spec,
|
| 286 |
+
collects=collects,
|
| 287 |
+
handoff_target=handoff_target,
|
| 288 |
+
handoff_urgency=handoff_urgency,
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
class ResponseSpecDef:
|
| 293 |
+
"""LLM response constraints parsed from YAML."""
|
| 294 |
+
|
| 295 |
+
def __init__(
|
| 296 |
+
self,
|
| 297 |
+
spec_id: str,
|
| 298 |
+
goal: str,
|
| 299 |
+
tone: str,
|
| 300 |
+
must_include: List[str],
|
| 301 |
+
must_ask: List[str],
|
| 302 |
+
must_not: List[str],
|
| 303 |
+
max_questions: int,
|
| 304 |
+
):
|
| 305 |
+
self.spec_id = spec_id
|
| 306 |
+
self.goal = goal
|
| 307 |
+
self.tone = tone
|
| 308 |
+
self.must_include = must_include
|
| 309 |
+
self.must_ask = must_ask
|
| 310 |
+
self.must_not = must_not
|
| 311 |
+
self.max_questions = max_questions
|
| 312 |
+
|
| 313 |
+
@classmethod
|
| 314 |
+
def from_yaml(cls, data: Dict[str, Any]) -> ResponseSpecDef:
|
| 315 |
+
return cls(
|
| 316 |
+
spec_id=data.get("id", ""),
|
| 317 |
+
goal=data.get("goal", ""),
|
| 318 |
+
tone=data.get("tone", "professional"),
|
| 319 |
+
must_include=data.get("must_include", []),
|
| 320 |
+
must_ask=data.get("must_ask", []),
|
| 321 |
+
must_not=data.get("must_not", []),
|
| 322 |
+
max_questions=data.get("max_questions", 0),
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
def validate_response(self, response_text: str) -> List[str]:
|
| 326 |
+
"""
|
| 327 |
+
Validate an LLM-generated response against this spec.
|
| 328 |
+
Returns list of violations (empty = valid).
|
| 329 |
+
"""
|
| 330 |
+
violations = []
|
| 331 |
+
response_lower = response_text.lower()
|
| 332 |
+
|
| 333 |
+
# Check must_include
|
| 334 |
+
for phrase in self.must_include:
|
| 335 |
+
if phrase.lower() not in response_lower:
|
| 336 |
+
violations.append(f"MISSING required phrase: '{phrase}'")
|
| 337 |
+
|
| 338 |
+
# Check must_not — SAFETY CRITICAL, never relaxed
|
| 339 |
+
for phrase in self.must_not:
|
| 340 |
+
if phrase.lower() in response_lower:
|
| 341 |
+
violations.append(f"FORBIDDEN phrase found: '{phrase}'")
|
| 342 |
+
|
| 343 |
+
# Check max_questions (count question marks)
|
| 344 |
+
question_count = response_text.count("?")
|
| 345 |
+
if question_count > self.max_questions and self.max_questions >= 0:
|
| 346 |
+
violations.append(
|
| 347 |
+
f"Too many questions: {question_count} > max {self.max_questions}"
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
return violations
|
| 351 |
+
|
| 352 |
+
def to_prompt_constraints(self) -> str:
|
| 353 |
+
"""Generate constraint text for LLM prompt injection."""
|
| 354 |
+
lines = [f"Goal: {self.goal}", f"Tone: {self.tone}"]
|
| 355 |
+
if self.must_include:
|
| 356 |
+
lines.append(f"Must include: {', '.join(self.must_include)}")
|
| 357 |
+
if self.must_ask:
|
| 358 |
+
lines.append(f"Must ask about: {', '.join(self.must_ask)}")
|
| 359 |
+
if self.must_not:
|
| 360 |
+
lines.append(f"NEVER say: {', '.join(self.must_not)}")
|
| 361 |
+
if self.max_questions >= 0:
|
| 362 |
+
lines.append(f"Maximum questions: {self.max_questions}")
|
| 363 |
+
return "\n".join(lines)
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
# ---------------------------------------------------------------------------
|
| 367 |
+
# Flow Execution Engine
|
| 368 |
+
# ---------------------------------------------------------------------------
|
| 369 |
+
|
| 370 |
+
class FlowEngine:
|
| 371 |
+
"""
|
| 372 |
+
Executes conversation flows and manages session state.
|
| 373 |
+
|
| 374 |
+
Usage:
|
| 375 |
+
engine = FlowEngine(config)
|
| 376 |
+
session = engine.create_session("session-123")
|
| 377 |
+
|
| 378 |
+
# Start call
|
| 379 |
+
result = engine.start_call(session)
|
| 380 |
+
# result.response_spec has what Avery should say
|
| 381 |
+
|
| 382 |
+
# Process patient response
|
| 383 |
+
result = engine.process_turn(session, patient_text, assessment)
|
| 384 |
+
# result tells you: next response_spec, slots collected, flow status
|
| 385 |
+
"""
|
| 386 |
+
|
| 387 |
+
def __init__(self, config: DecisionConfigLoader):
|
| 388 |
+
self._config = config
|
| 389 |
+
self._resolver = FlowResolver(config)
|
| 390 |
+
self._sessions: Dict[str, SessionState] = {}
|
| 391 |
+
|
| 392 |
+
def create_session(
|
| 393 |
+
self,
|
| 394 |
+
session_id: str,
|
| 395 |
+
patient_id: Optional[str] = None,
|
| 396 |
+
tenant_id: Optional[str] = None,
|
| 397 |
+
) -> SessionState:
|
| 398 |
+
"""Create a new call session."""
|
| 399 |
+
session = SessionState(
|
| 400 |
+
session_id=session_id,
|
| 401 |
+
patient_id=patient_id,
|
| 402 |
+
tenant_id=tenant_id,
|
| 403 |
+
call_phase=CallPhase.SESSION_START,
|
| 404 |
+
)
|
| 405 |
+
self._sessions[session_id] = session
|
| 406 |
+
logger.info("Session created: %s (patient=%s, tenant=%s)", session_id, patient_id, tenant_id)
|
| 407 |
+
return session
|
| 408 |
+
|
| 409 |
+
def get_session(self, session_id: str) -> Optional[SessionState]:
|
| 410 |
+
"""Retrieve an existing session."""
|
| 411 |
+
return self._sessions.get(session_id)
|
| 412 |
+
|
| 413 |
+
def destroy_session(self, session_id: str) -> None:
|
| 414 |
+
"""Destroy a session."""
|
| 415 |
+
self._sessions.pop(session_id, None)
|
| 416 |
+
|
| 417 |
+
def start_call(self, session: SessionState) -> TurnResult:
|
| 418 |
+
"""
|
| 419 |
+
Start a new call — returns the first flow step (greeting).
|
| 420 |
+
"""
|
| 421 |
+
flow = self._resolver.resolve_initial_flow(session)
|
| 422 |
+
if not flow:
|
| 423 |
+
return TurnResult(
|
| 424 |
+
outcome=TurnOutcome.END,
|
| 425 |
+
message="No initial flow available",
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
session.current_flow = flow.flow_id
|
| 429 |
+
session.current_step = 0
|
| 430 |
+
|
| 431 |
+
step = flow.get_step(0)
|
| 432 |
+
if not step:
|
| 433 |
+
return TurnResult(outcome=TurnOutcome.END, message="Flow has no steps")
|
| 434 |
+
|
| 435 |
+
return TurnResult(
|
| 436 |
+
outcome=TurnOutcome.PROCEED,
|
| 437 |
+
flow_id=flow.flow_id,
|
| 438 |
+
flow_type=flow.flow_type,
|
| 439 |
+
step_number=0,
|
| 440 |
+
step_type=step.step_type,
|
| 441 |
+
response_spec=step.response_spec,
|
| 442 |
+
collects=step.collects,
|
| 443 |
+
handoff_target=step.handoff_target,
|
| 444 |
+
handoff_urgency=step.handoff_urgency,
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
def process_turn(
|
| 448 |
+
self,
|
| 449 |
+
session: SessionState,
|
| 450 |
+
patient_text: str,
|
| 451 |
+
assessment: Optional[RiskAssessment] = None,
|
| 452 |
+
extracted_slots: Optional[Dict[str, Any]] = None,
|
| 453 |
+
) -> TurnResult:
|
| 454 |
+
"""
|
| 455 |
+
Process a patient turn and determine the next action.
|
| 456 |
+
|
| 457 |
+
Args:
|
| 458 |
+
session: Current session state
|
| 459 |
+
patient_text: What the patient said
|
| 460 |
+
assessment: Risk assessment from Phase 1 engine
|
| 461 |
+
extracted_slots: Slots extracted from patient text (by NLU)
|
| 462 |
+
|
| 463 |
+
Returns:
|
| 464 |
+
TurnResult with next response_spec or escalation action
|
| 465 |
+
"""
|
| 466 |
+
session.turn_count += 1
|
| 467 |
+
|
| 468 |
+
# Update slots from extracted values
|
| 469 |
+
if extracted_slots:
|
| 470 |
+
for k, v in extracted_slots.items():
|
| 471 |
+
session.slots.set_slot(k, v)
|
| 472 |
+
|
| 473 |
+
# SAFETY CHECK: If assessment triggers R3/hard_escalate, interrupt immediately
|
| 474 |
+
if assessment and (assessment.hard_escalate or assessment.risk_class == RiskClass.R3):
|
| 475 |
+
return self._handle_emergency_escalation(session, assessment)
|
| 476 |
+
|
| 477 |
+
if assessment and assessment.risk_class == RiskClass.R2:
|
| 478 |
+
return self._handle_nurse_transfer(session, assessment)
|
| 479 |
+
|
| 480 |
+
# Get current flow
|
| 481 |
+
current_flow = self._get_current_flow(session)
|
| 482 |
+
if not current_flow:
|
| 483 |
+
# No active flow — resolve from assessment
|
| 484 |
+
flow = self._resolver.resolve_next_flow(session, assessment)
|
| 485 |
+
if not flow:
|
| 486 |
+
return TurnResult(outcome=TurnOutcome.END, message="No applicable flow")
|
| 487 |
+
return self._enter_flow(session, flow)
|
| 488 |
+
|
| 489 |
+
# Advance to next step in current flow
|
| 490 |
+
return self._advance_flow(session, current_flow, assessment)
|
| 491 |
+
|
| 492 |
+
def _handle_emergency_escalation(
|
| 493 |
+
self, session: SessionState, assessment: RiskAssessment
|
| 494 |
+
) -> TurnResult:
|
| 495 |
+
"""Handle R3 / hard-escalate — interrupt everything, route to escalation."""
|
| 496 |
+
domain = assessment.primary_domain or "unknown"
|
| 497 |
+
session.call_phase = CallPhase.ENDED
|
| 498 |
+
|
| 499 |
+
# Try to load domain-specific escalation flow
|
| 500 |
+
flow = self._resolver.resolve_next_flow(session, assessment)
|
| 501 |
+
if flow and flow.steps:
|
| 502 |
+
step = flow.get_step(0)
|
| 503 |
+
logger.warning(
|
| 504 |
+
"EMERGENCY ESCALATION: session=%s domain=%s risk=%s",
|
| 505 |
+
session.session_id, domain, assessment.risk_class.value,
|
| 506 |
+
)
|
| 507 |
+
return TurnResult(
|
| 508 |
+
outcome=TurnOutcome.ESCALATE,
|
| 509 |
+
flow_id=flow.flow_id,
|
| 510 |
+
flow_type=flow.flow_type,
|
| 511 |
+
step_number=0,
|
| 512 |
+
step_type=step.step_type if step else "respond",
|
| 513 |
+
response_spec=step.response_spec if step else None,
|
| 514 |
+
handoff_target=step.handoff_target if step else "clinical_nurse",
|
| 515 |
+
handoff_urgency="immediate",
|
| 516 |
+
risk_class=assessment.risk_class,
|
| 517 |
+
domain=domain,
|
| 518 |
+
message=f"R3 EMERGENCY: {domain}",
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
# Fallback: generic escalation
|
| 522 |
+
return TurnResult(
|
| 523 |
+
outcome=TurnOutcome.ESCALATE,
|
| 524 |
+
handoff_target="clinical_nurse",
|
| 525 |
+
handoff_urgency="immediate",
|
| 526 |
+
risk_class=assessment.risk_class,
|
| 527 |
+
domain=domain,
|
| 528 |
+
message=f"R3 EMERGENCY: {domain} — transfer to nurse immediately",
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
def _handle_nurse_transfer(
|
| 532 |
+
self, session: SessionState, assessment: RiskAssessment
|
| 533 |
+
) -> TurnResult:
|
| 534 |
+
"""Handle R2 — warm transfer to nurse."""
|
| 535 |
+
domain = assessment.primary_domain or "unknown"
|
| 536 |
+
|
| 537 |
+
flow = self._resolver.resolve_next_flow(session, assessment)
|
| 538 |
+
if flow and flow.steps:
|
| 539 |
+
step = flow.get_step(0)
|
| 540 |
+
return TurnResult(
|
| 541 |
+
outcome=TurnOutcome.HANDOFF,
|
| 542 |
+
flow_id=flow.flow_id,
|
| 543 |
+
flow_type=flow.flow_type,
|
| 544 |
+
step_number=0,
|
| 545 |
+
step_type=step.step_type if step else "respond",
|
| 546 |
+
response_spec=step.response_spec if step else None,
|
| 547 |
+
handoff_target=step.handoff_target if step else "clinical_nurse",
|
| 548 |
+
handoff_urgency="soon",
|
| 549 |
+
risk_class=assessment.risk_class,
|
| 550 |
+
domain=domain,
|
| 551 |
+
message=f"R2: {domain} — schedule nurse callback",
|
| 552 |
+
)
|
| 553 |
+
|
| 554 |
+
return TurnResult(
|
| 555 |
+
outcome=TurnOutcome.HANDOFF,
|
| 556 |
+
handoff_target="clinical_nurse",
|
| 557 |
+
handoff_urgency="soon",
|
| 558 |
+
risk_class=assessment.risk_class,
|
| 559 |
+
domain=domain,
|
| 560 |
+
message=f"R2: {domain} — schedule nurse callback",
|
| 561 |
+
)
|
| 562 |
+
|
| 563 |
+
def _enter_flow(self, session: SessionState, flow: FlowDefinition) -> TurnResult:
|
| 564 |
+
"""Enter a new flow and return the first step."""
|
| 565 |
+
session.current_flow = flow.flow_id
|
| 566 |
+
session.current_step = 0
|
| 567 |
+
session.domain_history.append(flow.flow_id)
|
| 568 |
+
|
| 569 |
+
step = flow.get_step(0)
|
| 570 |
+
if not step:
|
| 571 |
+
return TurnResult(outcome=TurnOutcome.PROCEED, message="Flow has no steps")
|
| 572 |
+
|
| 573 |
+
return TurnResult(
|
| 574 |
+
outcome=TurnOutcome.PROCEED,
|
| 575 |
+
flow_id=flow.flow_id,
|
| 576 |
+
flow_type=flow.flow_type,
|
| 577 |
+
step_number=0,
|
| 578 |
+
step_type=step.step_type,
|
| 579 |
+
response_spec=step.response_spec,
|
| 580 |
+
collects=step.collects,
|
| 581 |
+
handoff_target=step.handoff_target,
|
| 582 |
+
handoff_urgency=step.handoff_urgency,
|
| 583 |
+
)
|
| 584 |
+
|
| 585 |
+
def _advance_flow(
|
| 586 |
+
self,
|
| 587 |
+
session: SessionState,
|
| 588 |
+
flow: FlowDefinition,
|
| 589 |
+
assessment: Optional[RiskAssessment],
|
| 590 |
+
) -> TurnResult:
|
| 591 |
+
"""Advance to the next step in the current flow."""
|
| 592 |
+
next_step_idx = session.current_step + 1
|
| 593 |
+
|
| 594 |
+
if next_step_idx < flow.step_count:
|
| 595 |
+
# Move to next step
|
| 596 |
+
session.current_step = next_step_idx
|
| 597 |
+
step = flow.get_step(next_step_idx)
|
| 598 |
+
|
| 599 |
+
return TurnResult(
|
| 600 |
+
outcome=TurnOutcome.PROCEED,
|
| 601 |
+
flow_id=flow.flow_id,
|
| 602 |
+
flow_type=flow.flow_type,
|
| 603 |
+
step_number=next_step_idx,
|
| 604 |
+
step_type=step.step_type,
|
| 605 |
+
response_spec=step.response_spec,
|
| 606 |
+
collects=step.collects,
|
| 607 |
+
handoff_target=step.handoff_target,
|
| 608 |
+
handoff_urgency=step.handoff_urgency,
|
| 609 |
+
)
|
| 610 |
+
|
| 611 |
+
# Flow complete — evaluate exit rules
|
| 612 |
+
exit_rules = flow.exit_rules
|
| 613 |
+
exit_target = exit_rules.get("on_complete", "end")
|
| 614 |
+
|
| 615 |
+
# Check for special exits based on slot values
|
| 616 |
+
if "on_refused" in exit_rules and session.slots.get_slot("consent_given") == "no":
|
| 617 |
+
exit_target = exit_rules["on_refused"]
|
| 618 |
+
if "on_unclear" in exit_rules and session.slots.get_slot("clarification_text") is None:
|
| 619 |
+
exit_target = exit_rules.get("on_unclear", exit_target)
|
| 620 |
+
if "on_concern" in exit_rules:
|
| 621 |
+
# Check if any concern was raised during the flow
|
| 622 |
+
concern_slots = ["pain_level", "medication_adherence"]
|
| 623 |
+
for slot in concern_slots:
|
| 624 |
+
val = session.slots.get_slot(slot)
|
| 625 |
+
if val and isinstance(val, str) and any(w in val.lower() for w in ["severe", "bad", "not taking", "stopped"]):
|
| 626 |
+
exit_target = exit_rules["on_concern"]
|
| 627 |
+
break
|
| 628 |
+
|
| 629 |
+
# Reset current flow
|
| 630 |
+
session.current_flow = None
|
| 631 |
+
session.current_step = 0
|
| 632 |
+
|
| 633 |
+
if exit_target == "end":
|
| 634 |
+
session.call_phase = CallPhase.ENDED
|
| 635 |
+
return TurnResult(outcome=TurnOutcome.END, message="Flow complete")
|
| 636 |
+
|
| 637 |
+
if exit_target == "re_evaluate" or exit_target == "evaluate_risk":
|
| 638 |
+
# Signal to re-run the risk assessment and determine next flow
|
| 639 |
+
return TurnResult(
|
| 640 |
+
outcome=TurnOutcome.PROCEED,
|
| 641 |
+
message=f"Flow complete — re-evaluate ({exit_target})",
|
| 642 |
+
requires_reassessment=True,
|
| 643 |
+
)
|
| 644 |
+
|
| 645 |
+
# Resolve exit target as next flow
|
| 646 |
+
next_flow = self._resolver.resolve_next_flow(session, assessment, current_exit=exit_target)
|
| 647 |
+
if next_flow:
|
| 648 |
+
return self._enter_flow(session, next_flow)
|
| 649 |
+
|
| 650 |
+
return TurnResult(outcome=TurnOutcome.END, message="No next flow resolved")
|
| 651 |
+
|
| 652 |
+
def _get_current_flow(self, session: SessionState) -> Optional[FlowDefinition]:
|
| 653 |
+
"""Get the current flow definition from session state."""
|
| 654 |
+
if not session.current_flow:
|
| 655 |
+
return None
|
| 656 |
+
|
| 657 |
+
# Check primitives first
|
| 658 |
+
prim = self._config.primitives.get(session.current_flow)
|
| 659 |
+
if prim:
|
| 660 |
+
return FlowDefinition.from_yaml(prim, source=f"primitives/{session.current_flow}")
|
| 661 |
+
|
| 662 |
+
# Check taxonomy flows
|
| 663 |
+
for domain, flows in self._config.taxonomy_flows.items():
|
| 664 |
+
if session.current_flow in flows:
|
| 665 |
+
return FlowDefinition.from_yaml(
|
| 666 |
+
flows[session.current_flow],
|
| 667 |
+
source=f"taxonomy/{domain}/{session.current_flow}",
|
| 668 |
+
)
|
| 669 |
+
|
| 670 |
+
return None
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
# ---------------------------------------------------------------------------
|
| 674 |
+
# Turn Result
|
| 675 |
+
# ---------------------------------------------------------------------------
|
| 676 |
+
|
| 677 |
+
class TurnResult:
|
| 678 |
+
"""Result of processing a conversational turn."""
|
| 679 |
+
|
| 680 |
+
def __init__(
|
| 681 |
+
self,
|
| 682 |
+
outcome: TurnOutcome,
|
| 683 |
+
flow_id: Optional[str] = None,
|
| 684 |
+
flow_type: Optional[str] = None,
|
| 685 |
+
step_number: int = 0,
|
| 686 |
+
step_type: Optional[str] = None,
|
| 687 |
+
response_spec: Optional[ResponseSpecDef] = None,
|
| 688 |
+
collects: Optional[List[str]] = None,
|
| 689 |
+
handoff_target: Optional[str] = None,
|
| 690 |
+
handoff_urgency: Optional[str] = None,
|
| 691 |
+
risk_class: Optional[RiskClass] = None,
|
| 692 |
+
domain: Optional[str] = None,
|
| 693 |
+
message: str = "",
|
| 694 |
+
requires_reassessment: bool = False,
|
| 695 |
+
):
|
| 696 |
+
self.outcome = outcome
|
| 697 |
+
self.flow_id = flow_id
|
| 698 |
+
self.flow_type = flow_type
|
| 699 |
+
self.step_number = step_number
|
| 700 |
+
self.step_type = step_type
|
| 701 |
+
self.response_spec = response_spec
|
| 702 |
+
self.collects = collects or []
|
| 703 |
+
self.handoff_target = handoff_target
|
| 704 |
+
self.handoff_urgency = handoff_urgency
|
| 705 |
+
self.risk_class = risk_class
|
| 706 |
+
self.domain = domain
|
| 707 |
+
self.message = message
|
| 708 |
+
self.requires_reassessment = requires_reassessment
|
| 709 |
+
|
| 710 |
+
@property
|
| 711 |
+
def is_escalation(self) -> bool:
|
| 712 |
+
return self.outcome == TurnOutcome.ESCALATE
|
| 713 |
+
|
| 714 |
+
@property
|
| 715 |
+
def is_handoff(self) -> bool:
|
| 716 |
+
return self.outcome in (TurnOutcome.ESCALATE, TurnOutcome.HANDOFF)
|
| 717 |
+
|
| 718 |
+
@property
|
| 719 |
+
def is_end(self) -> bool:
|
| 720 |
+
return self.outcome == TurnOutcome.END
|
| 721 |
+
|
| 722 |
+
@property
|
| 723 |
+
def prompt_constraints(self) -> Optional[str]:
|
| 724 |
+
"""Get LLM prompt constraints from response spec."""
|
| 725 |
+
if self.response_spec:
|
| 726 |
+
return self.response_spec.to_prompt_constraints()
|
| 727 |
+
return None
|
| 728 |
+
|
| 729 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 730 |
+
"""Serialize for API response."""
|
| 731 |
+
result = {
|
| 732 |
+
"outcome": self.outcome.value,
|
| 733 |
+
"flow_id": self.flow_id,
|
| 734 |
+
"flow_type": self.flow_type,
|
| 735 |
+
"step_number": self.step_number,
|
| 736 |
+
"step_type": self.step_type,
|
| 737 |
+
"collects": self.collects,
|
| 738 |
+
"handoff_target": self.handoff_target,
|
| 739 |
+
"handoff_urgency": self.handoff_urgency,
|
| 740 |
+
"message": self.message,
|
| 741 |
+
"requires_reassessment": self.requires_reassessment,
|
| 742 |
+
}
|
| 743 |
+
if self.response_spec:
|
| 744 |
+
result["response_spec"] = {
|
| 745 |
+
"id": self.response_spec.spec_id,
|
| 746 |
+
"goal": self.response_spec.goal,
|
| 747 |
+
"tone": self.response_spec.tone,
|
| 748 |
+
"must_include": self.response_spec.must_include,
|
| 749 |
+
"must_ask": self.response_spec.must_ask,
|
| 750 |
+
"must_not": self.response_spec.must_not,
|
| 751 |
+
"max_questions": self.response_spec.max_questions,
|
| 752 |
+
}
|
| 753 |
+
if self.risk_class:
|
| 754 |
+
result["risk_class"] = self.risk_class.value
|
| 755 |
+
if self.domain:
|
| 756 |
+
result["domain"] = self.domain
|
| 757 |
+
return result
|
| 758 |
+
|
| 759 |
+
def __repr__(self) -> str:
|
| 760 |
+
return (
|
| 761 |
+
f"TurnResult(outcome={self.outcome.value}, flow={self.flow_id}, "
|
| 762 |
+
f"step={self.step_number}, type={self.step_type})"
|
| 763 |
+
)
|
decision/engine/journey_orchestrator.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Journey Orchestrator (Phase 3B).
|
| 3 |
+
|
| 4 |
+
Manages multi-day patient engagement journeys with:
|
| 5 |
+
- Enrollment eligibility and deduplication
|
| 6 |
+
- Contact scheduling (offset days, time windows, channel selection)
|
| 7 |
+
- Contact caps (max per day/week, quiet hours)
|
| 8 |
+
- Wedge priority and conflict resolution
|
| 9 |
+
- Post-escalation suppression
|
| 10 |
+
- Journey step tracking and agenda management
|
| 11 |
+
|
| 12 |
+
Usage:
|
| 13 |
+
orchestrator = JourneyOrchestrator(config, tenant_manager)
|
| 14 |
+
|
| 15 |
+
# Enroll a patient
|
| 16 |
+
enrollment = orchestrator.enroll(patient_id, wedge_type, tenant_id, trigger_event_id)
|
| 17 |
+
|
| 18 |
+
# Check if a contact is allowed right now
|
| 19 |
+
allowed, reason = orchestrator.can_contact(patient_id, channel="voice")
|
| 20 |
+
|
| 21 |
+
# Get the next scheduled step
|
| 22 |
+
step = orchestrator.get_next_step(enrollment_id)
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import logging
|
| 28 |
+
from dataclasses import dataclass, field
|
| 29 |
+
from datetime import datetime, timedelta, timezone
|
| 30 |
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
| 31 |
+
|
| 32 |
+
from decision.engine.config_loader import DecisionConfigLoader
|
| 33 |
+
from decision.engine.tenant_manager import TenantManager
|
| 34 |
+
|
| 35 |
+
logger = logging.getLogger("decision.journey_orchestrator")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
# Data models
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
|
| 42 |
+
@dataclass
|
| 43 |
+
class Enrollment:
|
| 44 |
+
"""A patient enrolled in a journey."""
|
| 45 |
+
enrollment_id: str
|
| 46 |
+
patient_id: str
|
| 47 |
+
wedge_type: str
|
| 48 |
+
tenant_id: str
|
| 49 |
+
trigger_event_id: str
|
| 50 |
+
status: str = "active" # active, completed, withdrawn, paused
|
| 51 |
+
enrolled_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
| 52 |
+
current_step: int = 0
|
| 53 |
+
completed_steps: List[int] = field(default_factory=list)
|
| 54 |
+
last_contact_at: Optional[datetime] = None
|
| 55 |
+
last_contact_channel: Optional[str] = None
|
| 56 |
+
next_scheduled_at: Optional[datetime] = None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclass
|
| 60 |
+
class ContactRecord:
|
| 61 |
+
"""Record of a patient contact for rate limiting."""
|
| 62 |
+
patient_id: str
|
| 63 |
+
channel: str # voice, sms
|
| 64 |
+
timestamp: datetime
|
| 65 |
+
wedge_type: str
|
| 66 |
+
risk_class: Optional[str] = None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@dataclass
|
| 70 |
+
class JourneyStep:
|
| 71 |
+
"""A step in a journey to be executed."""
|
| 72 |
+
step_index: int
|
| 73 |
+
step_name: str
|
| 74 |
+
channel: str
|
| 75 |
+
offset_days: int
|
| 76 |
+
window_start_hour: int
|
| 77 |
+
window_end_hour: int
|
| 78 |
+
agenda_sequence: List[str]
|
| 79 |
+
conditional_probes: List[Dict[str, Any]]
|
| 80 |
+
pre_populated_slots: List[str]
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@dataclass
|
| 84 |
+
class ContactDecision:
|
| 85 |
+
"""Result of a contact eligibility check."""
|
| 86 |
+
allowed: bool
|
| 87 |
+
reason: str
|
| 88 |
+
retry_after: Optional[datetime] = None
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# ---------------------------------------------------------------------------
|
| 92 |
+
# Journey Orchestrator
|
| 93 |
+
# ---------------------------------------------------------------------------
|
| 94 |
+
|
| 95 |
+
class JourneyOrchestrator:
|
| 96 |
+
"""
|
| 97 |
+
Orchestrates multi-day patient engagement journeys.
|
| 98 |
+
"""
|
| 99 |
+
|
| 100 |
+
def __init__(
|
| 101 |
+
self,
|
| 102 |
+
config: DecisionConfigLoader,
|
| 103 |
+
tenant_manager: Optional[TenantManager] = None,
|
| 104 |
+
):
|
| 105 |
+
self._config = config
|
| 106 |
+
self._tenant_manager = tenant_manager
|
| 107 |
+
self._journeys = config.journeys
|
| 108 |
+
self._orchestrator = config.orchestrator
|
| 109 |
+
|
| 110 |
+
# In-memory stores (production: replace with persistent storage)
|
| 111 |
+
self._enrollments: Dict[str, Enrollment] = {} # enrollment_id -> Enrollment
|
| 112 |
+
self._patient_enrollments: Dict[str, List[str]] = {} # patient_id -> [enrollment_ids]
|
| 113 |
+
self._contact_history: Dict[str, List[ContactRecord]] = {} # patient_id -> [ContactRecord]
|
| 114 |
+
|
| 115 |
+
# Load orchestrator rules
|
| 116 |
+
self._contact_caps = self._orchestrator.get("contact_caps", {})
|
| 117 |
+
self._wedge_priority = self._orchestrator.get("wedge_priority", {})
|
| 118 |
+
self._conflict_rules = self._orchestrator.get("conflict_resolution", {})
|
| 119 |
+
self._enrollment_rules = self._orchestrator.get("enrollment_rules", {})
|
| 120 |
+
self._suppression_rules = self._orchestrator.get("wedge_suppression", {})
|
| 121 |
+
|
| 122 |
+
logger.info(
|
| 123 |
+
"JourneyOrchestrator initialized: %d journeys, %d wedge priorities",
|
| 124 |
+
len(self._journeys),
|
| 125 |
+
len(self._wedge_priority.get("priorities", [])),
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# ------------------------------------------------------------------
|
| 129 |
+
# Enrollment
|
| 130 |
+
# ------------------------------------------------------------------
|
| 131 |
+
|
| 132 |
+
def enroll(
|
| 133 |
+
self,
|
| 134 |
+
patient_id: str,
|
| 135 |
+
wedge_type: str,
|
| 136 |
+
tenant_id: str,
|
| 137 |
+
trigger_event_id: str,
|
| 138 |
+
) -> Tuple[Optional[Enrollment], str]:
|
| 139 |
+
"""
|
| 140 |
+
Enroll a patient in a journey.
|
| 141 |
+
|
| 142 |
+
Returns:
|
| 143 |
+
(Enrollment, reason) — Enrollment object if successful, None if rejected.
|
| 144 |
+
"""
|
| 145 |
+
# Check journey exists
|
| 146 |
+
if wedge_type not in self._journeys:
|
| 147 |
+
return None, f"Unknown wedge type: {wedge_type}"
|
| 148 |
+
|
| 149 |
+
# Check tenant enables this wedge
|
| 150 |
+
if self._tenant_manager:
|
| 151 |
+
if not self._tenant_manager.is_wedge_enabled(tenant_id, wedge_type):
|
| 152 |
+
return None, f"Wedge '{wedge_type}' not enabled for tenant '{tenant_id}'"
|
| 153 |
+
|
| 154 |
+
# Check deduplication
|
| 155 |
+
existing = self._patient_enrollments.get(patient_id, [])
|
| 156 |
+
for eid in existing:
|
| 157 |
+
enrollment = self._enrollments.get(eid)
|
| 158 |
+
if enrollment and enrollment.status == "active":
|
| 159 |
+
if enrollment.wedge_type == wedge_type and enrollment.trigger_event_id == trigger_event_id:
|
| 160 |
+
return None, f"Duplicate enrollment: patient already in {wedge_type} for event {trigger_event_id}"
|
| 161 |
+
|
| 162 |
+
# Check max active enrollments
|
| 163 |
+
max_active = self._enrollment_rules.get("max_active_per_patient", 3)
|
| 164 |
+
active_count = sum(
|
| 165 |
+
1 for eid in existing
|
| 166 |
+
if self._enrollments.get(eid, Enrollment("", "", "", "", "")).status == "active"
|
| 167 |
+
)
|
| 168 |
+
if active_count >= max_active:
|
| 169 |
+
return None, f"Max active enrollments ({max_active}) reached for patient {patient_id}"
|
| 170 |
+
|
| 171 |
+
# Check cooldown
|
| 172 |
+
cooldown_days = self._get_cooldown(wedge_type)
|
| 173 |
+
if cooldown_days:
|
| 174 |
+
for eid in existing:
|
| 175 |
+
prev = self._enrollments.get(eid)
|
| 176 |
+
if prev and prev.wedge_type == wedge_type and prev.status == "completed":
|
| 177 |
+
elapsed = (datetime.now(timezone.utc) - prev.enrolled_at).days
|
| 178 |
+
if elapsed < cooldown_days:
|
| 179 |
+
return None, f"Cooldown active: {cooldown_days - elapsed} days remaining for {wedge_type}"
|
| 180 |
+
|
| 181 |
+
# Create enrollment
|
| 182 |
+
import uuid
|
| 183 |
+
enrollment_id = f"enr-{uuid.uuid4().hex[:12]}"
|
| 184 |
+
enrollment = Enrollment(
|
| 185 |
+
enrollment_id=enrollment_id,
|
| 186 |
+
patient_id=patient_id,
|
| 187 |
+
wedge_type=wedge_type,
|
| 188 |
+
tenant_id=tenant_id,
|
| 189 |
+
trigger_event_id=trigger_event_id,
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
# Calculate first contact schedule
|
| 193 |
+
journey = self._journeys[wedge_type]
|
| 194 |
+
steps = journey.get("steps", [])
|
| 195 |
+
if steps:
|
| 196 |
+
first_step = steps[0]
|
| 197 |
+
offset = first_step.get("offset_days", 1)
|
| 198 |
+
enrollment.next_scheduled_at = datetime.now(timezone.utc) + timedelta(days=offset)
|
| 199 |
+
|
| 200 |
+
self._enrollments[enrollment_id] = enrollment
|
| 201 |
+
self._patient_enrollments.setdefault(patient_id, []).append(enrollment_id)
|
| 202 |
+
|
| 203 |
+
logger.info(
|
| 204 |
+
"Enrolled patient %s in %s (enrollment=%s, tenant=%s)",
|
| 205 |
+
patient_id, wedge_type, enrollment_id, tenant_id,
|
| 206 |
+
)
|
| 207 |
+
return enrollment, "enrolled"
|
| 208 |
+
|
| 209 |
+
# ------------------------------------------------------------------
|
| 210 |
+
# Contact eligibility
|
| 211 |
+
# ------------------------------------------------------------------
|
| 212 |
+
|
| 213 |
+
def can_contact(
|
| 214 |
+
self,
|
| 215 |
+
patient_id: str,
|
| 216 |
+
channel: str = "voice",
|
| 217 |
+
now: Optional[datetime] = None,
|
| 218 |
+
) -> ContactDecision:
|
| 219 |
+
"""
|
| 220 |
+
Check if a patient can be contacted right now.
|
| 221 |
+
|
| 222 |
+
Evaluates:
|
| 223 |
+
1. Quiet hours (default 9PM-9AM)
|
| 224 |
+
2. Channel-specific daily/weekly caps
|
| 225 |
+
3. Post-escalation suppression
|
| 226 |
+
4. Voice cooldown (4h between voice calls)
|
| 227 |
+
"""
|
| 228 |
+
now = now or datetime.now(timezone.utc)
|
| 229 |
+
|
| 230 |
+
# 1. Quiet hours (YAML may store as "21:00" or 21)
|
| 231 |
+
raw_start = self._contact_caps.get("quiet_hours", {}).get("start", 21)
|
| 232 |
+
raw_end = self._contact_caps.get("quiet_hours", {}).get("end", 9)
|
| 233 |
+
quiet_start = int(str(raw_start).split(":")[0])
|
| 234 |
+
quiet_end = int(str(raw_end).split(":")[0])
|
| 235 |
+
current_hour = now.hour
|
| 236 |
+
if quiet_start <= current_hour or current_hour < quiet_end:
|
| 237 |
+
resume_at = now.replace(hour=quiet_end, minute=0, second=0, microsecond=0)
|
| 238 |
+
if current_hour >= quiet_start:
|
| 239 |
+
resume_at += timedelta(days=1)
|
| 240 |
+
return ContactDecision(
|
| 241 |
+
allowed=False,
|
| 242 |
+
reason=f"Quiet hours ({quiet_start}:00-{quiet_end}:00)",
|
| 243 |
+
retry_after=resume_at,
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
# 2. Daily/weekly caps
|
| 247 |
+
history = self._contact_history.get(patient_id, [])
|
| 248 |
+
today = now.date()
|
| 249 |
+
week_ago = now - timedelta(days=7)
|
| 250 |
+
|
| 251 |
+
daily_total = sum(1 for r in history if r.timestamp.date() == today)
|
| 252 |
+
weekly_total = sum(1 for r in history if r.timestamp >= week_ago)
|
| 253 |
+
|
| 254 |
+
max_daily = self._contact_caps.get("max_contacts_per_day", 2)
|
| 255 |
+
max_weekly = self._contact_caps.get("max_contacts_per_week", 7)
|
| 256 |
+
|
| 257 |
+
if daily_total >= max_daily:
|
| 258 |
+
tomorrow = now.replace(hour=quiet_end, minute=0) + timedelta(days=1)
|
| 259 |
+
return ContactDecision(
|
| 260 |
+
allowed=False,
|
| 261 |
+
reason=f"Daily contact cap reached ({daily_total}/{max_daily})",
|
| 262 |
+
retry_after=tomorrow,
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
if weekly_total >= max_weekly:
|
| 266 |
+
return ContactDecision(
|
| 267 |
+
allowed=False,
|
| 268 |
+
reason=f"Weekly contact cap reached ({weekly_total}/{max_weekly})",
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
# Channel-specific caps (stored as list of {channel, max_per_day, ...})
|
| 272 |
+
raw_channel_caps = self._contact_caps.get("channel_caps", [])
|
| 273 |
+
channel_caps = {}
|
| 274 |
+
if isinstance(raw_channel_caps, list):
|
| 275 |
+
for entry in raw_channel_caps:
|
| 276 |
+
if isinstance(entry, dict) and entry.get("channel") == channel:
|
| 277 |
+
channel_caps = entry
|
| 278 |
+
break
|
| 279 |
+
elif isinstance(raw_channel_caps, dict):
|
| 280 |
+
channel_caps = raw_channel_caps.get(channel, {})
|
| 281 |
+
ch_max_daily = channel_caps.get("max_per_day", max_daily)
|
| 282 |
+
ch_daily = sum(1 for r in history if r.timestamp.date() == today and r.channel == channel)
|
| 283 |
+
if ch_daily >= ch_max_daily:
|
| 284 |
+
return ContactDecision(
|
| 285 |
+
allowed=False,
|
| 286 |
+
reason=f"{channel} daily cap reached ({ch_daily}/{ch_max_daily})",
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
# 3. Post-escalation suppression
|
| 290 |
+
suppression_rules = self._suppression_rules.get("post_escalation", [])
|
| 291 |
+
for rule in suppression_rules:
|
| 292 |
+
risk_trigger = rule.get("after_risk_class")
|
| 293 |
+
suppress_hours = rule.get("suppress_all_hours", 0)
|
| 294 |
+
if risk_trigger and suppress_hours:
|
| 295 |
+
for record in reversed(history):
|
| 296 |
+
if record.risk_class == risk_trigger:
|
| 297 |
+
suppress_until = record.timestamp + timedelta(hours=suppress_hours)
|
| 298 |
+
if now < suppress_until:
|
| 299 |
+
return ContactDecision(
|
| 300 |
+
allowed=False,
|
| 301 |
+
reason=f"Post-{risk_trigger} suppression ({suppress_hours}h)",
|
| 302 |
+
retry_after=suppress_until,
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
# 4. Voice cooldown
|
| 306 |
+
if channel == "voice":
|
| 307 |
+
cooldown_hours = self._contact_caps.get("voice_cooldown_hours", 4)
|
| 308 |
+
voice_records = [r for r in history if r.channel == "voice"]
|
| 309 |
+
if voice_records:
|
| 310 |
+
last_voice = max(voice_records, key=lambda r: r.timestamp)
|
| 311 |
+
cooldown_until = last_voice.timestamp + timedelta(hours=cooldown_hours)
|
| 312 |
+
if now < cooldown_until:
|
| 313 |
+
return ContactDecision(
|
| 314 |
+
allowed=False,
|
| 315 |
+
reason=f"Voice cooldown ({cooldown_hours}h since last voice contact)",
|
| 316 |
+
retry_after=cooldown_until,
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
return ContactDecision(allowed=True, reason="Contact allowed")
|
| 320 |
+
|
| 321 |
+
def record_contact(
|
| 322 |
+
self,
|
| 323 |
+
patient_id: str,
|
| 324 |
+
channel: str,
|
| 325 |
+
wedge_type: str,
|
| 326 |
+
risk_class: Optional[str] = None,
|
| 327 |
+
) -> None:
|
| 328 |
+
"""Record a contact for rate limiting."""
|
| 329 |
+
record = ContactRecord(
|
| 330 |
+
patient_id=patient_id,
|
| 331 |
+
channel=channel,
|
| 332 |
+
timestamp=datetime.now(timezone.utc),
|
| 333 |
+
wedge_type=wedge_type,
|
| 334 |
+
risk_class=risk_class,
|
| 335 |
+
)
|
| 336 |
+
self._contact_history.setdefault(patient_id, []).append(record)
|
| 337 |
+
|
| 338 |
+
# ------------------------------------------------------------------
|
| 339 |
+
# Journey step management
|
| 340 |
+
# ------------------------------------------------------------------
|
| 341 |
+
|
| 342 |
+
def get_next_step(self, enrollment_id: str) -> Optional[JourneyStep]:
|
| 343 |
+
"""Get the next scheduled step for an enrollment."""
|
| 344 |
+
enrollment = self._enrollments.get(enrollment_id)
|
| 345 |
+
if not enrollment or enrollment.status != "active":
|
| 346 |
+
return None
|
| 347 |
+
|
| 348 |
+
journey = self._journeys.get(enrollment.wedge_type, {})
|
| 349 |
+
steps = journey.get("steps", [])
|
| 350 |
+
|
| 351 |
+
if enrollment.current_step >= len(steps):
|
| 352 |
+
return None
|
| 353 |
+
|
| 354 |
+
step_data = steps[enrollment.current_step]
|
| 355 |
+
return JourneyStep(
|
| 356 |
+
step_index=enrollment.current_step,
|
| 357 |
+
step_name=step_data.get("name", f"step_{enrollment.current_step}"),
|
| 358 |
+
channel=step_data.get("channel", "voice"),
|
| 359 |
+
offset_days=step_data.get("offset_days", 0),
|
| 360 |
+
window_start_hour=step_data.get("window_start_hour", 9),
|
| 361 |
+
window_end_hour=step_data.get("window_end_hour", 20),
|
| 362 |
+
agenda_sequence=step_data.get("agenda_sequence", []),
|
| 363 |
+
conditional_probes=step_data.get("conditional_probes", []),
|
| 364 |
+
pre_populated_slots=step_data.get("pre_populated_slots", []),
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
+
def advance_step(self, enrollment_id: str) -> bool:
|
| 368 |
+
"""Mark the current step as completed and advance to next."""
|
| 369 |
+
enrollment = self._enrollments.get(enrollment_id)
|
| 370 |
+
if not enrollment or enrollment.status != "active":
|
| 371 |
+
return False
|
| 372 |
+
|
| 373 |
+
enrollment.completed_steps.append(enrollment.current_step)
|
| 374 |
+
enrollment.current_step += 1
|
| 375 |
+
enrollment.last_contact_at = datetime.now(timezone.utc)
|
| 376 |
+
|
| 377 |
+
journey = self._journeys.get(enrollment.wedge_type, {})
|
| 378 |
+
steps = journey.get("steps", [])
|
| 379 |
+
|
| 380 |
+
if enrollment.current_step >= len(steps):
|
| 381 |
+
enrollment.status = "completed"
|
| 382 |
+
logger.info("Enrollment %s completed all steps", enrollment_id)
|
| 383 |
+
return True
|
| 384 |
+
|
| 385 |
+
# Schedule next step
|
| 386 |
+
next_step = steps[enrollment.current_step]
|
| 387 |
+
offset = next_step.get("offset_days", 0)
|
| 388 |
+
enrollment.next_scheduled_at = datetime.now(timezone.utc) + timedelta(days=offset)
|
| 389 |
+
|
| 390 |
+
logger.info(
|
| 391 |
+
"Enrollment %s advanced to step %d (scheduled in %d days)",
|
| 392 |
+
enrollment_id, enrollment.current_step, offset,
|
| 393 |
+
)
|
| 394 |
+
return True
|
| 395 |
+
|
| 396 |
+
# ------------------------------------------------------------------
|
| 397 |
+
# Conflict resolution
|
| 398 |
+
# ------------------------------------------------------------------
|
| 399 |
+
|
| 400 |
+
def resolve_conflict(
|
| 401 |
+
self, patient_id: str
|
| 402 |
+
) -> Optional[str]:
|
| 403 |
+
"""
|
| 404 |
+
When multiple enrollments are due on the same day,
|
| 405 |
+
return the enrollment_id that should proceed (highest priority wins).
|
| 406 |
+
"""
|
| 407 |
+
enrollment_ids = self._patient_enrollments.get(patient_id, [])
|
| 408 |
+
active = [
|
| 409 |
+
self._enrollments[eid]
|
| 410 |
+
for eid in enrollment_ids
|
| 411 |
+
if self._enrollments.get(eid, Enrollment("", "", "", "", "")).status == "active"
|
| 412 |
+
]
|
| 413 |
+
|
| 414 |
+
if len(active) <= 1:
|
| 415 |
+
return active[0].enrollment_id if active else None
|
| 416 |
+
|
| 417 |
+
# Get priorities
|
| 418 |
+
priorities = {
|
| 419 |
+
p.get("wedge_type"): p.get("priority", 0)
|
| 420 |
+
for p in self._wedge_priority.get("priorities", [])
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
# Sort by priority descending
|
| 424 |
+
active.sort(key=lambda e: priorities.get(e.wedge_type, 0), reverse=True)
|
| 425 |
+
|
| 426 |
+
winner = active[0]
|
| 427 |
+
for loser in active[1:]:
|
| 428 |
+
if loser.next_scheduled_at and winner.next_scheduled_at:
|
| 429 |
+
if loser.next_scheduled_at.date() == winner.next_scheduled_at.date():
|
| 430 |
+
# Defer loser by 24h
|
| 431 |
+
loser.next_scheduled_at += timedelta(hours=24)
|
| 432 |
+
logger.info(
|
| 433 |
+
"Conflict resolution: %s (%s) wins over %s (%s), deferring 24h",
|
| 434 |
+
winner.wedge_type, winner.enrollment_id,
|
| 435 |
+
loser.wedge_type, loser.enrollment_id,
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
return winner.enrollment_id
|
| 439 |
+
|
| 440 |
+
# ------------------------------------------------------------------
|
| 441 |
+
# Status and listing
|
| 442 |
+
# ------------------------------------------------------------------
|
| 443 |
+
|
| 444 |
+
def get_patient_enrollments(self, patient_id: str) -> List[Enrollment]:
|
| 445 |
+
"""Get all enrollments for a patient."""
|
| 446 |
+
enrollment_ids = self._patient_enrollments.get(patient_id, [])
|
| 447 |
+
return [
|
| 448 |
+
self._enrollments[eid]
|
| 449 |
+
for eid in enrollment_ids
|
| 450 |
+
if eid in self._enrollments
|
| 451 |
+
]
|
| 452 |
+
|
| 453 |
+
def get_enrollment(self, enrollment_id: str) -> Optional[Enrollment]:
|
| 454 |
+
return self._enrollments.get(enrollment_id)
|
| 455 |
+
|
| 456 |
+
def withdraw(self, enrollment_id: str, reason: str = "") -> bool:
|
| 457 |
+
enrollment = self._enrollments.get(enrollment_id)
|
| 458 |
+
if not enrollment:
|
| 459 |
+
return False
|
| 460 |
+
enrollment.status = "withdrawn"
|
| 461 |
+
logger.info("Enrollment %s withdrawn: %s", enrollment_id, reason)
|
| 462 |
+
return True
|
| 463 |
+
|
| 464 |
+
# ------------------------------------------------------------------
|
| 465 |
+
# Internal helpers
|
| 466 |
+
# ------------------------------------------------------------------
|
| 467 |
+
|
| 468 |
+
def _get_cooldown(self, wedge_type: str) -> Optional[int]:
|
| 469 |
+
"""Get cooldown days for a wedge type."""
|
| 470 |
+
cooldowns = self._enrollment_rules.get("cooldown_days", {})
|
| 471 |
+
return cooldowns.get(wedge_type)
|
| 472 |
+
|
| 473 |
+
def _get_wedge_priority(self, wedge_type: str) -> int:
|
| 474 |
+
priorities = {
|
| 475 |
+
p.get("wedge_type"): p.get("priority", 0)
|
| 476 |
+
for p in self._wedge_priority.get("priorities", [])
|
| 477 |
+
}
|
| 478 |
+
return priorities.get(wedge_type, 0)
|
decision/engine/models.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Core data models for the Decision Engine.
|
| 3 |
+
|
| 4 |
+
All models are immutable dataclasses to prevent accidental mutation
|
| 5 |
+
during safety-critical evaluation pipelines.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import enum
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
from typing import Any, Dict, List, Optional
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------------------------------------
|
| 16 |
+
# Enums
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
class ConfidenceTier(enum.Enum):
|
| 20 |
+
"""Confidence tier for taxonomy trigger matches."""
|
| 21 |
+
HIGH = "high"
|
| 22 |
+
MEDIUM = "medium"
|
| 23 |
+
LOW = "low"
|
| 24 |
+
NONE = "none"
|
| 25 |
+
|
| 26 |
+
def __ge__(self, other: ConfidenceTier) -> bool:
|
| 27 |
+
order = {self.HIGH: 3, self.MEDIUM: 2, self.LOW: 1, self.NONE: 0}
|
| 28 |
+
return order[self] >= order[other]
|
| 29 |
+
|
| 30 |
+
def __gt__(self, other: ConfidenceTier) -> bool:
|
| 31 |
+
order = {self.HIGH: 3, self.MEDIUM: 2, self.LOW: 1, self.NONE: 0}
|
| 32 |
+
return order[self] > order[other]
|
| 33 |
+
|
| 34 |
+
def __le__(self, other: ConfidenceTier) -> bool:
|
| 35 |
+
order = {self.HIGH: 3, self.MEDIUM: 2, self.LOW: 1, self.NONE: 0}
|
| 36 |
+
return order[self] <= order[other]
|
| 37 |
+
|
| 38 |
+
def __lt__(self, other: ConfidenceTier) -> bool:
|
| 39 |
+
order = {self.HIGH: 3, self.MEDIUM: 2, self.LOW: 1, self.NONE: 0}
|
| 40 |
+
return order[self] < order[other]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class RiskClass(enum.Enum):
|
| 44 |
+
"""Clinical risk classification tier."""
|
| 45 |
+
R0 = "R0" # No clinical concern
|
| 46 |
+
R1 = "R1" # Low-level concern, routine follow-up
|
| 47 |
+
R2 = "R2" # Moderate concern, warm transfer to nurse
|
| 48 |
+
R3 = "R3" # Emergent, instruct to call 911 + transfer
|
| 49 |
+
|
| 50 |
+
@property
|
| 51 |
+
def is_emergent(self) -> bool:
|
| 52 |
+
return self == RiskClass.R3
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def requires_nurse(self) -> bool:
|
| 56 |
+
return self in (RiskClass.R2, RiskClass.R3)
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def severity_rank(self) -> int:
|
| 60 |
+
return {self.R0: 0, self.R1: 1, self.R2: 2, self.R3: 3}[self]
|
| 61 |
+
|
| 62 |
+
def __ge__(self, other: RiskClass) -> bool:
|
| 63 |
+
return self.severity_rank >= other.severity_rank
|
| 64 |
+
|
| 65 |
+
def __gt__(self, other: RiskClass) -> bool:
|
| 66 |
+
return self.severity_rank > other.severity_rank
|
| 67 |
+
|
| 68 |
+
def __le__(self, other: RiskClass) -> bool:
|
| 69 |
+
return self.severity_rank <= other.severity_rank
|
| 70 |
+
|
| 71 |
+
def __lt__(self, other: RiskClass) -> bool:
|
| 72 |
+
return self.severity_rank < other.severity_rank
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class MatchType(enum.Enum):
|
| 76 |
+
"""Type of lexical match that fired."""
|
| 77 |
+
PHRASE = "phrase"
|
| 78 |
+
REGEX = "regex"
|
| 79 |
+
PARTIAL = "partial"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class NegationAction(enum.Enum):
|
| 83 |
+
"""What to do when negation is detected."""
|
| 84 |
+
SUPPRESS = "suppress" # Remove domain nomination entirely
|
| 85 |
+
DOWNGRADE_CONFIDENCE = "downgrade_confidence" # Drop confidence one tier
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class TurnOutcome(enum.Enum):
|
| 89 |
+
"""Possible outcomes after a conversational turn."""
|
| 90 |
+
PROCEED = "proceed"
|
| 91 |
+
CLARIFY = "clarify"
|
| 92 |
+
ESCALATE = "escalate"
|
| 93 |
+
HANDOFF = "handoff"
|
| 94 |
+
END = "end"
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class FlowType(enum.Enum):
|
| 98 |
+
"""Type of conversation flow."""
|
| 99 |
+
SCREENING = "screening"
|
| 100 |
+
ESCALATION = "escalation"
|
| 101 |
+
CLARIFICATION = "clarification"
|
| 102 |
+
RESOLUTION = "resolution"
|
| 103 |
+
HANDOFF = "handoff"
|
| 104 |
+
PRIMITIVE = "primitive"
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class CallPhase(enum.Enum):
|
| 108 |
+
"""Phase of the current call."""
|
| 109 |
+
SESSION_START = "session_start"
|
| 110 |
+
ACTIVE_CALL = "active_call"
|
| 111 |
+
ENDED = "ended"
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
+
# Trigger & Match Models
|
| 116 |
+
# ---------------------------------------------------------------------------
|
| 117 |
+
|
| 118 |
+
@dataclass(frozen=True)
|
| 119 |
+
class TriggerMatch:
|
| 120 |
+
"""A single lexical match from a taxonomy trigger."""
|
| 121 |
+
domain: str
|
| 122 |
+
confidence_tier: ConfidenceTier
|
| 123 |
+
match_type: MatchType
|
| 124 |
+
matched_text: str # The phrase/pattern that matched
|
| 125 |
+
matched_span: str # The actual text span from patient input
|
| 126 |
+
priority: int # Domain priority from triggers.yaml
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@dataclass(frozen=True)
|
| 130 |
+
class NegationResult:
|
| 131 |
+
"""Result of negation analysis on a domain match."""
|
| 132 |
+
is_negated: bool
|
| 133 |
+
action: NegationAction
|
| 134 |
+
matched_pattern: str = "" # Which negation pattern matched
|
| 135 |
+
original_confidence: ConfidenceTier = ConfidenceTier.NONE
|
| 136 |
+
adjusted_confidence: ConfidenceTier = ConfidenceTier.NONE
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@dataclass(frozen=True)
|
| 140 |
+
class DomainNomination:
|
| 141 |
+
"""A nominated clinical domain with combined confidence from ML + lexical signals."""
|
| 142 |
+
domain: str
|
| 143 |
+
display_name: str
|
| 144 |
+
confidence_tier: ConfidenceTier
|
| 145 |
+
priority: int # From triggers.yaml priority field
|
| 146 |
+
target_risk_class: Optional[RiskClass] # Default risk from rules.yaml
|
| 147 |
+
trigger_matches: tuple # Tuple of TriggerMatch (frozen)
|
| 148 |
+
negation_result: Optional[NegationResult] = None
|
| 149 |
+
ml_label: Optional[str] = None # DriveHealthBERT predicted label
|
| 150 |
+
ml_confidence: Optional[float] = None # DriveHealthBERT probability
|
| 151 |
+
recommended_flow: Optional[str] = None # From rules.yaml decision_rules
|
| 152 |
+
|
| 153 |
+
@property
|
| 154 |
+
def is_negated(self) -> bool:
|
| 155 |
+
return self.negation_result is not None and self.negation_result.is_negated
|
| 156 |
+
|
| 157 |
+
@property
|
| 158 |
+
def is_safety_critical(self) -> bool:
|
| 159 |
+
"""R3 domains or hard-escalate domains."""
|
| 160 |
+
return self.target_risk_class == RiskClass.R3
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ---------------------------------------------------------------------------
|
| 164 |
+
# Risk Assessment Models
|
| 165 |
+
# ---------------------------------------------------------------------------
|
| 166 |
+
|
| 167 |
+
@dataclass(frozen=True)
|
| 168 |
+
class RiskRuleMatch:
|
| 169 |
+
"""A risk escalation rule that fired."""
|
| 170 |
+
rule_id: str
|
| 171 |
+
domain: str
|
| 172 |
+
risk_class: RiskClass
|
| 173 |
+
conditions_met: Dict[str, Any] = field(default_factory=dict)
|
| 174 |
+
context_conditions: Optional[Dict[str, Any]] = None
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@dataclass(frozen=True)
|
| 178 |
+
class SuppressionResult:
|
| 179 |
+
"""Result of domain suppression evaluation."""
|
| 180 |
+
suppressed: bool
|
| 181 |
+
suppressor_domain: Optional[str] = None
|
| 182 |
+
rule_reason: Optional[str] = None
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@dataclass(frozen=True)
|
| 186 |
+
class RiskAssessment:
|
| 187 |
+
"""Complete risk assessment for a patient utterance."""
|
| 188 |
+
# Primary result
|
| 189 |
+
risk_class: RiskClass
|
| 190 |
+
primary_domain: Optional[str]
|
| 191 |
+
# All nominated domains (ranked by priority)
|
| 192 |
+
domain_nominations: tuple # Tuple of DomainNomination
|
| 193 |
+
# Which rules fired
|
| 194 |
+
matched_rules: tuple # Tuple of RiskRuleMatch
|
| 195 |
+
# Suppression results
|
| 196 |
+
suppressions: tuple = () # Tuple of SuppressionResult
|
| 197 |
+
# Metadata
|
| 198 |
+
hard_escalate: bool = False # suicidal_ideation / homicidal_ideation
|
| 199 |
+
safety_override: bool = False # Lexical safety signal regardless of ML
|
| 200 |
+
ml_label: Optional[str] = None
|
| 201 |
+
ml_confidence: Optional[float] = None
|
| 202 |
+
recommended_flow: Optional[str] = None
|
| 203 |
+
recommended_action: Optional[TurnOutcome] = None
|
| 204 |
+
|
| 205 |
+
@property
|
| 206 |
+
def requires_911(self) -> bool:
|
| 207 |
+
return self.risk_class == RiskClass.R3
|
| 208 |
+
|
| 209 |
+
@property
|
| 210 |
+
def requires_nurse_transfer(self) -> bool:
|
| 211 |
+
return self.risk_class >= RiskClass.R2
|
| 212 |
+
|
| 213 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 214 |
+
"""Serialize for API response."""
|
| 215 |
+
return {
|
| 216 |
+
"risk_class": self.risk_class.value,
|
| 217 |
+
"primary_domain": self.primary_domain,
|
| 218 |
+
"requires_911": self.requires_911,
|
| 219 |
+
"requires_nurse_transfer": self.requires_nurse_transfer,
|
| 220 |
+
"hard_escalate": self.hard_escalate,
|
| 221 |
+
"safety_override": self.safety_override,
|
| 222 |
+
"ml_label": self.ml_label,
|
| 223 |
+
"ml_confidence": self.ml_confidence,
|
| 224 |
+
"recommended_flow": self.recommended_flow,
|
| 225 |
+
"recommended_action": self.recommended_action.value if self.recommended_action else None,
|
| 226 |
+
"domain_nominations": [
|
| 227 |
+
{
|
| 228 |
+
"domain": n.domain,
|
| 229 |
+
"confidence_tier": n.confidence_tier.value,
|
| 230 |
+
"priority": n.priority,
|
| 231 |
+
"target_risk_class": n.target_risk_class.value if n.target_risk_class else None,
|
| 232 |
+
"is_negated": n.is_negated,
|
| 233 |
+
"recommended_flow": n.recommended_flow,
|
| 234 |
+
"trigger_match_count": len(n.trigger_matches),
|
| 235 |
+
}
|
| 236 |
+
for n in self.domain_nominations
|
| 237 |
+
],
|
| 238 |
+
"matched_rules": [
|
| 239 |
+
{
|
| 240 |
+
"rule_id": r.rule_id,
|
| 241 |
+
"domain": r.domain,
|
| 242 |
+
"risk_class": r.risk_class.value,
|
| 243 |
+
}
|
| 244 |
+
for r in self.matched_rules
|
| 245 |
+
],
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
# ---------------------------------------------------------------------------
|
| 250 |
+
# Flow Execution Models (Phase 2 — stubs for forward compatibility)
|
| 251 |
+
# ---------------------------------------------------------------------------
|
| 252 |
+
|
| 253 |
+
@dataclass(frozen=True)
|
| 254 |
+
class ResponseSpec:
|
| 255 |
+
"""LLM response constraints for a flow step."""
|
| 256 |
+
spec_id: str
|
| 257 |
+
goal: str
|
| 258 |
+
tone: str
|
| 259 |
+
must_include: tuple = ()
|
| 260 |
+
must_ask: tuple = ()
|
| 261 |
+
must_not: tuple = ()
|
| 262 |
+
max_questions: int = 0
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
@dataclass(frozen=True)
|
| 266 |
+
class FlowStep:
|
| 267 |
+
"""A single step in a conversation flow."""
|
| 268 |
+
step_number: int
|
| 269 |
+
step_type: str # "ask", "respond", "handoff"
|
| 270 |
+
response_spec: ResponseSpec
|
| 271 |
+
collects: tuple = () # Slot names to extract
|
| 272 |
+
handoff_target: Optional[str] = None
|
| 273 |
+
handoff_urgency: Optional[str] = None
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
@dataclass(frozen=True)
|
| 277 |
+
class ConversationFlow:
|
| 278 |
+
"""A complete conversation flow definition."""
|
| 279 |
+
flow_id: str
|
| 280 |
+
flow_type: FlowType
|
| 281 |
+
domain: Optional[str]
|
| 282 |
+
required_slots: tuple = ()
|
| 283 |
+
steps: tuple = () # Tuple of FlowStep
|
| 284 |
+
exit_rules: Dict[str, str] = field(default_factory=dict)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
# ---------------------------------------------------------------------------
|
| 288 |
+
# Session State Models (Phase 2 — stubs for forward compatibility)
|
| 289 |
+
# ---------------------------------------------------------------------------
|
| 290 |
+
|
| 291 |
+
@dataclass
|
| 292 |
+
class SlotState:
|
| 293 |
+
"""Mutable slot tracker for an active conversation."""
|
| 294 |
+
slots: Dict[str, Any] = field(default_factory=dict)
|
| 295 |
+
|
| 296 |
+
def set_slot(self, name: str, value: Any) -> None:
|
| 297 |
+
self.slots[name] = value
|
| 298 |
+
|
| 299 |
+
def get_slot(self, name: str) -> Optional[Any]:
|
| 300 |
+
return self.slots.get(name)
|
| 301 |
+
|
| 302 |
+
def has_slot(self, name: str) -> bool:
|
| 303 |
+
return name in self.slots
|
| 304 |
+
|
| 305 |
+
def has_all(self, slot_names: List[str]) -> bool:
|
| 306 |
+
return all(name in self.slots for name in slot_names)
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
@dataclass
|
| 310 |
+
class SessionState:
|
| 311 |
+
"""Mutable state for an active call session."""
|
| 312 |
+
session_id: str
|
| 313 |
+
patient_id: Optional[str] = None
|
| 314 |
+
tenant_id: Optional[str] = None
|
| 315 |
+
call_phase: CallPhase = CallPhase.SESSION_START
|
| 316 |
+
current_flow: Optional[str] = None
|
| 317 |
+
current_step: int = 0
|
| 318 |
+
agenda_position: int = 0
|
| 319 |
+
slots: SlotState = field(default_factory=SlotState)
|
| 320 |
+
domain_history: List[str] = field(default_factory=list)
|
| 321 |
+
risk_history: List[RiskAssessment] = field(default_factory=list)
|
| 322 |
+
turn_count: int = 0
|
decision/engine/risk_classifier.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Risk Classifier.
|
| 3 |
+
|
| 4 |
+
Evaluates domain nominations against global risk escalation rules to
|
| 5 |
+
produce a final RiskAssessment with R0/R1/R2/R3 classification.
|
| 6 |
+
|
| 7 |
+
Evaluation pipeline:
|
| 8 |
+
1. Check hard-escalate domains (suicidal_ideation → always R3)
|
| 9 |
+
2. For each nominated domain, evaluate risk_escalation_rules
|
| 10 |
+
3. Apply domain suppression rules (R1 wound_concern suppresses R2 wound_infection)
|
| 11 |
+
4. Select highest risk class across all active nominations
|
| 12 |
+
5. Determine recommended action (proceed / clarify / escalate / handoff)
|
| 13 |
+
|
| 14 |
+
Safety invariants:
|
| 15 |
+
- Hard-escalate domains CANNOT be suppressed
|
| 16 |
+
- R3 can never be downgraded by suppression
|
| 17 |
+
- When no rules match, default to the domain's target_risk_class
|
| 18 |
+
- When no target_risk_class exists, default to R1 (fail-open)
|
| 19 |
+
- Context-aware rules require explicit context; they fail closed (don't fire)
|
| 20 |
+
when context is unavailable
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import logging
|
| 26 |
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
| 27 |
+
|
| 28 |
+
from decision.engine.config_loader import DecisionConfigLoader
|
| 29 |
+
from decision.engine.models import (
|
| 30 |
+
ConfidenceTier,
|
| 31 |
+
DomainNomination,
|
| 32 |
+
RiskAssessment,
|
| 33 |
+
RiskClass,
|
| 34 |
+
RiskRuleMatch,
|
| 35 |
+
SuppressionResult,
|
| 36 |
+
TurnOutcome,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
logger = logging.getLogger("decision.risk_classifier")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class RiskClassifier:
|
| 43 |
+
"""
|
| 44 |
+
Classifies risk from domain nominations using global rules.
|
| 45 |
+
|
| 46 |
+
Usage:
|
| 47 |
+
classifier = RiskClassifier(config)
|
| 48 |
+
assessment = classifier.assess(
|
| 49 |
+
nominations=nominations,
|
| 50 |
+
slot_values={"shortness_of_breath": "yes"},
|
| 51 |
+
patient_context=None, # Phase 3: FHIR data
|
| 52 |
+
)
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
def __init__(self, config: DecisionConfigLoader):
|
| 56 |
+
self._config = config
|
| 57 |
+
self._hard_escalate: Set[str] = config.hard_escalate_domains
|
| 58 |
+
self._safety_precedence: List[str] = config.safety_precedence
|
| 59 |
+
self._risk_rules: List[Dict[str, Any]] = config.risk_escalation_rules
|
| 60 |
+
self._suppression_rules: List[Dict[str, Any]] = config.domain_suppression_rules
|
| 61 |
+
|
| 62 |
+
def assess(
|
| 63 |
+
self,
|
| 64 |
+
nominations: List[DomainNomination],
|
| 65 |
+
slot_values: Optional[Dict[str, str]] = None,
|
| 66 |
+
patient_context: Optional[Dict[str, Any]] = None,
|
| 67 |
+
ml_label: Optional[str] = None,
|
| 68 |
+
ml_confidence: Optional[float] = None,
|
| 69 |
+
) -> RiskAssessment:
|
| 70 |
+
"""
|
| 71 |
+
Produce a complete risk assessment from domain nominations.
|
| 72 |
+
|
| 73 |
+
Args:
|
| 74 |
+
nominations: Ranked domain nominations from DomainNominator
|
| 75 |
+
slot_values: Currently filled slots (from flow execution)
|
| 76 |
+
patient_context: FHIR-derived patient context (Phase 3)
|
| 77 |
+
ml_label: Original DriveHealthBERT label
|
| 78 |
+
ml_confidence: Original DriveHealthBERT confidence
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
RiskAssessment with final risk class, matched rules, etc.
|
| 82 |
+
"""
|
| 83 |
+
if not nominations:
|
| 84 |
+
return self._empty_assessment(ml_label, ml_confidence)
|
| 85 |
+
|
| 86 |
+
slot_values = slot_values or {}
|
| 87 |
+
active_nominations = [n for n in nominations if not n.is_negated]
|
| 88 |
+
|
| 89 |
+
# Step 1: Check hard-escalate domains
|
| 90 |
+
hard_escalate = False
|
| 91 |
+
hard_domain = None
|
| 92 |
+
for nom in active_nominations:
|
| 93 |
+
if nom.domain in self._hard_escalate:
|
| 94 |
+
hard_escalate = True
|
| 95 |
+
hard_domain = nom.domain
|
| 96 |
+
logger.warning(
|
| 97 |
+
"HARD ESCALATE triggered: domain=%s", nom.domain
|
| 98 |
+
)
|
| 99 |
+
break
|
| 100 |
+
|
| 101 |
+
# Step 2: Evaluate risk rules for each nomination
|
| 102 |
+
all_rule_matches: List[RiskRuleMatch] = []
|
| 103 |
+
for nom in active_nominations:
|
| 104 |
+
matches = self._evaluate_rules_for_domain(
|
| 105 |
+
nom.domain, slot_values, patient_context
|
| 106 |
+
)
|
| 107 |
+
all_rule_matches.extend(matches)
|
| 108 |
+
|
| 109 |
+
# Step 3: Apply domain suppression
|
| 110 |
+
suppressions: List[SuppressionResult] = []
|
| 111 |
+
suppressed_domains: Set[str] = set()
|
| 112 |
+
if not hard_escalate:
|
| 113 |
+
suppressions, suppressed_domains = self._evaluate_suppressions(
|
| 114 |
+
active_nominations
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
# Step 4: Determine highest risk class
|
| 118 |
+
risk_class = self._determine_risk_class(
|
| 119 |
+
nominations=active_nominations,
|
| 120 |
+
rule_matches=all_rule_matches,
|
| 121 |
+
suppressed_domains=suppressed_domains,
|
| 122 |
+
hard_escalate=hard_escalate,
|
| 123 |
+
hard_domain=hard_domain,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# Step 5: Determine primary domain
|
| 127 |
+
primary_domain = self._select_primary_domain(
|
| 128 |
+
active_nominations, suppressed_domains, hard_domain
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
# Step 6: Determine recommended action
|
| 132 |
+
recommended_action = self._determine_action(risk_class)
|
| 133 |
+
|
| 134 |
+
# Step 7: Determine recommended flow from primary domain
|
| 135 |
+
recommended_flow = None
|
| 136 |
+
for nom in active_nominations:
|
| 137 |
+
if nom.domain == primary_domain and nom.recommended_flow:
|
| 138 |
+
recommended_flow = nom.recommended_flow
|
| 139 |
+
break
|
| 140 |
+
|
| 141 |
+
# Safety override flag
|
| 142 |
+
safety_override = any(
|
| 143 |
+
nom.confidence_tier >= ConfidenceTier.HIGH
|
| 144 |
+
and nom.domain in set(self._safety_precedence)
|
| 145 |
+
and nom.domain not in suppressed_domains
|
| 146 |
+
for nom in active_nominations
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
return RiskAssessment(
|
| 150 |
+
risk_class=risk_class,
|
| 151 |
+
primary_domain=primary_domain,
|
| 152 |
+
domain_nominations=tuple(nominations),
|
| 153 |
+
matched_rules=tuple(all_rule_matches),
|
| 154 |
+
suppressions=tuple(suppressions),
|
| 155 |
+
hard_escalate=hard_escalate,
|
| 156 |
+
safety_override=safety_override,
|
| 157 |
+
ml_label=ml_label,
|
| 158 |
+
ml_confidence=ml_confidence,
|
| 159 |
+
recommended_flow=recommended_flow,
|
| 160 |
+
recommended_action=recommended_action,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
# ------------------------------------------------------------------
|
| 164 |
+
# Internal evaluation
|
| 165 |
+
# ------------------------------------------------------------------
|
| 166 |
+
|
| 167 |
+
def _evaluate_rules_for_domain(
|
| 168 |
+
self,
|
| 169 |
+
domain: str,
|
| 170 |
+
slot_values: Dict[str, str],
|
| 171 |
+
patient_context: Optional[Dict[str, Any]],
|
| 172 |
+
) -> List[RiskRuleMatch]:
|
| 173 |
+
"""Evaluate all global risk rules for a specific domain."""
|
| 174 |
+
matches = []
|
| 175 |
+
|
| 176 |
+
for rule in self._risk_rules:
|
| 177 |
+
rule_id = rule.get("id", "unknown")
|
| 178 |
+
conditions = rule.get("if", {})
|
| 179 |
+
result = rule.get("then", {})
|
| 180 |
+
|
| 181 |
+
# Check domain match
|
| 182 |
+
rule_domain = conditions.get("domain")
|
| 183 |
+
if rule_domain != domain:
|
| 184 |
+
continue
|
| 185 |
+
|
| 186 |
+
# Check slot conditions
|
| 187 |
+
slots_present = conditions.get("slots_present", [])
|
| 188 |
+
slot_value_conditions = conditions.get("slot_values", {})
|
| 189 |
+
context_conditions = conditions.get("context")
|
| 190 |
+
|
| 191 |
+
# All required slots must be present
|
| 192 |
+
slots_ok = all(
|
| 193 |
+
slot_name in slot_values for slot_name in slots_present
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
# All slot value conditions must match
|
| 197 |
+
values_ok = all(
|
| 198 |
+
slot_values.get(k) == v
|
| 199 |
+
for k, v in slot_value_conditions.items()
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
# Context conditions (Phase 3)
|
| 203 |
+
context_ok = True
|
| 204 |
+
if context_conditions:
|
| 205 |
+
if patient_context is None:
|
| 206 |
+
# Fail closed: context required but not available
|
| 207 |
+
context_ok = False
|
| 208 |
+
else:
|
| 209 |
+
context_ok = self._evaluate_context_conditions(
|
| 210 |
+
context_conditions, patient_context
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
if slots_ok and values_ok and context_ok:
|
| 214 |
+
risk_str = result.get("risk_class", "R1")
|
| 215 |
+
try:
|
| 216 |
+
risk = RiskClass(risk_str)
|
| 217 |
+
except ValueError:
|
| 218 |
+
risk = RiskClass.R1
|
| 219 |
+
|
| 220 |
+
matches.append(
|
| 221 |
+
RiskRuleMatch(
|
| 222 |
+
rule_id=rule_id,
|
| 223 |
+
domain=domain,
|
| 224 |
+
risk_class=risk,
|
| 225 |
+
conditions_met=slot_value_conditions,
|
| 226 |
+
context_conditions=context_conditions,
|
| 227 |
+
)
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
return matches
|
| 231 |
+
|
| 232 |
+
def _evaluate_context_conditions(
|
| 233 |
+
self,
|
| 234 |
+
conditions: Dict[str, Any],
|
| 235 |
+
patient_context: Dict[str, Any],
|
| 236 |
+
) -> bool:
|
| 237 |
+
"""
|
| 238 |
+
Evaluate FHIR context conditions against patient context.
|
| 239 |
+
|
| 240 |
+
Phase 3: Full implementation. Currently supports:
|
| 241 |
+
- patient_has_condition: list of ICD-10 patterns
|
| 242 |
+
- medication_count_above: int threshold
|
| 243 |
+
"""
|
| 244 |
+
# patient_has_condition: check ICD-10 codes
|
| 245 |
+
required_conditions = conditions.get("patient_has_condition", [])
|
| 246 |
+
if required_conditions:
|
| 247 |
+
patient_icd_codes = patient_context.get("active_conditions", [])
|
| 248 |
+
if not self._match_icd_patterns(required_conditions, patient_icd_codes):
|
| 249 |
+
return False
|
| 250 |
+
|
| 251 |
+
# medication_count_above: check polypharmacy
|
| 252 |
+
med_threshold = conditions.get("medication_count_above")
|
| 253 |
+
if med_threshold is not None:
|
| 254 |
+
med_count = patient_context.get("active_medication_count", 0)
|
| 255 |
+
if med_count <= med_threshold:
|
| 256 |
+
return False
|
| 257 |
+
|
| 258 |
+
return True
|
| 259 |
+
|
| 260 |
+
@staticmethod
|
| 261 |
+
def _match_icd_patterns(
|
| 262 |
+
patterns: List[str], patient_codes: List[str]
|
| 263 |
+
) -> bool:
|
| 264 |
+
"""Check if any patient ICD-10 code matches any required pattern."""
|
| 265 |
+
import re
|
| 266 |
+
|
| 267 |
+
for pattern in patterns:
|
| 268 |
+
# Convert ICD-10 wildcard to regex (e.g., "I50.*" → "I50\..*")
|
| 269 |
+
regex = pattern.replace(".", r"\.").replace("*", ".*")
|
| 270 |
+
for code in patient_codes:
|
| 271 |
+
if re.match(regex, code, re.IGNORECASE):
|
| 272 |
+
return True
|
| 273 |
+
return False
|
| 274 |
+
|
| 275 |
+
def _evaluate_suppressions(
|
| 276 |
+
self, nominations: List[DomainNomination]
|
| 277 |
+
) -> Tuple[List[SuppressionResult], Set[str]]:
|
| 278 |
+
"""
|
| 279 |
+
Evaluate domain suppression rules.
|
| 280 |
+
|
| 281 |
+
A lower-acuity domain firing at high confidence can suppress
|
| 282 |
+
a higher-acuity domain at low/medium confidence to reduce
|
| 283 |
+
false escalations.
|
| 284 |
+
"""
|
| 285 |
+
suppressions: List[SuppressionResult] = []
|
| 286 |
+
suppressed: Set[str] = set()
|
| 287 |
+
|
| 288 |
+
active_domains = {n.domain: n for n in nominations}
|
| 289 |
+
|
| 290 |
+
for rule in self._suppression_rules:
|
| 291 |
+
suppressor_name = rule.get("suppressor")
|
| 292 |
+
suppressed_list = rule.get("suppressed_domains", [])
|
| 293 |
+
condition = rule.get("condition", {})
|
| 294 |
+
reason = rule.get("reason", "")
|
| 295 |
+
|
| 296 |
+
suppressor = active_domains.get(suppressor_name)
|
| 297 |
+
if not suppressor:
|
| 298 |
+
continue
|
| 299 |
+
|
| 300 |
+
# Check suppressor minimum confidence
|
| 301 |
+
min_conf_str = condition.get("suppressor_min_confidence", "medium")
|
| 302 |
+
min_conf = self._str_to_confidence(min_conf_str)
|
| 303 |
+
if suppressor.confidence_tier < min_conf:
|
| 304 |
+
continue
|
| 305 |
+
|
| 306 |
+
# Check suppressed domains
|
| 307 |
+
max_conf_str = condition.get("suppressed_max_confidence", "medium")
|
| 308 |
+
max_conf = self._str_to_confidence(max_conf_str)
|
| 309 |
+
|
| 310 |
+
for target_name in suppressed_list:
|
| 311 |
+
target = active_domains.get(target_name)
|
| 312 |
+
if not target:
|
| 313 |
+
continue
|
| 314 |
+
|
| 315 |
+
# SAFETY: Never suppress hard-escalate domains
|
| 316 |
+
if target_name in self._hard_escalate:
|
| 317 |
+
continue
|
| 318 |
+
|
| 319 |
+
# Only suppress if target is at or below max confidence
|
| 320 |
+
if target.confidence_tier <= max_conf:
|
| 321 |
+
suppressed.add(target_name)
|
| 322 |
+
suppressions.append(
|
| 323 |
+
SuppressionResult(
|
| 324 |
+
suppressed=True,
|
| 325 |
+
suppressor_domain=suppressor_name,
|
| 326 |
+
rule_reason=reason,
|
| 327 |
+
)
|
| 328 |
+
)
|
| 329 |
+
logger.info(
|
| 330 |
+
"Domain suppression: %s suppresses %s (reason: %s)",
|
| 331 |
+
suppressor_name,
|
| 332 |
+
target_name,
|
| 333 |
+
reason,
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
return suppressions, suppressed
|
| 337 |
+
|
| 338 |
+
def _determine_risk_class(
|
| 339 |
+
self,
|
| 340 |
+
nominations: List[DomainNomination],
|
| 341 |
+
rule_matches: List[RiskRuleMatch],
|
| 342 |
+
suppressed_domains: Set[str],
|
| 343 |
+
hard_escalate: bool,
|
| 344 |
+
hard_domain: Optional[str],
|
| 345 |
+
) -> RiskClass:
|
| 346 |
+
"""Determine the highest applicable risk class."""
|
| 347 |
+
|
| 348 |
+
# Hard escalate always wins
|
| 349 |
+
if hard_escalate:
|
| 350 |
+
return RiskClass.R3
|
| 351 |
+
|
| 352 |
+
# Collect all risk classes from rule matches (excluding suppressed)
|
| 353 |
+
risk_candidates: List[RiskClass] = []
|
| 354 |
+
for rm in rule_matches:
|
| 355 |
+
if rm.domain not in suppressed_domains:
|
| 356 |
+
risk_candidates.append(rm.risk_class)
|
| 357 |
+
|
| 358 |
+
# ALSO consider target_risk_class from nominations (even when rules
|
| 359 |
+
# matched for other domains). Previously this was a fallback that
|
| 360 |
+
# only ran when zero rules matched, which let a benign R1 rule on
|
| 361 |
+
# domain A mask a critical R3 target on domain B.
|
| 362 |
+
for nom in nominations:
|
| 363 |
+
if nom.domain in suppressed_domains:
|
| 364 |
+
continue
|
| 365 |
+
if nom.is_negated:
|
| 366 |
+
continue
|
| 367 |
+
if nom.target_risk_class:
|
| 368 |
+
# For high-confidence matches on safety domains, use target risk
|
| 369 |
+
if nom.confidence_tier >= ConfidenceTier.HIGH:
|
| 370 |
+
risk_candidates.append(nom.target_risk_class)
|
| 371 |
+
elif nom.confidence_tier >= ConfidenceTier.MEDIUM:
|
| 372 |
+
# Medium confidence: one tier below target, minimum R1
|
| 373 |
+
downgraded = self._downgrade_risk(nom.target_risk_class)
|
| 374 |
+
risk_candidates.append(downgraded)
|
| 375 |
+
else:
|
| 376 |
+
# Low confidence: two tiers below or R1
|
| 377 |
+
risk_candidates.append(RiskClass.R1)
|
| 378 |
+
|
| 379 |
+
if risk_candidates:
|
| 380 |
+
return max(risk_candidates)
|
| 381 |
+
|
| 382 |
+
# Absolute fallback: if we have any non-negated nomination, R1
|
| 383 |
+
if any(not n.is_negated for n in nominations):
|
| 384 |
+
return RiskClass.R1
|
| 385 |
+
|
| 386 |
+
return RiskClass.R0
|
| 387 |
+
|
| 388 |
+
def _select_primary_domain(
|
| 389 |
+
self,
|
| 390 |
+
nominations: List[DomainNomination],
|
| 391 |
+
suppressed_domains: Set[str],
|
| 392 |
+
hard_domain: Optional[str],
|
| 393 |
+
) -> Optional[str]:
|
| 394 |
+
"""Select the primary domain from active nominations."""
|
| 395 |
+
if hard_domain:
|
| 396 |
+
return hard_domain
|
| 397 |
+
|
| 398 |
+
# Use safety precedence order for tie-breaking
|
| 399 |
+
precedence_set = set(self._safety_precedence)
|
| 400 |
+
|
| 401 |
+
for nom in nominations:
|
| 402 |
+
if nom.domain in suppressed_domains:
|
| 403 |
+
continue
|
| 404 |
+
if nom.is_negated:
|
| 405 |
+
continue
|
| 406 |
+
return nom.domain # Already sorted by confidence + priority
|
| 407 |
+
|
| 408 |
+
# All negated or suppressed
|
| 409 |
+
return nominations[0].domain if nominations else None
|
| 410 |
+
|
| 411 |
+
@staticmethod
|
| 412 |
+
def _determine_action(risk_class: RiskClass) -> TurnOutcome:
|
| 413 |
+
"""Map risk class to recommended turn outcome."""
|
| 414 |
+
if risk_class == RiskClass.R3:
|
| 415 |
+
return TurnOutcome.ESCALATE
|
| 416 |
+
elif risk_class == RiskClass.R2:
|
| 417 |
+
return TurnOutcome.HANDOFF
|
| 418 |
+
elif risk_class == RiskClass.R1:
|
| 419 |
+
return TurnOutcome.PROCEED
|
| 420 |
+
return TurnOutcome.PROCEED
|
| 421 |
+
|
| 422 |
+
@staticmethod
|
| 423 |
+
def _downgrade_risk(risk: RiskClass) -> RiskClass:
|
| 424 |
+
"""Downgrade risk by one tier, minimum R1."""
|
| 425 |
+
if risk == RiskClass.R3:
|
| 426 |
+
return RiskClass.R2
|
| 427 |
+
elif risk == RiskClass.R2:
|
| 428 |
+
return RiskClass.R1
|
| 429 |
+
return RiskClass.R1
|
| 430 |
+
|
| 431 |
+
@staticmethod
|
| 432 |
+
def _str_to_confidence(s: str) -> ConfidenceTier:
|
| 433 |
+
try:
|
| 434 |
+
return ConfidenceTier(s)
|
| 435 |
+
except ValueError:
|
| 436 |
+
return ConfidenceTier.MEDIUM
|
| 437 |
+
|
| 438 |
+
def _empty_assessment(
|
| 439 |
+
self,
|
| 440 |
+
ml_label: Optional[str] = None,
|
| 441 |
+
ml_confidence: Optional[float] = None,
|
| 442 |
+
) -> RiskAssessment:
|
| 443 |
+
"""Return a default R0 assessment when no nominations exist."""
|
| 444 |
+
return RiskAssessment(
|
| 445 |
+
risk_class=RiskClass.R0,
|
| 446 |
+
primary_domain=None,
|
| 447 |
+
domain_nominations=(),
|
| 448 |
+
matched_rules=(),
|
| 449 |
+
suppressions=(),
|
| 450 |
+
hard_escalate=False,
|
| 451 |
+
safety_override=False,
|
| 452 |
+
ml_label=ml_label,
|
| 453 |
+
ml_confidence=ml_confidence,
|
| 454 |
+
recommended_flow=None,
|
| 455 |
+
recommended_action=TurnOutcome.PROCEED,
|
| 456 |
+
)
|
decision/engine/tenant_manager.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Multi-Tenant Configuration Manager (Phase 3B).
|
| 3 |
+
|
| 4 |
+
Manages tenant-specific configuration overrides including:
|
| 5 |
+
- Enabled wedges and journeys per tenant
|
| 6 |
+
- Custom escalation rules (institution-specific clinical protocols)
|
| 7 |
+
- Domain overrides (enable/disable specific domains)
|
| 8 |
+
- Institution context (nurse line, ED name, timezone, branding)
|
| 9 |
+
- FHIR data source configuration
|
| 10 |
+
- Compliance settings (audit retention, PHI logging)
|
| 11 |
+
|
| 12 |
+
Usage:
|
| 13 |
+
manager = TenantManager(config)
|
| 14 |
+
tenant = manager.get_tenant("example_health_system")
|
| 15 |
+
merged_rules = manager.get_merged_escalation_rules("example_health_system")
|
| 16 |
+
enabled_domains = manager.get_enabled_domains("example_health_system")
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import logging
|
| 22 |
+
from typing import Any, Dict, List, Optional, Set
|
| 23 |
+
|
| 24 |
+
from decision.engine.config_loader import DecisionConfigLoader
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger("decision.tenant_manager")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class TenantConfig:
|
| 30 |
+
"""Parsed tenant configuration with convenience accessors."""
|
| 31 |
+
|
| 32 |
+
def __init__(self, tenant_id: str, raw: Dict[str, Any]):
|
| 33 |
+
self.tenant_id = tenant_id
|
| 34 |
+
self._raw = raw
|
| 35 |
+
|
| 36 |
+
@property
|
| 37 |
+
def name(self) -> str:
|
| 38 |
+
return self._raw.get("institution_name", self.tenant_id)
|
| 39 |
+
|
| 40 |
+
@property
|
| 41 |
+
def type(self) -> str:
|
| 42 |
+
return self._raw.get("institution_type", "unknown")
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def enabled_wedges(self) -> List[str]:
|
| 46 |
+
return self._raw.get("enabled_wedges", [])
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def enabled_journeys(self) -> List[str]:
|
| 50 |
+
return self._raw.get("enabled_journeys", self.enabled_wedges)
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def custom_escalation_rules(self) -> List[Dict[str, Any]]:
|
| 54 |
+
return self._raw.get("custom_escalation_rules", [])
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def domain_overrides(self) -> Dict[str, Any]:
|
| 58 |
+
return self._raw.get("domain_overrides", {})
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def disabled_domains(self) -> Set[str]:
|
| 62 |
+
overrides = self.domain_overrides
|
| 63 |
+
return {
|
| 64 |
+
domain
|
| 65 |
+
for domain, cfg in overrides.items()
|
| 66 |
+
if isinstance(cfg, dict) and not cfg.get("enabled", True)
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
@property
|
| 70 |
+
def institution_context(self) -> Dict[str, Any]:
|
| 71 |
+
return self._raw.get("institution_context", {})
|
| 72 |
+
|
| 73 |
+
@property
|
| 74 |
+
def nurse_line_number(self) -> str:
|
| 75 |
+
return self.institution_context.get("nurse_line_number", "")
|
| 76 |
+
|
| 77 |
+
@property
|
| 78 |
+
def ed_name(self) -> str:
|
| 79 |
+
return self.institution_context.get("ed_name", "the emergency department")
|
| 80 |
+
|
| 81 |
+
@property
|
| 82 |
+
def timezone(self) -> str:
|
| 83 |
+
return self.institution_context.get("timezone", "America/New_York")
|
| 84 |
+
|
| 85 |
+
@property
|
| 86 |
+
def operating_hours(self) -> Dict[str, str]:
|
| 87 |
+
return self.institution_context.get("operating_hours", {})
|
| 88 |
+
|
| 89 |
+
@property
|
| 90 |
+
def fhir_config(self) -> Dict[str, Any]:
|
| 91 |
+
return self._raw.get("data_source", {}).get("fhir", {})
|
| 92 |
+
|
| 93 |
+
@property
|
| 94 |
+
def branding(self) -> Dict[str, str]:
|
| 95 |
+
return self._raw.get("branding", {})
|
| 96 |
+
|
| 97 |
+
@property
|
| 98 |
+
def greeting_name(self) -> str:
|
| 99 |
+
return self.branding.get("agent_introduction", "Avery, a care assistant")
|
| 100 |
+
|
| 101 |
+
@property
|
| 102 |
+
def compliance(self) -> Dict[str, Any]:
|
| 103 |
+
return self._raw.get("compliance", {})
|
| 104 |
+
|
| 105 |
+
@property
|
| 106 |
+
def audit_retention_years(self) -> int:
|
| 107 |
+
return self.compliance.get("audit_retention_years", 7)
|
| 108 |
+
|
| 109 |
+
def __repr__(self) -> str:
|
| 110 |
+
return f"TenantConfig({self.tenant_id}, name={self.name}, wedges={self.enabled_wedges})"
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class TenantManager:
|
| 114 |
+
"""
|
| 115 |
+
Manages multi-tenant configurations and rule merging.
|
| 116 |
+
|
| 117 |
+
Provides:
|
| 118 |
+
- Tenant lookup and validation
|
| 119 |
+
- Merged escalation rules (global + tenant custom)
|
| 120 |
+
- Domain filtering (respect tenant disabled_domains)
|
| 121 |
+
- Institution context for response generation
|
| 122 |
+
"""
|
| 123 |
+
|
| 124 |
+
def __init__(self, config: DecisionConfigLoader):
|
| 125 |
+
self._config = config
|
| 126 |
+
self._tenants: Dict[str, TenantConfig] = {}
|
| 127 |
+
|
| 128 |
+
for tid, raw in config.tenants.items():
|
| 129 |
+
self._tenants[tid] = TenantConfig(tid, raw)
|
| 130 |
+
|
| 131 |
+
logger.info("TenantManager initialized with %d tenants", len(self._tenants))
|
| 132 |
+
|
| 133 |
+
@property
|
| 134 |
+
def tenant_ids(self) -> List[str]:
|
| 135 |
+
return sorted(self._tenants.keys())
|
| 136 |
+
|
| 137 |
+
def get_tenant(self, tenant_id: str) -> Optional[TenantConfig]:
|
| 138 |
+
return self._tenants.get(tenant_id)
|
| 139 |
+
|
| 140 |
+
def get_merged_escalation_rules(
|
| 141 |
+
self, tenant_id: str
|
| 142 |
+
) -> List[Dict[str, Any]]:
|
| 143 |
+
"""
|
| 144 |
+
Merge global escalation rules with tenant-specific custom rules.
|
| 145 |
+
|
| 146 |
+
Tenant rules are appended AFTER global rules, so they take effect
|
| 147 |
+
as additional checks. Tenant rules with the same ID as global rules
|
| 148 |
+
override the global version.
|
| 149 |
+
"""
|
| 150 |
+
global_rules = list(self._config.risk_escalation_rules)
|
| 151 |
+
tenant = self._tenants.get(tenant_id)
|
| 152 |
+
if not tenant:
|
| 153 |
+
return global_rules
|
| 154 |
+
|
| 155 |
+
custom = tenant.custom_escalation_rules
|
| 156 |
+
if not custom:
|
| 157 |
+
return global_rules
|
| 158 |
+
|
| 159 |
+
# Build a map of global rules by ID
|
| 160 |
+
global_ids = {r.get("id"): i for i, r in enumerate(global_rules)}
|
| 161 |
+
|
| 162 |
+
merged = list(global_rules)
|
| 163 |
+
for rule in custom:
|
| 164 |
+
rule_id = rule.get("id")
|
| 165 |
+
if rule_id and rule_id in global_ids:
|
| 166 |
+
# Override existing rule
|
| 167 |
+
merged[global_ids[rule_id]] = rule
|
| 168 |
+
logger.info(
|
| 169 |
+
"Tenant %s overrides global rule: %s", tenant_id, rule_id
|
| 170 |
+
)
|
| 171 |
+
else:
|
| 172 |
+
# Append new rule
|
| 173 |
+
merged.append(rule)
|
| 174 |
+
logger.info(
|
| 175 |
+
"Tenant %s adds custom rule: %s", tenant_id, rule_id
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
return merged
|
| 179 |
+
|
| 180 |
+
def get_enabled_domains(
|
| 181 |
+
self, tenant_id: str, all_domains: Optional[List[str]] = None
|
| 182 |
+
) -> List[str]:
|
| 183 |
+
"""
|
| 184 |
+
Get domains enabled for a tenant (all domains minus disabled ones).
|
| 185 |
+
"""
|
| 186 |
+
if all_domains is None:
|
| 187 |
+
all_domains = self._config.domain_names
|
| 188 |
+
|
| 189 |
+
tenant = self._tenants.get(tenant_id)
|
| 190 |
+
if not tenant:
|
| 191 |
+
return all_domains
|
| 192 |
+
|
| 193 |
+
disabled = tenant.disabled_domains
|
| 194 |
+
if not disabled:
|
| 195 |
+
return all_domains
|
| 196 |
+
|
| 197 |
+
enabled = [d for d in all_domains if d not in disabled]
|
| 198 |
+
logger.info(
|
| 199 |
+
"Tenant %s: %d domains enabled (%d disabled: %s)",
|
| 200 |
+
tenant_id,
|
| 201 |
+
len(enabled),
|
| 202 |
+
len(disabled),
|
| 203 |
+
disabled,
|
| 204 |
+
)
|
| 205 |
+
return enabled
|
| 206 |
+
|
| 207 |
+
def is_wedge_enabled(self, tenant_id: str, wedge_type: str) -> bool:
|
| 208 |
+
"""Check if a wedge/journey is enabled for a tenant."""
|
| 209 |
+
tenant = self._tenants.get(tenant_id)
|
| 210 |
+
if not tenant:
|
| 211 |
+
return True # No tenant config = all enabled
|
| 212 |
+
return wedge_type in tenant.enabled_wedges
|
| 213 |
+
|
| 214 |
+
def get_institution_context_for_prompts(
|
| 215 |
+
self, tenant_id: str
|
| 216 |
+
) -> Dict[str, str]:
|
| 217 |
+
"""
|
| 218 |
+
Get institution-specific context for LLM prompt injection.
|
| 219 |
+
|
| 220 |
+
Returns strings that can be inserted into response specs.
|
| 221 |
+
"""
|
| 222 |
+
tenant = self._tenants.get(tenant_id)
|
| 223 |
+
if not tenant:
|
| 224 |
+
return {
|
| 225 |
+
"agent_name": "Avery",
|
| 226 |
+
"institution_name": "your healthcare provider",
|
| 227 |
+
"nurse_line": "the nurse line",
|
| 228 |
+
"ed_name": "the emergency department",
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
return {
|
| 232 |
+
"agent_name": "Avery",
|
| 233 |
+
"institution_name": tenant.name,
|
| 234 |
+
"nurse_line": tenant.nurse_line_number or "the nurse line",
|
| 235 |
+
"ed_name": tenant.ed_name,
|
| 236 |
+
"greeting": tenant.greeting_name,
|
| 237 |
+
}
|
decision/engine/training_data_generator.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Training Data Generator (Phase 3C).
|
| 3 |
+
|
| 4 |
+
Generates synthetic training samples from taxonomy trigger definitions
|
| 5 |
+
for model retraining and augmentation.
|
| 6 |
+
|
| 7 |
+
Each domain's triggers.yaml contains carefully curated phrases at
|
| 8 |
+
multiple confidence tiers. These can be converted into labeled training
|
| 9 |
+
examples to improve DriveHealthBERT's domain coverage.
|
| 10 |
+
|
| 11 |
+
Capabilities:
|
| 12 |
+
- Generate ESCALATION samples from R2/R3 domain triggers
|
| 13 |
+
- Generate domain-specific samples with label mapping
|
| 14 |
+
- Generate negation examples (negative samples for safety)
|
| 15 |
+
- Template-based augmentation with variations
|
| 16 |
+
- JSONL output compatible with train.py
|
| 17 |
+
|
| 18 |
+
Usage:
|
| 19 |
+
generator = TrainingDataGenerator(config)
|
| 20 |
+
samples = generator.generate_all()
|
| 21 |
+
generator.write_jsonl(samples, "data/taxonomy_augmented.jsonl")
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import json
|
| 27 |
+
import logging
|
| 28 |
+
import random
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
| 31 |
+
|
| 32 |
+
from decision.engine.config_loader import DecisionConfigLoader
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger("decision.training_data_generator")
|
| 35 |
+
|
| 36 |
+
# Map domains to DriveHealthBERT labels
|
| 37 |
+
_DOMAIN_TO_LABEL = {
|
| 38 |
+
# R3 domains → ESCALATION
|
| 39 |
+
"chest_pain": "ESCALATION",
|
| 40 |
+
"suicidal_ideation": "ESCALATION",
|
| 41 |
+
"homicidal_ideation": "ESCALATION",
|
| 42 |
+
"stroke_symptoms": "ESCALATION",
|
| 43 |
+
"seizure": "ESCALATION",
|
| 44 |
+
"severe_bleeding": "ESCALATION",
|
| 45 |
+
"anaphylaxis": "ESCALATION",
|
| 46 |
+
"airway_compromise": "ESCALATION",
|
| 47 |
+
"overdose": "ESCALATION",
|
| 48 |
+
"pregnancy_emergency": "ESCALATION",
|
| 49 |
+
"ectopic_signal": "ESCALATION",
|
| 50 |
+
"febrile_neutropenia": "ESCALATION",
|
| 51 |
+
"meningitis_signal": "ESCALATION",
|
| 52 |
+
"dka_hhs": "ESCALATION",
|
| 53 |
+
"severe_hypoglycemia": "ESCALATION",
|
| 54 |
+
"syncope": "ESCALATION",
|
| 55 |
+
"thunderclap_headache": "ESCALATION",
|
| 56 |
+
"sudden_vision_loss": "ESCALATION",
|
| 57 |
+
"acute_limb_deficit": "ESCALATION",
|
| 58 |
+
|
| 59 |
+
# R2 domains → ESCALATION (moderate, but still escalation)
|
| 60 |
+
"shortness_of_breath_rest": "ESCALATION",
|
| 61 |
+
"palpitations_dizziness": "ESCALATION",
|
| 62 |
+
"heart_failure_worsening": "ESCALATION",
|
| 63 |
+
"gi_bleed": "ESCALATION",
|
| 64 |
+
"severe_abdominal_pain": "ESCALATION",
|
| 65 |
+
"wound_infection": "ESCALATION",
|
| 66 |
+
"fall_high_risk": "ESCALATION",
|
| 67 |
+
"behavioral_crisis": "ESCALATION",
|
| 68 |
+
"acute_confusion": "ESCALATION",
|
| 69 |
+
"persistent_vomiting": "ESCALATION",
|
| 70 |
+
"post_discharge_fever": "ESCALATION",
|
| 71 |
+
"dvt_signal": "ESCALATION",
|
| 72 |
+
"npo_violation": "ESCALATION",
|
| 73 |
+
"anticoagulant_hold_failure": "ESCALATION",
|
| 74 |
+
"acute_illness_preop": "ESCALATION",
|
| 75 |
+
"worsening_depression_si": "ESCALATION",
|
| 76 |
+
"procedure_anxiety_severe": "ESCALATION",
|
| 77 |
+
"domestic_safety_concern": "ESCALATION",
|
| 78 |
+
|
| 79 |
+
# R1/R0 symptom domains → SYMPTOM_CHECK
|
| 80 |
+
"exertional_dyspnea": "SYMPTOM_CHECK",
|
| 81 |
+
"persistent_dizziness": "SYMPTOM_CHECK",
|
| 82 |
+
"abnormal_bp": "SYMPTOM_CHECK",
|
| 83 |
+
"reproducible_chest_discomfort": "SYMPTOM_CHECK",
|
| 84 |
+
"wound_concern": "SYMPTOM_CHECK",
|
| 85 |
+
"uncontrolled_pain": "SYMPTOM_CHECK",
|
| 86 |
+
"worsening_chronic_pain": "SYMPTOM_CHECK",
|
| 87 |
+
"progressive_weakness": "SYMPTOM_CHECK",
|
| 88 |
+
"urinary_retention": "SYMPTOM_CHECK",
|
| 89 |
+
"severe_diarrhea": "SYMPTOM_CHECK",
|
| 90 |
+
"poor_oral_intake": "SYMPTOM_CHECK",
|
| 91 |
+
"new_jaundice": "SYMPTOM_CHECK",
|
| 92 |
+
"glucose_out_of_range": "SYMPTOM_CHECK",
|
| 93 |
+
"recurrent_falls": "SYMPTOM_CHECK",
|
| 94 |
+
"post_procedure_incontinence": "SYMPTOM_CHECK",
|
| 95 |
+
|
| 96 |
+
# Medication domains → MEDICATION
|
| 97 |
+
"medication": "MEDICATION",
|
| 98 |
+
"medication_nonadherence": "MEDICATION",
|
| 99 |
+
|
| 100 |
+
# Scheduling domains → APPOINTMENT
|
| 101 |
+
"scheduling": "APPOINTMENT",
|
| 102 |
+
"scheduling_barrier": "APPOINTMENT",
|
| 103 |
+
"missed_followup": "APPOINTMENT",
|
| 104 |
+
"post_discharge_engagement": "APPOINTMENT",
|
| 105 |
+
|
| 106 |
+
# Screening domains → SYMPTOM_CHECK
|
| 107 |
+
"phq2_positive": "SYMPTOM_CHECK",
|
| 108 |
+
"audit_c_positive": "SYMPTOM_CHECK",
|
| 109 |
+
"screening_gap_identified": "SYMPTOM_CHECK",
|
| 110 |
+
"screening_refusal": "GENERAL_INQUIRY",
|
| 111 |
+
"cognitive_concern": "SYMPTOM_CHECK",
|
| 112 |
+
|
| 113 |
+
# Social/safety domains
|
| 114 |
+
"caregiver_safety_concern": "ESCALATION",
|
| 115 |
+
"food_insecurity": "GENERAL_INQUIRY",
|
| 116 |
+
"social_isolation": "GENERAL_INQUIRY",
|
| 117 |
+
"tobacco_use_active": "GENERAL_INQUIRY",
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
# Templates for wrapping trigger phrases into natural sentences
|
| 121 |
+
_PATIENT_TEMPLATES = [
|
| 122 |
+
"{phrase}",
|
| 123 |
+
"I have {phrase}",
|
| 124 |
+
"I'm experiencing {phrase}",
|
| 125 |
+
"I've been having {phrase}",
|
| 126 |
+
"I think I have {phrase}",
|
| 127 |
+
"My {phrase} is getting worse",
|
| 128 |
+
"I need help with {phrase}",
|
| 129 |
+
"I'm worried about {phrase}",
|
| 130 |
+
"I want to talk about {phrase}",
|
| 131 |
+
"Can you help me with {phrase}",
|
| 132 |
+
]
|
| 133 |
+
|
| 134 |
+
_NEGATION_TEMPLATES = [
|
| 135 |
+
"I don't have {phrase}",
|
| 136 |
+
"No {phrase}",
|
| 137 |
+
"I'm not experiencing {phrase}",
|
| 138 |
+
"{phrase} has gone away",
|
| 139 |
+
"I used to have {phrase} but not anymore",
|
| 140 |
+
"My mother had {phrase} not me",
|
| 141 |
+
]
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
class TrainingDataGenerator:
|
| 145 |
+
"""Generates training data from taxonomy triggers."""
|
| 146 |
+
|
| 147 |
+
def __init__(self, config: DecisionConfigLoader):
|
| 148 |
+
self._config = config
|
| 149 |
+
self._triggers = config.taxonomy_triggers
|
| 150 |
+
self._rules = config.taxonomy_rules
|
| 151 |
+
|
| 152 |
+
def generate_all(
|
| 153 |
+
self,
|
| 154 |
+
include_negations: bool = True,
|
| 155 |
+
max_per_domain: int = 50,
|
| 156 |
+
augment_templates: bool = True,
|
| 157 |
+
seed: int = 42,
|
| 158 |
+
) -> List[Dict[str, str]]:
|
| 159 |
+
"""
|
| 160 |
+
Generate training samples from all taxonomy domains.
|
| 161 |
+
|
| 162 |
+
Args:
|
| 163 |
+
include_negations: Also generate negative samples from negation patterns
|
| 164 |
+
max_per_domain: Maximum samples per domain
|
| 165 |
+
augment_templates: Apply template-based augmentation
|
| 166 |
+
seed: Random seed for reproducibility
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
List of {"text": ..., "label": ..., "source": ...} dicts
|
| 170 |
+
"""
|
| 171 |
+
random.seed(seed)
|
| 172 |
+
all_samples: List[Dict[str, str]] = []
|
| 173 |
+
|
| 174 |
+
for domain_name, triggers in self._triggers.items():
|
| 175 |
+
label = _DOMAIN_TO_LABEL.get(domain_name)
|
| 176 |
+
if not label:
|
| 177 |
+
logger.debug("Skipping domain %s (no label mapping)", domain_name)
|
| 178 |
+
continue
|
| 179 |
+
|
| 180 |
+
domain_samples = self._generate_for_domain(
|
| 181 |
+
domain_name, triggers, label, augment_templates, max_per_domain
|
| 182 |
+
)
|
| 183 |
+
all_samples.extend(domain_samples)
|
| 184 |
+
|
| 185 |
+
# Generate negation samples
|
| 186 |
+
if include_negations and label == "ESCALATION":
|
| 187 |
+
neg_samples = self._generate_negations(
|
| 188 |
+
domain_name, triggers, max_per_domain // 3
|
| 189 |
+
)
|
| 190 |
+
all_samples.extend(neg_samples)
|
| 191 |
+
|
| 192 |
+
random.shuffle(all_samples)
|
| 193 |
+
|
| 194 |
+
# Summary
|
| 195 |
+
label_counts = {}
|
| 196 |
+
for s in all_samples:
|
| 197 |
+
label_counts[s["label"]] = label_counts.get(s["label"], 0) + 1
|
| 198 |
+
|
| 199 |
+
logger.info(
|
| 200 |
+
"Generated %d training samples from %d domains: %s",
|
| 201 |
+
len(all_samples),
|
| 202 |
+
len(self._triggers),
|
| 203 |
+
label_counts,
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
return all_samples
|
| 207 |
+
|
| 208 |
+
def _generate_for_domain(
|
| 209 |
+
self,
|
| 210 |
+
domain_name: str,
|
| 211 |
+
triggers: Dict[str, Any],
|
| 212 |
+
label: str,
|
| 213 |
+
augment: bool,
|
| 214 |
+
max_samples: int,
|
| 215 |
+
) -> List[Dict[str, str]]:
|
| 216 |
+
"""Generate training samples for a single domain."""
|
| 217 |
+
samples = []
|
| 218 |
+
lexical = triggers.get("lexical_signals", {})
|
| 219 |
+
|
| 220 |
+
for tier_name in ("high", "medium", "low"):
|
| 221 |
+
tier = lexical.get(tier_name, {})
|
| 222 |
+
|
| 223 |
+
# Direct phrase samples
|
| 224 |
+
for phrase in tier.get("phrases", []):
|
| 225 |
+
samples.append({
|
| 226 |
+
"text": phrase,
|
| 227 |
+
"label": label,
|
| 228 |
+
"source": f"taxonomy/{domain_name}/{tier_name}/phrase",
|
| 229 |
+
})
|
| 230 |
+
|
| 231 |
+
# Template augmentation
|
| 232 |
+
if augment:
|
| 233 |
+
templates = random.sample(
|
| 234 |
+
_PATIENT_TEMPLATES, min(3, len(_PATIENT_TEMPLATES))
|
| 235 |
+
)
|
| 236 |
+
for template in templates:
|
| 237 |
+
try:
|
| 238 |
+
augmented = template.format(phrase=phrase.lower())
|
| 239 |
+
if augmented != phrase:
|
| 240 |
+
samples.append({
|
| 241 |
+
"text": augmented,
|
| 242 |
+
"label": label,
|
| 243 |
+
"source": f"taxonomy/{domain_name}/{tier_name}/augmented",
|
| 244 |
+
})
|
| 245 |
+
except (KeyError, IndexError):
|
| 246 |
+
pass
|
| 247 |
+
|
| 248 |
+
# Partial samples (lower confidence)
|
| 249 |
+
for partial in tier.get("partials", []):
|
| 250 |
+
samples.append({
|
| 251 |
+
"text": partial,
|
| 252 |
+
"label": label,
|
| 253 |
+
"source": f"taxonomy/{domain_name}/{tier_name}/partial",
|
| 254 |
+
})
|
| 255 |
+
|
| 256 |
+
# Deduplicate by text
|
| 257 |
+
seen = set()
|
| 258 |
+
unique = []
|
| 259 |
+
for s in samples:
|
| 260 |
+
text_lower = s["text"].lower().strip()
|
| 261 |
+
if text_lower not in seen:
|
| 262 |
+
seen.add(text_lower)
|
| 263 |
+
unique.append(s)
|
| 264 |
+
|
| 265 |
+
# Cap per domain
|
| 266 |
+
if len(unique) > max_samples:
|
| 267 |
+
unique = random.sample(unique, max_samples)
|
| 268 |
+
|
| 269 |
+
return unique
|
| 270 |
+
|
| 271 |
+
def _generate_negations(
|
| 272 |
+
self,
|
| 273 |
+
domain_name: str,
|
| 274 |
+
triggers: Dict[str, Any],
|
| 275 |
+
max_samples: int,
|
| 276 |
+
) -> List[Dict[str, str]]:
|
| 277 |
+
"""
|
| 278 |
+
Generate negative samples from negation patterns.
|
| 279 |
+
|
| 280 |
+
These become NON-ESCALATION training examples to reduce false positives.
|
| 281 |
+
"""
|
| 282 |
+
samples = []
|
| 283 |
+
negation = triggers.get("negation_handling", {})
|
| 284 |
+
patterns = negation.get("patterns", [])
|
| 285 |
+
|
| 286 |
+
# Use negation patterns directly as negative examples
|
| 287 |
+
for pattern in patterns:
|
| 288 |
+
samples.append({
|
| 289 |
+
"text": pattern,
|
| 290 |
+
"label": "GENERAL_INQUIRY", # Negated safety = not escalation
|
| 291 |
+
"source": f"taxonomy/{domain_name}/negation",
|
| 292 |
+
})
|
| 293 |
+
|
| 294 |
+
# Apply negation templates to domain phrases
|
| 295 |
+
lexical = triggers.get("lexical_signals", {})
|
| 296 |
+
high_phrases = lexical.get("high", {}).get("phrases", [])
|
| 297 |
+
|
| 298 |
+
for phrase in high_phrases[:5]: # Top 5 high-confidence phrases
|
| 299 |
+
templates = random.sample(
|
| 300 |
+
_NEGATION_TEMPLATES, min(2, len(_NEGATION_TEMPLATES))
|
| 301 |
+
)
|
| 302 |
+
for template in templates:
|
| 303 |
+
try:
|
| 304 |
+
negated = template.format(phrase=phrase.lower())
|
| 305 |
+
samples.append({
|
| 306 |
+
"text": negated,
|
| 307 |
+
"label": "GENERAL_INQUIRY",
|
| 308 |
+
"source": f"taxonomy/{domain_name}/negation_augmented",
|
| 309 |
+
})
|
| 310 |
+
except (KeyError, IndexError):
|
| 311 |
+
pass
|
| 312 |
+
|
| 313 |
+
if len(samples) > max_samples:
|
| 314 |
+
samples = random.sample(samples, max_samples)
|
| 315 |
+
|
| 316 |
+
return samples
|
| 317 |
+
|
| 318 |
+
def write_jsonl(
|
| 319 |
+
self, samples: List[Dict[str, str]], output_path: str
|
| 320 |
+
) -> int:
|
| 321 |
+
"""Write samples to JSONL file compatible with train.py."""
|
| 322 |
+
path = Path(output_path)
|
| 323 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 324 |
+
|
| 325 |
+
count = 0
|
| 326 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 327 |
+
for sample in samples:
|
| 328 |
+
record = {
|
| 329 |
+
"text": sample["text"],
|
| 330 |
+
"label": sample["label"],
|
| 331 |
+
}
|
| 332 |
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
| 333 |
+
count += 1
|
| 334 |
+
|
| 335 |
+
logger.info("Wrote %d samples to %s", count, path)
|
| 336 |
+
return count
|
| 337 |
+
|
| 338 |
+
def get_label_distribution(
|
| 339 |
+
self, samples: List[Dict[str, str]]
|
| 340 |
+
) -> Dict[str, int]:
|
| 341 |
+
"""Get label distribution of generated samples."""
|
| 342 |
+
dist: Dict[str, int] = {}
|
| 343 |
+
for s in samples:
|
| 344 |
+
dist[s["label"]] = dist.get(s["label"], 0) + 1
|
| 345 |
+
return dict(sorted(dist.items(), key=lambda x: x[1], reverse=True))
|
| 346 |
+
|
| 347 |
+
def get_domain_coverage_report(
|
| 348 |
+
self, samples: List[Dict[str, str]]
|
| 349 |
+
) -> str:
|
| 350 |
+
"""Generate a coverage report."""
|
| 351 |
+
domain_count: Dict[str, int] = {}
|
| 352 |
+
for s in samples:
|
| 353 |
+
source = s.get("source", "")
|
| 354 |
+
domain = source.split("/")[1] if "/" in source else "unknown"
|
| 355 |
+
domain_count[domain] = domain_count.get(domain, 0) + 1
|
| 356 |
+
|
| 357 |
+
lines = ["=== Training Data Coverage Report ===", ""]
|
| 358 |
+
lines.append(f"Total samples: {len(samples)}")
|
| 359 |
+
lines.append(f"Domains covered: {len(domain_count)}")
|
| 360 |
+
lines.append("")
|
| 361 |
+
|
| 362 |
+
dist = self.get_label_distribution(samples)
|
| 363 |
+
lines.append("Label distribution:")
|
| 364 |
+
for label, count in dist.items():
|
| 365 |
+
pct = count / len(samples) * 100
|
| 366 |
+
lines.append(f" {label:20s}: {count:5d} ({pct:.1f}%)")
|
| 367 |
+
|
| 368 |
+
lines.append("")
|
| 369 |
+
lines.append("Top domains by sample count:")
|
| 370 |
+
for domain, count in sorted(domain_count.items(), key=lambda x: x[1], reverse=True)[:15]:
|
| 371 |
+
lines.append(f" {domain:35s}: {count:4d}")
|
| 372 |
+
|
| 373 |
+
return "\n".join(lines)
|
decision/engine/trigger_engine.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Taxonomy Trigger Engine.
|
| 3 |
+
|
| 4 |
+
Matches patient utterances against all 65 clinical domain trigger definitions
|
| 5 |
+
using phrase matching, regex patterns, partial substring matching, and
|
| 6 |
+
negation handling.
|
| 7 |
+
|
| 8 |
+
Safety-critical: This is the last line of defense for catching clinical
|
| 9 |
+
emergencies that the ML model may miss. False negatives here can be
|
| 10 |
+
life-threatening.
|
| 11 |
+
|
| 12 |
+
Design principles:
|
| 13 |
+
- Fail open: If in doubt, nominate the domain (better to over-escalate)
|
| 14 |
+
- Exhaustive matching: Check ALL domains, not just the first match
|
| 15 |
+
- Negation requires explicit evidence: Only suppress if negation pattern
|
| 16 |
+
clearly matches
|
| 17 |
+
- Pre-compiled regex: All patterns compiled at config load time
|
| 18 |
+
- Case-insensitive matching throughout
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import logging
|
| 24 |
+
import re
|
| 25 |
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
| 26 |
+
|
| 27 |
+
from decision.engine.config_loader import DecisionConfigLoader
|
| 28 |
+
from decision.engine.models import (
|
| 29 |
+
ConfidenceTier,
|
| 30 |
+
MatchType,
|
| 31 |
+
NegationAction,
|
| 32 |
+
NegationResult,
|
| 33 |
+
TriggerMatch,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
logger = logging.getLogger("decision.trigger_engine")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class TaxonomyTriggerEngine:
|
| 40 |
+
"""
|
| 41 |
+
Matches text against taxonomy trigger definitions.
|
| 42 |
+
|
| 43 |
+
Usage:
|
| 44 |
+
engine = TaxonomyTriggerEngine(config)
|
| 45 |
+
matches = engine.match_all(text)
|
| 46 |
+
# Returns: Dict[str, DomainMatchResult] keyed by domain name
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
def __init__(self, config: DecisionConfigLoader):
|
| 50 |
+
self._config = config
|
| 51 |
+
self._domains: Dict[str, Dict[str, Any]] = config.taxonomy_triggers
|
| 52 |
+
logger.info(
|
| 53 |
+
"TaxonomyTriggerEngine initialized with %d domains", len(self._domains)
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# ------------------------------------------------------------------
|
| 57 |
+
# Public API
|
| 58 |
+
# ------------------------------------------------------------------
|
| 59 |
+
|
| 60 |
+
def match_all(self, text: str) -> Dict[str, DomainMatchResult]:
|
| 61 |
+
"""
|
| 62 |
+
Match text against ALL taxonomy domains.
|
| 63 |
+
|
| 64 |
+
Returns a dict of domain_name -> DomainMatchResult for every domain
|
| 65 |
+
that has at least one trigger match (before negation).
|
| 66 |
+
|
| 67 |
+
Negation is evaluated but does NOT remove the domain from results.
|
| 68 |
+
The caller decides what to do based on negation_result.
|
| 69 |
+
"""
|
| 70 |
+
text_lower = text.lower().strip()
|
| 71 |
+
if not text_lower:
|
| 72 |
+
return {}
|
| 73 |
+
|
| 74 |
+
results: Dict[str, DomainMatchResult] = {}
|
| 75 |
+
|
| 76 |
+
for domain_name, triggers in self._domains.items():
|
| 77 |
+
result = self._match_domain(domain_name, triggers, text, text_lower)
|
| 78 |
+
if result and result.highest_confidence != ConfidenceTier.NONE:
|
| 79 |
+
results[domain_name] = result
|
| 80 |
+
|
| 81 |
+
return results
|
| 82 |
+
|
| 83 |
+
def match_domain(self, domain_name: str, text: str) -> Optional[DomainMatchResult]:
|
| 84 |
+
"""Match text against a single domain's triggers."""
|
| 85 |
+
triggers = self._domains.get(domain_name)
|
| 86 |
+
if not triggers:
|
| 87 |
+
return None
|
| 88 |
+
text_lower = text.lower().strip()
|
| 89 |
+
return self._match_domain(domain_name, triggers, text, text_lower)
|
| 90 |
+
|
| 91 |
+
def check_negation(
|
| 92 |
+
self, domain_name: str, text: str
|
| 93 |
+
) -> NegationResult:
|
| 94 |
+
"""Check if text matches negation patterns for a domain."""
|
| 95 |
+
triggers = self._domains.get(domain_name, {})
|
| 96 |
+
text_lower = text.lower().strip()
|
| 97 |
+
return self._evaluate_negation(domain_name, triggers, text_lower)
|
| 98 |
+
|
| 99 |
+
# ------------------------------------------------------------------
|
| 100 |
+
# Internal matching
|
| 101 |
+
# ------------------------------------------------------------------
|
| 102 |
+
|
| 103 |
+
def _match_domain(
|
| 104 |
+
self,
|
| 105 |
+
domain_name: str,
|
| 106 |
+
triggers: Dict[str, Any],
|
| 107 |
+
text: str,
|
| 108 |
+
text_lower: str,
|
| 109 |
+
) -> Optional[DomainMatchResult]:
|
| 110 |
+
"""Match text against a single domain's trigger definition."""
|
| 111 |
+
lexical = triggers.get("lexical_signals", {})
|
| 112 |
+
priority = triggers.get("priority", 0)
|
| 113 |
+
|
| 114 |
+
all_matches: List[TriggerMatch] = []
|
| 115 |
+
|
| 116 |
+
# Check each confidence tier (high → medium → low)
|
| 117 |
+
for tier_name, tier_enum in [
|
| 118 |
+
("high", ConfidenceTier.HIGH),
|
| 119 |
+
("medium", ConfidenceTier.MEDIUM),
|
| 120 |
+
("low", ConfidenceTier.LOW),
|
| 121 |
+
]:
|
| 122 |
+
tier = lexical.get(tier_name, {})
|
| 123 |
+
tier_matches = self._match_tier(
|
| 124 |
+
domain_name, tier, tier_enum, text, text_lower, priority
|
| 125 |
+
)
|
| 126 |
+
all_matches.extend(tier_matches)
|
| 127 |
+
|
| 128 |
+
if not all_matches:
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
# Determine highest confidence from matches
|
| 132 |
+
highest = ConfidenceTier.NONE
|
| 133 |
+
for m in all_matches:
|
| 134 |
+
if m.confidence_tier > highest:
|
| 135 |
+
highest = m.confidence_tier
|
| 136 |
+
|
| 137 |
+
# Evaluate negation
|
| 138 |
+
negation = self._evaluate_negation(domain_name, triggers, text_lower)
|
| 139 |
+
|
| 140 |
+
# Apply negation to adjust confidence
|
| 141 |
+
effective_confidence = highest
|
| 142 |
+
if negation.is_negated:
|
| 143 |
+
if negation.action == NegationAction.SUPPRESS:
|
| 144 |
+
effective_confidence = ConfidenceTier.NONE
|
| 145 |
+
elif negation.action == NegationAction.DOWNGRADE_CONFIDENCE:
|
| 146 |
+
effective_confidence = self._downgrade_tier(highest)
|
| 147 |
+
|
| 148 |
+
return DomainMatchResult(
|
| 149 |
+
domain=domain_name,
|
| 150 |
+
priority=priority,
|
| 151 |
+
matches=tuple(all_matches),
|
| 152 |
+
highest_confidence=highest,
|
| 153 |
+
effective_confidence=effective_confidence,
|
| 154 |
+
negation_result=negation,
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
def _match_tier(
|
| 158 |
+
self,
|
| 159 |
+
domain_name: str,
|
| 160 |
+
tier: Dict[str, Any],
|
| 161 |
+
tier_enum: ConfidenceTier,
|
| 162 |
+
text: str,
|
| 163 |
+
text_lower: str,
|
| 164 |
+
priority: int,
|
| 165 |
+
) -> List[TriggerMatch]:
|
| 166 |
+
"""Match text against a single confidence tier."""
|
| 167 |
+
matches: List[TriggerMatch] = []
|
| 168 |
+
|
| 169 |
+
# 1. Phrase matching (exact substring, case-insensitive)
|
| 170 |
+
for phrase in tier.get("phrases", []):
|
| 171 |
+
phrase_lower = phrase.lower()
|
| 172 |
+
idx = text_lower.find(phrase_lower)
|
| 173 |
+
if idx >= 0:
|
| 174 |
+
matched_span = text[idx : idx + len(phrase)]
|
| 175 |
+
matches.append(
|
| 176 |
+
TriggerMatch(
|
| 177 |
+
domain=domain_name,
|
| 178 |
+
confidence_tier=tier_enum,
|
| 179 |
+
match_type=MatchType.PHRASE,
|
| 180 |
+
matched_text=phrase,
|
| 181 |
+
matched_span=matched_span,
|
| 182 |
+
priority=priority,
|
| 183 |
+
)
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
# 2. Regex matching (pre-compiled)
|
| 187 |
+
for compiled_re in tier.get("_compiled_regex", []):
|
| 188 |
+
m = compiled_re.search(text)
|
| 189 |
+
if m:
|
| 190 |
+
matches.append(
|
| 191 |
+
TriggerMatch(
|
| 192 |
+
domain=domain_name,
|
| 193 |
+
confidence_tier=tier_enum,
|
| 194 |
+
match_type=MatchType.REGEX,
|
| 195 |
+
matched_text=compiled_re.pattern,
|
| 196 |
+
matched_span=m.group(0),
|
| 197 |
+
priority=priority,
|
| 198 |
+
)
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
# 3. Partial matching (substring, case-insensitive)
|
| 202 |
+
for partial in tier.get("partials", []):
|
| 203 |
+
partial_lower = partial.lower()
|
| 204 |
+
idx = text_lower.find(partial_lower)
|
| 205 |
+
if idx >= 0:
|
| 206 |
+
matched_span = text[idx : idx + len(partial)]
|
| 207 |
+
matches.append(
|
| 208 |
+
TriggerMatch(
|
| 209 |
+
domain=domain_name,
|
| 210 |
+
confidence_tier=tier_enum,
|
| 211 |
+
match_type=MatchType.PARTIAL,
|
| 212 |
+
matched_text=partial,
|
| 213 |
+
matched_span=matched_span,
|
| 214 |
+
priority=priority,
|
| 215 |
+
)
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
return matches
|
| 219 |
+
|
| 220 |
+
def _evaluate_negation(
|
| 221 |
+
self,
|
| 222 |
+
domain_name: str,
|
| 223 |
+
triggers: Dict[str, Any],
|
| 224 |
+
text_lower: str,
|
| 225 |
+
) -> NegationResult:
|
| 226 |
+
"""
|
| 227 |
+
Evaluate negation patterns for a domain.
|
| 228 |
+
|
| 229 |
+
SAFETY DESIGN: Negation requires an EXPLICIT match against a known
|
| 230 |
+
negation pattern. We do NOT use generic "no/not" detection because
|
| 231 |
+
that risks suppressing true emergencies.
|
| 232 |
+
"""
|
| 233 |
+
negation_config = triggers.get("negation_handling", {})
|
| 234 |
+
compiled_patterns: List[str] = negation_config.get("_compiled_patterns", [])
|
| 235 |
+
action_str = negation_config.get("action", "downgrade_confidence")
|
| 236 |
+
|
| 237 |
+
try:
|
| 238 |
+
action = NegationAction(action_str)
|
| 239 |
+
except ValueError:
|
| 240 |
+
action = NegationAction.DOWNGRADE_CONFIDENCE
|
| 241 |
+
|
| 242 |
+
for pattern in compiled_patterns:
|
| 243 |
+
if pattern in text_lower:
|
| 244 |
+
return NegationResult(
|
| 245 |
+
is_negated=True,
|
| 246 |
+
action=action,
|
| 247 |
+
matched_pattern=pattern,
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
return NegationResult(
|
| 251 |
+
is_negated=False,
|
| 252 |
+
action=action,
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
@staticmethod
|
| 256 |
+
def _downgrade_tier(tier: ConfidenceTier) -> ConfidenceTier:
|
| 257 |
+
"""Downgrade confidence by one level."""
|
| 258 |
+
if tier == ConfidenceTier.HIGH:
|
| 259 |
+
return ConfidenceTier.MEDIUM
|
| 260 |
+
elif tier == ConfidenceTier.MEDIUM:
|
| 261 |
+
return ConfidenceTier.LOW
|
| 262 |
+
elif tier == ConfidenceTier.LOW:
|
| 263 |
+
return ConfidenceTier.NONE
|
| 264 |
+
return ConfidenceTier.NONE
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
# ---------------------------------------------------------------------------
|
| 268 |
+
# Domain Match Result
|
| 269 |
+
# ---------------------------------------------------------------------------
|
| 270 |
+
|
| 271 |
+
class DomainMatchResult:
|
| 272 |
+
"""
|
| 273 |
+
Result of matching a single domain against patient text.
|
| 274 |
+
|
| 275 |
+
Contains all trigger matches, the highest raw confidence,
|
| 276 |
+
effective confidence (after negation), and negation details.
|
| 277 |
+
"""
|
| 278 |
+
|
| 279 |
+
__slots__ = (
|
| 280 |
+
"domain",
|
| 281 |
+
"priority",
|
| 282 |
+
"matches",
|
| 283 |
+
"highest_confidence",
|
| 284 |
+
"effective_confidence",
|
| 285 |
+
"negation_result",
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
def __init__(
|
| 289 |
+
self,
|
| 290 |
+
domain: str,
|
| 291 |
+
priority: int,
|
| 292 |
+
matches: Tuple[TriggerMatch, ...],
|
| 293 |
+
highest_confidence: ConfidenceTier,
|
| 294 |
+
effective_confidence: ConfidenceTier,
|
| 295 |
+
negation_result: NegationResult,
|
| 296 |
+
):
|
| 297 |
+
self.domain = domain
|
| 298 |
+
self.priority = priority
|
| 299 |
+
self.matches = matches
|
| 300 |
+
self.highest_confidence = highest_confidence
|
| 301 |
+
self.effective_confidence = effective_confidence
|
| 302 |
+
self.negation_result = negation_result
|
| 303 |
+
|
| 304 |
+
@property
|
| 305 |
+
def is_negated(self) -> bool:
|
| 306 |
+
return self.negation_result.is_negated
|
| 307 |
+
|
| 308 |
+
@property
|
| 309 |
+
def is_suppressed(self) -> bool:
|
| 310 |
+
return self.effective_confidence == ConfidenceTier.NONE
|
| 311 |
+
|
| 312 |
+
@property
|
| 313 |
+
def match_count(self) -> int:
|
| 314 |
+
return len(self.matches)
|
| 315 |
+
|
| 316 |
+
def __repr__(self) -> str:
|
| 317 |
+
neg = " [NEGATED]" if self.is_negated else ""
|
| 318 |
+
return (
|
| 319 |
+
f"DomainMatchResult({self.domain}, "
|
| 320 |
+
f"confidence={self.effective_confidence.value}, "
|
| 321 |
+
f"priority={self.priority}, "
|
| 322 |
+
f"matches={self.match_count}{neg})"
|
| 323 |
+
)
|
decision/global/global_rules.yaml
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
safety_precedence:
|
| 2 |
+
# R3 domains ordered by clinical acuity
|
| 3 |
+
- chest_pain
|
| 4 |
+
- airway_compromise
|
| 5 |
+
- shortness_of_breath_rest
|
| 6 |
+
- exertional_dyspnea
|
| 7 |
+
- severe_bleeding
|
| 8 |
+
- gi_bleed
|
| 9 |
+
- stroke_symptoms
|
| 10 |
+
- acute_limb_deficit
|
| 11 |
+
- syncope
|
| 12 |
+
- anaphylaxis
|
| 13 |
+
- seizure
|
| 14 |
+
- suicidal_ideation
|
| 15 |
+
- homicidal_ideation
|
| 16 |
+
- overdose
|
| 17 |
+
- thunderclap_headache
|
| 18 |
+
- pregnancy_emergency
|
| 19 |
+
- severe_hypoglycemia
|
| 20 |
+
- dka_hhs
|
| 21 |
+
- febrile_neutropenia
|
| 22 |
+
- sudden_vision_loss
|
| 23 |
+
# R2 safety domain — domestic violence / elder abuse
|
| 24 |
+
- domestic_safety_concern
|
| 25 |
+
|
| 26 |
+
call_phases:
|
| 27 |
+
- session_start
|
| 28 |
+
- active_call
|
| 29 |
+
- ended
|
| 30 |
+
|
| 31 |
+
primitive_sequence:
|
| 32 |
+
- greeting
|
| 33 |
+
- identity_verify
|
| 34 |
+
- consent
|
| 35 |
+
|
| 36 |
+
fallback_flow: primitives/clarify
|
| 37 |
+
|
| 38 |
+
risk_classes:
|
| 39 |
+
R0: "No clinical concern identified"
|
| 40 |
+
R1: "Low-level concern, routine follow-up"
|
| 41 |
+
R2: "Moderate concern, warm transfer to nurse"
|
| 42 |
+
R3: "Emergent, instruct patient to call 911 and transfer to nurse"
|
| 43 |
+
|
| 44 |
+
risk_escalation_rules:
|
| 45 |
+
- id: chest_pain_with_sob
|
| 46 |
+
if:
|
| 47 |
+
domain: chest_pain
|
| 48 |
+
slots_present:
|
| 49 |
+
- shortness_of_breath
|
| 50 |
+
slot_values:
|
| 51 |
+
shortness_of_breath: "yes"
|
| 52 |
+
then:
|
| 53 |
+
risk_class: R3
|
| 54 |
+
|
| 55 |
+
- id: chest_pain_with_syncope
|
| 56 |
+
if:
|
| 57 |
+
domain: chest_pain
|
| 58 |
+
slots_present:
|
| 59 |
+
- syncope_or_dizziness
|
| 60 |
+
slot_values:
|
| 61 |
+
syncope_or_dizziness: "yes"
|
| 62 |
+
then:
|
| 63 |
+
risk_class: R3
|
| 64 |
+
|
| 65 |
+
- id: chest_pain_isolated
|
| 66 |
+
if:
|
| 67 |
+
domain: chest_pain
|
| 68 |
+
slots_present:
|
| 69 |
+
- chest_pain_severity
|
| 70 |
+
then:
|
| 71 |
+
risk_class: R3
|
| 72 |
+
|
| 73 |
+
- id: chest_pain_screening_clear
|
| 74 |
+
if:
|
| 75 |
+
domain: chest_pain
|
| 76 |
+
slot_values:
|
| 77 |
+
shortness_of_breath: "no"
|
| 78 |
+
syncope_or_dizziness: "no"
|
| 79 |
+
then:
|
| 80 |
+
risk_class: R3
|
| 81 |
+
|
| 82 |
+
# ── DKA/HHS ──────────────────────────────────────────────────
|
| 83 |
+
- id: dka_hhs_confirmed_nausea
|
| 84 |
+
if:
|
| 85 |
+
domain: dka_hhs
|
| 86 |
+
slots_present:
|
| 87 |
+
- nausea_vomiting
|
| 88 |
+
slot_values:
|
| 89 |
+
nausea_vomiting: "yes"
|
| 90 |
+
then:
|
| 91 |
+
risk_class: R3
|
| 92 |
+
|
| 93 |
+
- id: dka_hhs_confirmed_confusion
|
| 94 |
+
if:
|
| 95 |
+
domain: dka_hhs
|
| 96 |
+
slots_present:
|
| 97 |
+
- confusion_status
|
| 98 |
+
slot_values:
|
| 99 |
+
confusion_status: "yes"
|
| 100 |
+
then:
|
| 101 |
+
risk_class: R3
|
| 102 |
+
|
| 103 |
+
- id: dka_hhs_glucose_critical
|
| 104 |
+
if:
|
| 105 |
+
domain: dka_hhs
|
| 106 |
+
slots_present:
|
| 107 |
+
- glucose_level_bucket
|
| 108 |
+
slot_values:
|
| 109 |
+
glucose_level_bucket: "severe"
|
| 110 |
+
then:
|
| 111 |
+
risk_class: R3
|
| 112 |
+
|
| 113 |
+
- id: dka_hhs_glucose_high
|
| 114 |
+
if:
|
| 115 |
+
domain: dka_hhs
|
| 116 |
+
slots_present:
|
| 117 |
+
- glucose_level_bucket
|
| 118 |
+
slot_values:
|
| 119 |
+
glucose_level_bucket: "moderate"
|
| 120 |
+
then:
|
| 121 |
+
risk_class: R2
|
| 122 |
+
|
| 123 |
+
- id: dka_hhs_screening_clear
|
| 124 |
+
if:
|
| 125 |
+
domain: dka_hhs
|
| 126 |
+
slot_values:
|
| 127 |
+
nausea_vomiting: "no"
|
| 128 |
+
confusion_status: "no"
|
| 129 |
+
then:
|
| 130 |
+
risk_class: R2
|
| 131 |
+
|
| 132 |
+
# ── Shortness of Breath at Rest ──────────────────────────────
|
| 133 |
+
- id: sob_rest_confirmed
|
| 134 |
+
if:
|
| 135 |
+
domain: shortness_of_breath_rest
|
| 136 |
+
slots_present:
|
| 137 |
+
- sob_at_rest
|
| 138 |
+
slot_values:
|
| 139 |
+
sob_at_rest: "yes"
|
| 140 |
+
then:
|
| 141 |
+
risk_class: R3
|
| 142 |
+
|
| 143 |
+
- id: sob_rest_exertion_only
|
| 144 |
+
if:
|
| 145 |
+
domain: shortness_of_breath_rest
|
| 146 |
+
slot_values:
|
| 147 |
+
sob_at_rest: "no"
|
| 148 |
+
then:
|
| 149 |
+
risk_class: R0
|
| 150 |
+
|
| 151 |
+
# ── Wound Infection ──────────────────────────────────────────
|
| 152 |
+
- id: wound_infection_confirmed_fever
|
| 153 |
+
if:
|
| 154 |
+
domain: wound_infection
|
| 155 |
+
slots_present:
|
| 156 |
+
- fever_present
|
| 157 |
+
slot_values:
|
| 158 |
+
fever_present: "yes"
|
| 159 |
+
then:
|
| 160 |
+
risk_class: R3
|
| 161 |
+
|
| 162 |
+
- id: wound_infection_confirmed_systemic
|
| 163 |
+
if:
|
| 164 |
+
domain: wound_infection
|
| 165 |
+
slots_present:
|
| 166 |
+
- wound_systemic_signs
|
| 167 |
+
slot_values:
|
| 168 |
+
wound_systemic_signs: "yes"
|
| 169 |
+
then:
|
| 170 |
+
risk_class: R3
|
| 171 |
+
|
| 172 |
+
- id: wound_infection_screening_clear
|
| 173 |
+
if:
|
| 174 |
+
domain: wound_infection
|
| 175 |
+
slot_values:
|
| 176 |
+
fever_present: "no"
|
| 177 |
+
wound_systemic_signs: "no"
|
| 178 |
+
then:
|
| 179 |
+
risk_class: R2
|
| 180 |
+
|
| 181 |
+
# ── DVT Signal ───────────────────────────────────────────────
|
| 182 |
+
- id: dvt_signal_confirmed_swelling
|
| 183 |
+
if:
|
| 184 |
+
domain: dvt_signal
|
| 185 |
+
slots_present:
|
| 186 |
+
- leg_swelling_acute
|
| 187 |
+
slot_values:
|
| 188 |
+
leg_swelling_acute: "yes"
|
| 189 |
+
then:
|
| 190 |
+
risk_class: R2
|
| 191 |
+
|
| 192 |
+
- id: dvt_signal_confirmed_tenderness
|
| 193 |
+
if:
|
| 194 |
+
domain: dvt_signal
|
| 195 |
+
slots_present:
|
| 196 |
+
- calf_tenderness
|
| 197 |
+
slot_values:
|
| 198 |
+
calf_tenderness: "yes"
|
| 199 |
+
then:
|
| 200 |
+
risk_class: R2
|
| 201 |
+
|
| 202 |
+
- id: dvt_signal_screening_clear
|
| 203 |
+
if:
|
| 204 |
+
domain: dvt_signal
|
| 205 |
+
slot_values:
|
| 206 |
+
leg_swelling_acute: "no"
|
| 207 |
+
calf_tenderness: "no"
|
| 208 |
+
then:
|
| 209 |
+
risk_class: R0
|
| 210 |
+
|
| 211 |
+
# ── Fall High Risk ───────────────���──────────────────────────
|
| 212 |
+
- id: fall_confirmed_loc
|
| 213 |
+
if:
|
| 214 |
+
domain: fall_high_risk
|
| 215 |
+
slot_values:
|
| 216 |
+
loss_of_consciousness: "yes"
|
| 217 |
+
then:
|
| 218 |
+
risk_class: R3
|
| 219 |
+
|
| 220 |
+
- id: fall_confirmed_head_strike_anticoag
|
| 221 |
+
if:
|
| 222 |
+
domain: fall_high_risk
|
| 223 |
+
slot_values:
|
| 224 |
+
head_strike: "yes"
|
| 225 |
+
on_anticoagulant: "yes"
|
| 226 |
+
then:
|
| 227 |
+
risk_class: R3
|
| 228 |
+
|
| 229 |
+
- id: fall_confirmed_head_strike
|
| 230 |
+
if:
|
| 231 |
+
domain: fall_high_risk
|
| 232 |
+
slots_present:
|
| 233 |
+
- head_strike
|
| 234 |
+
slot_values:
|
| 235 |
+
head_strike: "yes"
|
| 236 |
+
then:
|
| 237 |
+
risk_class: R2
|
| 238 |
+
|
| 239 |
+
- id: fall_confirmed_anticoagulant
|
| 240 |
+
if:
|
| 241 |
+
domain: fall_high_risk
|
| 242 |
+
slots_present:
|
| 243 |
+
- on_anticoagulant
|
| 244 |
+
slot_values:
|
| 245 |
+
on_anticoagulant: "yes"
|
| 246 |
+
then:
|
| 247 |
+
risk_class: R2
|
| 248 |
+
|
| 249 |
+
- id: fall_screening_clear
|
| 250 |
+
if:
|
| 251 |
+
domain: fall_high_risk
|
| 252 |
+
slot_values:
|
| 253 |
+
head_strike: "no"
|
| 254 |
+
injury_present: "no"
|
| 255 |
+
then:
|
| 256 |
+
risk_class: R0
|
| 257 |
+
|
| 258 |
+
# ── Anaphylaxis ─────────────────────────────────────────────
|
| 259 |
+
- id: anaphylaxis_confirmed_swelling
|
| 260 |
+
if:
|
| 261 |
+
domain: anaphylaxis
|
| 262 |
+
slots_present:
|
| 263 |
+
- tongue_lip_swelling
|
| 264 |
+
slot_values:
|
| 265 |
+
tongue_lip_swelling: "yes"
|
| 266 |
+
then:
|
| 267 |
+
risk_class: R3
|
| 268 |
+
|
| 269 |
+
- id: anaphylaxis_confirmed_breathing
|
| 270 |
+
if:
|
| 271 |
+
domain: anaphylaxis
|
| 272 |
+
slots_present:
|
| 273 |
+
- breathing_difficulty_acute
|
| 274 |
+
slot_values:
|
| 275 |
+
breathing_difficulty_acute: "yes"
|
| 276 |
+
then:
|
| 277 |
+
risk_class: R3
|
| 278 |
+
|
| 279 |
+
- id: anaphylaxis_screening_clear
|
| 280 |
+
if:
|
| 281 |
+
domain: anaphylaxis
|
| 282 |
+
slot_values:
|
| 283 |
+
rash_hives: "no"
|
| 284 |
+
tongue_lip_swelling: "no"
|
| 285 |
+
breathing_difficulty_acute: "no"
|
| 286 |
+
then:
|
| 287 |
+
risk_class: R0
|
| 288 |
+
|
| 289 |
+
# ── Overdose ────────────────────────────────────────────────
|
| 290 |
+
- id: overdose_confirmed_intentional
|
| 291 |
+
if:
|
| 292 |
+
domain: overdose
|
| 293 |
+
slots_present:
|
| 294 |
+
- intent
|
| 295 |
+
slot_values:
|
| 296 |
+
intent: "intentional"
|
| 297 |
+
then:
|
| 298 |
+
risk_class: R3
|
| 299 |
+
|
| 300 |
+
- id: overdose_confirmed_altered
|
| 301 |
+
if:
|
| 302 |
+
domain: overdose
|
| 303 |
+
slots_present:
|
| 304 |
+
- consciousness_level
|
| 305 |
+
slot_values:
|
| 306 |
+
consciousness_level: "altered"
|
| 307 |
+
then:
|
| 308 |
+
risk_class: R3
|
| 309 |
+
|
| 310 |
+
- id: overdose_screening_clear
|
| 311 |
+
if:
|
| 312 |
+
domain: overdose
|
| 313 |
+
slot_values:
|
| 314 |
+
intent: "accidental"
|
| 315 |
+
consciousness_level: "alert"
|
| 316 |
+
then:
|
| 317 |
+
risk_class: R2
|
| 318 |
+
|
| 319 |
+
# ── Stroke Symptoms (BE-FAST) ────────────────────────────────
|
| 320 |
+
- id: stroke_confirmed_balance
|
| 321 |
+
if:
|
| 322 |
+
domain: stroke_symptoms
|
| 323 |
+
slot_values:
|
| 324 |
+
balance_deficit: "yes"
|
| 325 |
+
then:
|
| 326 |
+
risk_class: R3
|
| 327 |
+
|
| 328 |
+
- id: stroke_confirmed_vision
|
| 329 |
+
if:
|
| 330 |
+
domain: stroke_symptoms
|
| 331 |
+
slot_values:
|
| 332 |
+
vision_change: "yes"
|
| 333 |
+
then:
|
| 334 |
+
risk_class: R3
|
| 335 |
+
|
| 336 |
+
- id: stroke_confirmed_facial_droop
|
| 337 |
+
if:
|
| 338 |
+
domain: stroke_symptoms
|
| 339 |
+
slots_present:
|
| 340 |
+
- facial_droop
|
| 341 |
+
slot_values:
|
| 342 |
+
facial_droop: "yes"
|
| 343 |
+
then:
|
| 344 |
+
risk_class: R3
|
| 345 |
+
|
| 346 |
+
- id: stroke_confirmed_arm_weakness
|
| 347 |
+
if:
|
| 348 |
+
domain: stroke_symptoms
|
| 349 |
+
slots_present:
|
| 350 |
+
- arm_weakness
|
| 351 |
+
slot_values:
|
| 352 |
+
arm_weakness: "yes"
|
| 353 |
+
then:
|
| 354 |
+
risk_class: R3
|
| 355 |
+
|
| 356 |
+
- id: stroke_confirmed_speech
|
| 357 |
+
if:
|
| 358 |
+
domain: stroke_symptoms
|
| 359 |
+
slots_present:
|
| 360 |
+
- speech_slurring
|
| 361 |
+
slot_values:
|
| 362 |
+
speech_slurring: "yes"
|
| 363 |
+
then:
|
| 364 |
+
risk_class: R3
|
| 365 |
+
|
| 366 |
+
- id: stroke_screening_clear
|
| 367 |
+
if:
|
| 368 |
+
domain: stroke_symptoms
|
| 369 |
+
slot_values:
|
| 370 |
+
balance_deficit: "no"
|
| 371 |
+
vision_change: "no"
|
| 372 |
+
facial_droop: "no"
|
| 373 |
+
arm_weakness: "no"
|
| 374 |
+
speech_slurring: "no"
|
| 375 |
+
then:
|
| 376 |
+
risk_class: R0
|
| 377 |
+
|
| 378 |
+
# ── Exertional Dyspnea ──────────────────────────────────────
|
| 379 |
+
- id: exertional_dyspnea_new_onset
|
| 380 |
+
if:
|
| 381 |
+
domain: exertional_dyspnea
|
| 382 |
+
slot_values:
|
| 383 |
+
dyspnea_onset: "new"
|
| 384 |
+
then:
|
| 385 |
+
risk_class: R3
|
| 386 |
+
|
| 387 |
+
- id: exertional_dyspnea_chronic
|
| 388 |
+
if:
|
| 389 |
+
domain: exertional_dyspnea
|
| 390 |
+
slot_values:
|
| 391 |
+
dyspnea_onset: "chronic"
|
| 392 |
+
then:
|
| 393 |
+
risk_class: R1
|
| 394 |
+
|
| 395 |
+
- id: exertional_dyspnea_screening_clear
|
| 396 |
+
if:
|
| 397 |
+
domain: exertional_dyspnea
|
| 398 |
+
slot_values:
|
| 399 |
+
dyspnea_onset: "chronic"
|
| 400 |
+
then:
|
| 401 |
+
risk_class: R0
|
| 402 |
+
|
| 403 |
+
# ── Abnormal BP ────────────────────────────────────────────
|
| 404 |
+
- id: abnormal_bp_critical_threshold
|
| 405 |
+
if:
|
| 406 |
+
domain: abnormal_bp
|
| 407 |
+
slots_present:
|
| 408 |
+
- bp_critical
|
| 409 |
+
slot_values:
|
| 410 |
+
bp_critical: "yes"
|
| 411 |
+
then:
|
| 412 |
+
risk_class: R3
|
| 413 |
+
|
| 414 |
+
- id: abnormal_bp_any_report
|
| 415 |
+
if:
|
| 416 |
+
domain: abnormal_bp
|
| 417 |
+
slots_present:
|
| 418 |
+
- bp_report_confirmed
|
| 419 |
+
slot_values:
|
| 420 |
+
bp_report_confirmed: "yes"
|
| 421 |
+
then:
|
| 422 |
+
risk_class: R2
|
| 423 |
+
|
| 424 |
+
# ── Anticoagulant Hold Failure ─────────────────────────────
|
| 425 |
+
- id: anticoag_still_taking
|
| 426 |
+
if:
|
| 427 |
+
domain: anticoagulant_hold_failure
|
| 428 |
+
slots_present:
|
| 429 |
+
- anticoag_still_taking
|
| 430 |
+
slot_values:
|
| 431 |
+
anticoag_still_taking: "yes"
|
| 432 |
+
then:
|
| 433 |
+
risk_class: R2
|
| 434 |
+
|
| 435 |
+
- id: anticoag_hold_compliant
|
| 436 |
+
if:
|
| 437 |
+
domain: anticoagulant_hold_failure
|
| 438 |
+
slot_values:
|
| 439 |
+
anticoag_still_taking: "no"
|
| 440 |
+
then:
|
| 441 |
+
risk_class: R0
|
| 442 |
+
|
| 443 |
+
# ── NPO Violation ─────────────────────────────────────────
|
| 444 |
+
- id: npo_recent_intake
|
| 445 |
+
if:
|
| 446 |
+
domain: npo_violation
|
| 447 |
+
slots_present:
|
| 448 |
+
- npo_last_intake_time
|
| 449 |
+
slot_values:
|
| 450 |
+
npo_intake_recent: "yes"
|
| 451 |
+
then:
|
| 452 |
+
risk_class: R2
|
| 453 |
+
|
| 454 |
+
- id: npo_compliant
|
| 455 |
+
if:
|
| 456 |
+
domain: npo_violation
|
| 457 |
+
slot_values:
|
| 458 |
+
npo_intake_recent: "no"
|
| 459 |
+
then:
|
| 460 |
+
risk_class: R0
|
| 461 |
+
|
| 462 |
+
# ── Acute Illness Pre-Op ──────────────────────────────────
|
| 463 |
+
- id: preop_illness_fever
|
| 464 |
+
if:
|
| 465 |
+
domain: acute_illness_preop
|
| 466 |
+
slots_present:
|
| 467 |
+
- illness_fever_present
|
| 468 |
+
slot_values:
|
| 469 |
+
illness_fever_present: "yes"
|
| 470 |
+
then:
|
| 471 |
+
risk_class: R2
|
| 472 |
+
|
| 473 |
+
- id: preop_illness_respiratory
|
| 474 |
+
if:
|
| 475 |
+
domain: acute_illness_preop
|
| 476 |
+
slots_present:
|
| 477 |
+
- illness_respiratory_symptoms
|
| 478 |
+
slot_values:
|
| 479 |
+
illness_respiratory_symptoms: "yes"
|
| 480 |
+
then:
|
| 481 |
+
risk_class: R2
|
| 482 |
+
|
| 483 |
+
- id: preop_illness_gi
|
| 484 |
+
if:
|
| 485 |
+
domain: acute_illness_preop
|
| 486 |
+
slots_present:
|
| 487 |
+
- illness_gi_symptoms
|
| 488 |
+
slot_values:
|
| 489 |
+
illness_gi_symptoms: "yes"
|
| 490 |
+
then:
|
| 491 |
+
risk_class: R2
|
| 492 |
+
|
| 493 |
+
- id: preop_illness_clear
|
| 494 |
+
if:
|
| 495 |
+
domain: acute_illness_preop
|
| 496 |
+
slot_values:
|
| 497 |
+
illness_fever_present: "no"
|
| 498 |
+
illness_respiratory_symptoms: "no"
|
| 499 |
+
illness_gi_symptoms: "no"
|
| 500 |
+
then:
|
| 501 |
+
risk_class: R0
|
| 502 |
+
|
| 503 |
+
# ── Domestic Safety Concern ──────────────────────────────────
|
| 504 |
+
- id: domestic_safety_immediate_danger
|
| 505 |
+
if:
|
| 506 |
+
domain: domestic_safety_concern
|
| 507 |
+
slots_present:
|
| 508 |
+
- immediate_danger
|
| 509 |
+
slot_values:
|
| 510 |
+
immediate_danger: "yes"
|
| 511 |
+
then:
|
| 512 |
+
risk_class: R2
|
| 513 |
+
|
| 514 |
+
- id: domestic_safety_not_safe_to_talk
|
| 515 |
+
if:
|
| 516 |
+
domain: domestic_safety_concern
|
| 517 |
+
slots_present:
|
| 518 |
+
- safe_to_talk
|
| 519 |
+
slot_values:
|
| 520 |
+
safe_to_talk: "no"
|
| 521 |
+
then:
|
| 522 |
+
risk_class: R2
|
| 523 |
+
|
| 524 |
+
- id: domestic_safety_screening_clear
|
| 525 |
+
if:
|
| 526 |
+
domain: domestic_safety_concern
|
| 527 |
+
slot_values:
|
| 528 |
+
safe_to_talk: "yes"
|
| 529 |
+
immediate_danger: "no"
|
| 530 |
+
then:
|
| 531 |
+
risk_class: R0
|
| 532 |
+
|
| 533 |
+
# ── Context-aware risk rules ─────────────────────────────────────────
|
| 534 |
+
# These rules require BOTH slot conditions AND patient context conditions
|
| 535 |
+
# to fire. When patient_context is None, context conditions are treated
|
| 536 |
+
# as non-matching (fail closed).
|
| 537 |
+
|
| 538 |
+
- id: sob_chf_decompensation
|
| 539 |
+
if:
|
| 540 |
+
domain: shortness_of_breath_rest
|
| 541 |
+
slots_present:
|
| 542 |
+
- sob_severity
|
| 543 |
+
slot_values:
|
| 544 |
+
sob_severity: "moderate"
|
| 545 |
+
context:
|
| 546 |
+
patient_has_condition: ["I50.*"]
|
| 547 |
+
then:
|
| 548 |
+
risk_class: R2
|
| 549 |
+
|
| 550 |
+
- id: sob_severe_chf_decompensation
|
| 551 |
+
if:
|
| 552 |
+
domain: shortness_of_breath_rest
|
| 553 |
+
slots_present:
|
| 554 |
+
- sob_severity
|
| 555 |
+
slot_values:
|
| 556 |
+
sob_severity: "severe"
|
| 557 |
+
context:
|
| 558 |
+
patient_has_condition: ["I50.*"]
|
| 559 |
+
then:
|
| 560 |
+
risk_class: R2
|
| 561 |
+
|
| 562 |
+
- id: confusion_polypharmacy
|
| 563 |
+
if:
|
| 564 |
+
domain: altered_mental_status
|
| 565 |
+
slots_present:
|
| 566 |
+
- confusion_reported
|
| 567 |
+
slot_values:
|
| 568 |
+
confusion_reported: "yes"
|
| 569 |
+
context:
|
| 570 |
+
medication_count_above: 10
|
| 571 |
+
then:
|
| 572 |
+
risk_class: R2
|
| 573 |
+
|
| 574 |
+
- id: weight_gain_chf_fluid_overload
|
| 575 |
+
if:
|
| 576 |
+
domain: weight_gain_sudden
|
| 577 |
+
slots_present:
|
| 578 |
+
- weight_change_lbs
|
| 579 |
+
- weight_gain_significant
|
| 580 |
+
slot_values:
|
| 581 |
+
weight_gain_significant: "yes"
|
| 582 |
+
context:
|
| 583 |
+
patient_has_condition: ["I50.*"]
|
| 584 |
+
then:
|
| 585 |
+
risk_class: R2
|
| 586 |
+
|
| 587 |
+
hard_escalate_domains:
|
| 588 |
+
- suicidal_ideation
|
| 589 |
+
- homicidal_ideation
|
| 590 |
+
|
| 591 |
+
turn_outcomes:
|
| 592 |
+
- proceed
|
| 593 |
+
- clarify
|
| 594 |
+
- escalate
|
| 595 |
+
- handoff
|
| 596 |
+
- end
|
| 597 |
+
|
| 598 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 599 |
+
# Domain Suppression Rules
|
| 600 |
+
# ═══════════════════════════════════════════════════════════════��══════════════
|
| 601 |
+
# These rules allow an R1 domain firing at high confidence to suppress
|
| 602 |
+
# an R2/R3 domain, reducing false escalations.
|
| 603 |
+
# The flow engine must be updated separately to consume these rules.
|
| 604 |
+
|
| 605 |
+
domain_suppression:
|
| 606 |
+
# ── Wound domains ─────────────────────────────────────────────────────
|
| 607 |
+
# When R1 wound_concern fires at medium/high confidence, suppress R2 wound_infection
|
| 608 |
+
# at low/medium confidence (not high - genuine infection signals should still escalate)
|
| 609 |
+
- suppressor: wound_concern
|
| 610 |
+
suppressed_domains:
|
| 611 |
+
- wound_infection
|
| 612 |
+
condition:
|
| 613 |
+
suppressor_min_confidence: medium # medium or high
|
| 614 |
+
suppressed_max_confidence: medium # low or medium (not high)
|
| 615 |
+
reason: "Routine wound concern (R1) contradicts infection (R2) at lower confidence"
|
| 616 |
+
|
| 617 |
+
# ── Chest pain domains ────────────────────────────────────────────────
|
| 618 |
+
# When R1 reproducible_chest_discomfort fires, suppress R3 chest_pain at low/medium
|
| 619 |
+
- suppressor: reproducible_chest_discomfort
|
| 620 |
+
suppressed_domains:
|
| 621 |
+
- chest_pain
|
| 622 |
+
condition:
|
| 623 |
+
suppressor_min_confidence: medium
|
| 624 |
+
suppressed_max_confidence: medium
|
| 625 |
+
reason: "Reproducible pattern suggests non-ACS chest pain"
|
| 626 |
+
|
| 627 |
+
# ── Glucose domains ───────────────────────────────────────────────────
|
| 628 |
+
# When R1 glucose_out_of_range fires, suppress R3 dka_hhs at low/medium
|
| 629 |
+
- suppressor: glucose_out_of_range
|
| 630 |
+
suppressed_domains:
|
| 631 |
+
- dka_hhs
|
| 632 |
+
- severe_hypoglycemia
|
| 633 |
+
condition:
|
| 634 |
+
suppressor_min_confidence: medium
|
| 635 |
+
suppressed_max_confidence: medium
|
| 636 |
+
reason: "Routine glucose management (R1) vs emergency (R3)"
|
| 637 |
+
|
| 638 |
+
# ── Medication domains ────────────────────────────────────────────────
|
| 639 |
+
# When R1 medication fires, suppress R3 overdose at low/medium
|
| 640 |
+
- suppressor: medication
|
| 641 |
+
suppressed_domains:
|
| 642 |
+
- overdose
|
| 643 |
+
condition:
|
| 644 |
+
suppressor_min_confidence: medium
|
| 645 |
+
suppressed_max_confidence: medium
|
| 646 |
+
reason: "Medication inquiry (R1) vs overdose (R3)"
|
| 647 |
+
|
| 648 |
+
# ── Oral intake domains ───────────────────────────────────────────────
|
| 649 |
+
# When R1 poor_oral_intake fires, suppress R2 persistent_vomiting at low/medium
|
| 650 |
+
- suppressor: poor_oral_intake
|
| 651 |
+
suppressed_domains:
|
| 652 |
+
- persistent_vomiting
|
| 653 |
+
condition:
|
| 654 |
+
suppressor_min_confidence: medium
|
| 655 |
+
suppressed_max_confidence: medium
|
| 656 |
+
reason: "Appetite concern (R1) vs persistent vomiting (R2)"
|
| 657 |
+
|
| 658 |
+
# ── Pain domains ──────────────────────────────────────────────────────
|
| 659 |
+
# When R1 worsening_chronic_pain fires, suppress R2 uncontrolled_pain at low/medium
|
| 660 |
+
- suppressor: worsening_chronic_pain
|
| 661 |
+
suppressed_domains:
|
| 662 |
+
- uncontrolled_pain
|
| 663 |
+
condition:
|
| 664 |
+
suppressor_min_confidence: medium
|
| 665 |
+
suppressed_max_confidence: medium
|
| 666 |
+
reason: "Chronic pain management (R1) vs acute uncontrolled pain (R2)"
|
decision/journeys/annual_wellness_visit.yaml
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
wedge_type: awv
|
| 2 |
+
display_name: Annual Wellness Visit
|
| 3 |
+
description: >-
|
| 4 |
+
30-day annual wellness visit preparation journey. Voice calls with SMS
|
| 5 |
+
reminders. Initial outreach uses the full 12-step agenda. Subsequent calls
|
| 6 |
+
use abbreviated agendas focused on screening reminders and visit preparation.
|
| 7 |
+
default_duration_days: 30
|
| 8 |
+
steps:
|
| 9 |
+
- step_id: initial_outreach
|
| 10 |
+
channel: voice
|
| 11 |
+
schedule:
|
| 12 |
+
offset_days: 30
|
| 13 |
+
window_hours: [9, 17]
|
| 14 |
+
agenda_sequence:
|
| 15 |
+
- awv_intro
|
| 16 |
+
- health_history_update
|
| 17 |
+
- medication_list_review
|
| 18 |
+
- family_history_update
|
| 19 |
+
- functional_status
|
| 20 |
+
- fall_risk_screening
|
| 21 |
+
- screening_reminders
|
| 22 |
+
- immunization_reminders
|
| 23 |
+
- advance_directives
|
| 24 |
+
- depression_screening
|
| 25 |
+
- social_determinants
|
| 26 |
+
- closing
|
| 27 |
+
teachback_steps: []
|
| 28 |
+
max_retries: 3
|
| 29 |
+
retry_delay_hours: 2
|
| 30 |
+
|
| 31 |
+
- step_id: reminder_call
|
| 32 |
+
channel: voice
|
| 33 |
+
schedule:
|
| 34 |
+
offset_days: 7
|
| 35 |
+
window_hours: [9, 17]
|
| 36 |
+
agenda_sequence:
|
| 37 |
+
- awv_intro
|
| 38 |
+
- screening_reminders
|
| 39 |
+
- immunization_reminders
|
| 40 |
+
- social_determinants
|
| 41 |
+
- closing
|
| 42 |
+
teachback_steps: []
|
| 43 |
+
max_retries: 2
|
| 44 |
+
retry_delay_hours: 4
|
| 45 |
+
|
| 46 |
+
- step_id: visit_prep_call
|
| 47 |
+
channel: voice
|
| 48 |
+
schedule:
|
| 49 |
+
offset_days: 2
|
| 50 |
+
window_hours: [9, 17]
|
| 51 |
+
agenda_sequence:
|
| 52 |
+
- awv_intro
|
| 53 |
+
- medication_list_review
|
| 54 |
+
- screening_reminders
|
| 55 |
+
- closing
|
| 56 |
+
teachback_steps: []
|
| 57 |
+
max_retries: 2
|
| 58 |
+
retry_delay_hours: 2
|
decision/journeys/gap_closure.yaml
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
wedge_type: gap_closure
|
| 2 |
+
display_name: Gap Closure
|
| 3 |
+
description: >-
|
| 4 |
+
30-90 day care gap closure journey. SMS-first channel strategy with voice
|
| 5 |
+
call escalation for patients who do not respond to SMS reminders. SMS
|
| 6 |
+
messages are template-driven and do NOT pass through the DCE pipeline.
|
| 7 |
+
Only voice calls invoke the engine. Voice calls use a 7-step agenda
|
| 8 |
+
focused on gap identification, barrier assessment, and scheduling assistance.
|
| 9 |
+
default_duration_days: 90
|
| 10 |
+
steps:
|
| 11 |
+
# Step 1: SMS reminder (template-driven, does not invoke DCE)
|
| 12 |
+
- step_id: sms_reminder_1
|
| 13 |
+
channel: sms
|
| 14 |
+
schedule:
|
| 15 |
+
offset_days: 1
|
| 16 |
+
window_hours: [9, 20]
|
| 17 |
+
agenda_sequence: []
|
| 18 |
+
teachback_steps: []
|
| 19 |
+
max_retries: 0
|
| 20 |
+
retry_delay_hours: 0
|
| 21 |
+
|
| 22 |
+
# Step 2: SMS reminder (template-driven, does not invoke DCE)
|
| 23 |
+
- step_id: sms_reminder_2
|
| 24 |
+
channel: sms
|
| 25 |
+
schedule:
|
| 26 |
+
offset_days: 7
|
| 27 |
+
window_hours: [9, 20]
|
| 28 |
+
agenda_sequence: []
|
| 29 |
+
teachback_steps: []
|
| 30 |
+
max_retries: 0
|
| 31 |
+
retry_delay_hours: 0
|
| 32 |
+
|
| 33 |
+
# Step 3: SMS reminder (template-driven, does not invoke DCE)
|
| 34 |
+
- step_id: sms_reminder_3
|
| 35 |
+
channel: sms
|
| 36 |
+
schedule:
|
| 37 |
+
offset_days: 14
|
| 38 |
+
window_hours: [9, 20]
|
| 39 |
+
agenda_sequence: []
|
| 40 |
+
teachback_steps: []
|
| 41 |
+
max_retries: 0
|
| 42 |
+
retry_delay_hours: 0
|
| 43 |
+
|
| 44 |
+
# Step 4: First voice call — full 7-step gap closure agenda
|
| 45 |
+
- step_id: initial_outreach_call
|
| 46 |
+
channel: voice
|
| 47 |
+
schedule:
|
| 48 |
+
offset_days: 21
|
| 49 |
+
window_hours: [9, 17]
|
| 50 |
+
agenda_sequence:
|
| 51 |
+
- gap_intro
|
| 52 |
+
- gap_identification
|
| 53 |
+
- barrier_assessment
|
| 54 |
+
- motivational_support
|
| 55 |
+
- scheduling_assistance
|
| 56 |
+
- questions
|
| 57 |
+
- closing
|
| 58 |
+
teachback_steps: []
|
| 59 |
+
max_retries: 3
|
| 60 |
+
retry_delay_hours: 2
|
| 61 |
+
|
| 62 |
+
# Step 5: Follow-up voice call — abbreviated agenda
|
| 63 |
+
- step_id: follow_up_call
|
| 64 |
+
channel: voice
|
| 65 |
+
schedule:
|
| 66 |
+
offset_days: 45
|
| 67 |
+
window_hours: [9, 17]
|
| 68 |
+
agenda_sequence:
|
| 69 |
+
- gap_intro
|
| 70 |
+
- gap_identification
|
| 71 |
+
- scheduling_assistance
|
| 72 |
+
- closing
|
| 73 |
+
teachback_steps: []
|
| 74 |
+
max_retries: 2
|
| 75 |
+
retry_delay_hours: 4
|
| 76 |
+
|
| 77 |
+
# Step 6: Final voice call — last attempt
|
| 78 |
+
- step_id: final_outreach_call
|
| 79 |
+
channel: voice
|
| 80 |
+
schedule:
|
| 81 |
+
offset_days: 75
|
| 82 |
+
window_hours: [9, 17]
|
| 83 |
+
agenda_sequence:
|
| 84 |
+
- gap_intro
|
| 85 |
+
- gap_identification
|
| 86 |
+
- barrier_assessment
|
| 87 |
+
- scheduling_assistance
|
| 88 |
+
- closing
|
| 89 |
+
teachback_steps: []
|
| 90 |
+
max_retries: 2
|
| 91 |
+
retry_delay_hours: 4
|
decision/journeys/health_risk_assessment.yaml
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
wedge_type: hra
|
| 2 |
+
display_name: Health Risk Assessment
|
| 3 |
+
description: >-
|
| 4 |
+
14-day health risk assessment journey. Voice calls with SMS reminders.
|
| 5 |
+
Initial call uses the full 10-step agenda covering chronic conditions,
|
| 6 |
+
functional status, mental health screening, social determinants, and
|
| 7 |
+
preventive care review. Follow-up call uses abbreviated agenda for
|
| 8 |
+
incomplete sections.
|
| 9 |
+
default_duration_days: 14
|
| 10 |
+
steps:
|
| 11 |
+
- step_id: initial_hra_call
|
| 12 |
+
channel: voice
|
| 13 |
+
schedule:
|
| 14 |
+
offset_days: 1
|
| 15 |
+
window_hours: [9, 17]
|
| 16 |
+
agenda_sequence:
|
| 17 |
+
- hra_intro
|
| 18 |
+
- chronic_condition_inventory
|
| 19 |
+
- functional_status_assessment
|
| 20 |
+
- mental_health_screen
|
| 21 |
+
- social_determinants_screen
|
| 22 |
+
- medication_burden_review
|
| 23 |
+
- preventive_care_review
|
| 24 |
+
- care_plan_summary
|
| 25 |
+
- closing
|
| 26 |
+
- wrap_up
|
| 27 |
+
teachback_steps: []
|
| 28 |
+
max_retries: 3
|
| 29 |
+
retry_delay_hours: 2
|
| 30 |
+
|
| 31 |
+
- step_id: followup_hra_call
|
| 32 |
+
channel: voice
|
| 33 |
+
schedule:
|
| 34 |
+
offset_days: 7
|
| 35 |
+
window_hours: [9, 17]
|
| 36 |
+
agenda_sequence:
|
| 37 |
+
- hra_intro
|
| 38 |
+
- mental_health_screen
|
| 39 |
+
- social_determinants_screen
|
| 40 |
+
- preventive_care_review
|
| 41 |
+
- care_plan_summary
|
| 42 |
+
- closing
|
| 43 |
+
teachback_steps: []
|
| 44 |
+
max_retries: 2
|
| 45 |
+
retry_delay_hours: 4
|
decision/journeys/post_discharge_engagement.yaml
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
wedge_type: pde
|
| 2 |
+
display_name: Post-Discharge Engagement
|
| 3 |
+
description: >-
|
| 4 |
+
3-30 day post-discharge follow-up journey. Voice calls with SMS reminders.
|
| 5 |
+
Day 1 uses the full 14-step agenda. Subsequent calls use abbreviated agendas
|
| 6 |
+
focused on evolving patient needs.
|
| 7 |
+
default_duration_days: 30
|
| 8 |
+
steps:
|
| 9 |
+
- step_id: day1_call
|
| 10 |
+
channel: voice
|
| 11 |
+
schedule:
|
| 12 |
+
offset_days: 1
|
| 13 |
+
window_hours: [9, 17]
|
| 14 |
+
agenda_sequence:
|
| 15 |
+
- intro_permission
|
| 16 |
+
- discharge_context
|
| 17 |
+
- teachback_diagnosis
|
| 18 |
+
- teachback_warning_signs
|
| 19 |
+
- general_status
|
| 20 |
+
- symptom_review
|
| 21 |
+
- meds_reconciliation
|
| 22 |
+
- appointments_check
|
| 23 |
+
- pending_tests
|
| 24 |
+
- home_services
|
| 25 |
+
- problem_solving
|
| 26 |
+
- emergency_plan
|
| 27 |
+
- closing
|
| 28 |
+
- wrap_up
|
| 29 |
+
teachback_steps:
|
| 30 |
+
- teachback_diagnosis
|
| 31 |
+
- teachback_warning_signs
|
| 32 |
+
max_retries: 3
|
| 33 |
+
retry_delay_hours: 2
|
| 34 |
+
conditional_probes:
|
| 35 |
+
- probe_id: chf_symptom_review
|
| 36 |
+
when:
|
| 37 |
+
patient_has_condition: ["I50.*"]
|
| 38 |
+
must_ask:
|
| 39 |
+
- "Have you been weighing yourself daily as instructed?"
|
| 40 |
+
- "Have you noticed any new or worsening swelling in your legs or ankles?"
|
| 41 |
+
- "Have you had any trouble breathing when lying flat?"
|
| 42 |
+
slots_to_extract: ["daily_weight_check", "edema_worsening", "orthopnea"]
|
| 43 |
+
reason: "CHF-specific symptom monitoring per AHA guidelines"
|
| 44 |
+
- probe_id: copd_symptom_review
|
| 45 |
+
when:
|
| 46 |
+
patient_has_condition: ["J44.*", "J43.*"]
|
| 47 |
+
must_ask:
|
| 48 |
+
- "Have you noticed any change in your sputum color or amount?"
|
| 49 |
+
- "Are you using your rescue inhaler more than usual?"
|
| 50 |
+
slots_to_extract: ["sputum_change", "rescue_inhaler_frequency"]
|
| 51 |
+
reason: "COPD exacerbation early detection"
|
| 52 |
+
- probe_id: diabetes_symptom_review
|
| 53 |
+
when:
|
| 54 |
+
patient_has_condition: ["E11.*", "E10.*"]
|
| 55 |
+
must_ask:
|
| 56 |
+
- "Have you been checking your blood sugar regularly?"
|
| 57 |
+
- "Have you had any episodes of very high or very low blood sugar?"
|
| 58 |
+
slots_to_extract: ["glucose_monitoring", "glucose_extremes"]
|
| 59 |
+
reason: "Post-discharge glucose management"
|
| 60 |
+
pre_populated_slots:
|
| 61 |
+
- slot: discharge_med_count
|
| 62 |
+
source_field: discharge_info.discharge_medications
|
| 63 |
+
reason: "Pre-populate from discharge medication list"
|
| 64 |
+
|
| 65 |
+
- step_id: day3_call
|
| 66 |
+
channel: voice
|
| 67 |
+
schedule:
|
| 68 |
+
offset_days: 3
|
| 69 |
+
window_hours: [9, 17]
|
| 70 |
+
agenda_sequence:
|
| 71 |
+
- intro_permission
|
| 72 |
+
- general_status
|
| 73 |
+
- symptom_review
|
| 74 |
+
- meds_reconciliation
|
| 75 |
+
- closing
|
| 76 |
+
teachback_steps: []
|
| 77 |
+
max_retries: 2
|
| 78 |
+
retry_delay_hours: 4
|
| 79 |
+
|
| 80 |
+
- step_id: day7_call
|
| 81 |
+
channel: voice
|
| 82 |
+
schedule:
|
| 83 |
+
offset_days: 7
|
| 84 |
+
window_hours: [9, 17]
|
| 85 |
+
agenda_sequence:
|
| 86 |
+
- intro_permission
|
| 87 |
+
- appointments_check
|
| 88 |
+
- problem_solving
|
| 89 |
+
- closing
|
| 90 |
+
teachback_steps: []
|
| 91 |
+
max_retries: 2
|
| 92 |
+
retry_delay_hours: 4
|
| 93 |
+
|
| 94 |
+
- step_id: day14_call
|
| 95 |
+
channel: voice
|
| 96 |
+
schedule:
|
| 97 |
+
offset_days: 14
|
| 98 |
+
window_hours: [9, 17]
|
| 99 |
+
agenda_sequence:
|
| 100 |
+
- intro_permission
|
| 101 |
+
- general_status
|
| 102 |
+
- symptom_review
|
| 103 |
+
- closing
|
| 104 |
+
teachback_steps: []
|
| 105 |
+
max_retries: 2
|
| 106 |
+
retry_delay_hours: 4
|
| 107 |
+
|
| 108 |
+
- step_id: day30_call
|
| 109 |
+
channel: voice
|
| 110 |
+
schedule:
|
| 111 |
+
offset_days: 30
|
| 112 |
+
window_hours: [9, 17]
|
| 113 |
+
agenda_sequence:
|
| 114 |
+
- intro_permission
|
| 115 |
+
- general_status
|
| 116 |
+
- emergency_plan
|
| 117 |
+
- closing
|
| 118 |
+
teachback_steps: []
|
| 119 |
+
max_retries: 2
|
| 120 |
+
retry_delay_hours: 4
|
decision/journeys/pre_op_optimization.yaml
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
wedge_type: pre_op
|
| 2 |
+
display_name: Pre-Op Optimization
|
| 3 |
+
description: >-
|
| 4 |
+
7-90 day pre-surgery optimization journey. Voice calls with SMS reminders.
|
| 5 |
+
Initial call uses the full 10-step agenda. Subsequent calls use abbreviated
|
| 6 |
+
agendas focused on compliance verification and anxiety management.
|
| 7 |
+
default_duration_days: 90
|
| 8 |
+
steps:
|
| 9 |
+
- step_id: initial_assessment
|
| 10 |
+
channel: voice
|
| 11 |
+
schedule:
|
| 12 |
+
offset_days: 7
|
| 13 |
+
window_hours: [9, 17]
|
| 14 |
+
agenda_sequence:
|
| 15 |
+
- preop_intro
|
| 16 |
+
- health_history_update
|
| 17 |
+
- medication_review
|
| 18 |
+
- allergy_verification
|
| 19 |
+
- npo_status
|
| 20 |
+
- anxiety_screening
|
| 21 |
+
- procedure_prep
|
| 22 |
+
- logistics
|
| 23 |
+
- questions
|
| 24 |
+
- closing
|
| 25 |
+
teachback_steps:
|
| 26 |
+
- npo_status
|
| 27 |
+
max_retries: 3
|
| 28 |
+
retry_delay_hours: 2
|
| 29 |
+
conditional_probes:
|
| 30 |
+
- probe_id: anticoagulant_hold_check
|
| 31 |
+
when:
|
| 32 |
+
patient_on_medication_class: ["anticoagulant", "DOAC"]
|
| 33 |
+
must_ask:
|
| 34 |
+
- "Have you stopped taking your blood thinner as instructed?"
|
| 35 |
+
- "When did you take your last dose?"
|
| 36 |
+
slots_to_extract: ["anticoag_stopped", "last_anticoag_dose"]
|
| 37 |
+
reason: "Anticoagulant hold verification per ERAS guidelines"
|
| 38 |
+
- probe_id: glp1_hold_check
|
| 39 |
+
when:
|
| 40 |
+
patient_on_medication_class: ["GLP-1_agonist"]
|
| 41 |
+
must_ask:
|
| 42 |
+
- "Have you stopped taking your GLP-1 medication as instructed?"
|
| 43 |
+
slots_to_extract: ["glp1_stopped"]
|
| 44 |
+
reason: "GLP-1 agonist hold per updated ERAS guidelines"
|
| 45 |
+
- probe_id: insulin_management
|
| 46 |
+
when:
|
| 47 |
+
patient_on_medication_class: ["insulin"]
|
| 48 |
+
must_ask:
|
| 49 |
+
- "Have you adjusted your insulin dose as your surgeon instructed?"
|
| 50 |
+
slots_to_extract: ["insulin_adjusted"]
|
| 51 |
+
reason: "Perioperative insulin management"
|
| 52 |
+
|
| 53 |
+
- step_id: compliance_check
|
| 54 |
+
channel: voice
|
| 55 |
+
schedule:
|
| 56 |
+
offset_days: 3
|
| 57 |
+
window_hours: [9, 17]
|
| 58 |
+
agenda_sequence:
|
| 59 |
+
- preop_intro
|
| 60 |
+
- medication_review
|
| 61 |
+
- npo_status
|
| 62 |
+
- anxiety_screening
|
| 63 |
+
- closing
|
| 64 |
+
teachback_steps:
|
| 65 |
+
- npo_status
|
| 66 |
+
max_retries: 2
|
| 67 |
+
retry_delay_hours: 4
|
| 68 |
+
|
| 69 |
+
- step_id: day_before_call
|
| 70 |
+
channel: voice
|
| 71 |
+
schedule:
|
| 72 |
+
offset_days: 1
|
| 73 |
+
window_hours: [9, 17]
|
| 74 |
+
agenda_sequence:
|
| 75 |
+
- preop_intro
|
| 76 |
+
- npo_status
|
| 77 |
+
- logistics
|
| 78 |
+
- anxiety_screening
|
| 79 |
+
- closing
|
| 80 |
+
teachback_steps:
|
| 81 |
+
- npo_status
|
| 82 |
+
max_retries: 2
|
| 83 |
+
retry_delay_hours: 2
|
decision/orchestrator/conflict_resolution.yaml
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Cross-wedge conflict resolution rules.
|
| 2 |
+
# When two wedges compete for the same contact slot, these rules decide
|
| 3 |
+
# which one proceeds and what happens to the other.
|
| 4 |
+
|
| 5 |
+
conflict_rules:
|
| 6 |
+
# PDE and Pre-Op on same day: PDE wins (higher priority_rank), Pre-Op defers
|
| 7 |
+
- rule_id: pde_preop_same_day
|
| 8 |
+
wedge_a: pde
|
| 9 |
+
wedge_b: pre_op
|
| 10 |
+
condition: same_day
|
| 11 |
+
winner: higher_priority
|
| 12 |
+
loser_action: defer_24h
|
| 13 |
+
|
| 14 |
+
# Tier 1 vs Tier 2 on same day: Tier 1 always wins
|
| 15 |
+
- rule_id: tier1_beats_tier2
|
| 16 |
+
wedge_a: pde
|
| 17 |
+
wedge_b: awv
|
| 18 |
+
condition: same_day
|
| 19 |
+
winner: higher_priority
|
| 20 |
+
loser_action: defer_24h
|
| 21 |
+
|
| 22 |
+
- rule_id: tier1_beats_gap
|
| 23 |
+
wedge_a: pde
|
| 24 |
+
wedge_b: gap_closure
|
| 25 |
+
condition: same_day
|
| 26 |
+
winner: higher_priority
|
| 27 |
+
loser_action: defer_24h
|
| 28 |
+
|
| 29 |
+
- rule_id: tier1_beats_hra
|
| 30 |
+
wedge_a: pde
|
| 31 |
+
wedge_b: hra
|
| 32 |
+
condition: same_day
|
| 33 |
+
winner: higher_priority
|
| 34 |
+
loser_action: defer_24h
|
| 35 |
+
|
| 36 |
+
- rule_id: preop_beats_awv
|
| 37 |
+
wedge_a: pre_op
|
| 38 |
+
wedge_b: awv
|
| 39 |
+
condition: same_day
|
| 40 |
+
winner: higher_priority
|
| 41 |
+
loser_action: defer_24h
|
| 42 |
+
|
| 43 |
+
- rule_id: preop_beats_gap
|
| 44 |
+
wedge_a: pre_op
|
| 45 |
+
wedge_b: gap_closure
|
| 46 |
+
condition: same_day
|
| 47 |
+
winner: higher_priority
|
| 48 |
+
loser_action: defer_24h
|
| 49 |
+
|
| 50 |
+
- rule_id: preop_beats_hra
|
| 51 |
+
wedge_a: pre_op
|
| 52 |
+
wedge_b: hra
|
| 53 |
+
condition: same_day
|
| 54 |
+
winner: higher_priority
|
| 55 |
+
loser_action: defer_24h
|
| 56 |
+
|
| 57 |
+
# Within Tier 2: AWV > Gap Closure > HRA (by priority_rank)
|
| 58 |
+
- rule_id: awv_beats_gap
|
| 59 |
+
wedge_a: awv
|
| 60 |
+
wedge_b: gap_closure
|
| 61 |
+
condition: same_day
|
| 62 |
+
winner: higher_priority
|
| 63 |
+
loser_action: defer_24h
|
| 64 |
+
|
| 65 |
+
- rule_id: awv_beats_hra
|
| 66 |
+
wedge_a: awv
|
| 67 |
+
wedge_b: hra
|
| 68 |
+
condition: same_day
|
| 69 |
+
winner: higher_priority
|
| 70 |
+
loser_action: defer_24h
|
| 71 |
+
|
| 72 |
+
- rule_id: gap_beats_hra
|
| 73 |
+
wedge_a: gap_closure
|
| 74 |
+
wedge_b: hra
|
| 75 |
+
condition: same_day
|
| 76 |
+
winner: higher_priority
|
| 77 |
+
loser_action: defer_24h
|
| 78 |
+
|
| 79 |
+
default_loser_action: defer_24h
|
decision/orchestrator/contact_caps.yaml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
global_max_per_day: 2
|
| 2 |
+
global_max_per_week: 7
|
| 3 |
+
channel_caps:
|
| 4 |
+
- channel: voice
|
| 5 |
+
max_per_day: 1
|
| 6 |
+
max_per_week: 5
|
| 7 |
+
- channel: sms
|
| 8 |
+
max_per_day: 2
|
| 9 |
+
max_per_week: 10
|
| 10 |
+
- channel: email
|
| 11 |
+
max_per_day: 1
|
| 12 |
+
max_per_week: 3
|
| 13 |
+
quiet_hours:
|
| 14 |
+
start: "21:00"
|
| 15 |
+
end: "09:00"
|
| 16 |
+
emergency_override: true
|
decision/orchestrator/enrollment_rules.yaml
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Enrollment rules for journey lifecycle management.
|
| 2 |
+
# Controls eligibility, deduplication, and cooldown periods.
|
| 3 |
+
|
| 4 |
+
dedup_key_fields:
|
| 5 |
+
- patient_id
|
| 6 |
+
- wedge_type
|
| 7 |
+
- trigger_event_id
|
| 8 |
+
|
| 9 |
+
enrollment_criteria:
|
| 10 |
+
- wedge_type: pde
|
| 11 |
+
required_events:
|
| 12 |
+
- adt_discharge
|
| 13 |
+
eligible_statuses:
|
| 14 |
+
- discharged
|
| 15 |
+
cooldown_days: 30
|
| 16 |
+
max_concurrent_journeys: 1
|
| 17 |
+
|
| 18 |
+
- wedge_type: pre_op
|
| 19 |
+
required_events:
|
| 20 |
+
- surgery_scheduled
|
| 21 |
+
eligible_statuses:
|
| 22 |
+
- scheduled
|
| 23 |
+
cooldown_days: 7
|
| 24 |
+
max_concurrent_journeys: 1
|
| 25 |
+
|
| 26 |
+
- wedge_type: awv
|
| 27 |
+
required_events:
|
| 28 |
+
- awv_eligible
|
| 29 |
+
eligible_statuses:
|
| 30 |
+
- eligible
|
| 31 |
+
cooldown_days: 365
|
| 32 |
+
max_concurrent_journeys: 1
|
| 33 |
+
|
| 34 |
+
- wedge_type: gap_closure
|
| 35 |
+
required_events:
|
| 36 |
+
- gap_identified
|
| 37 |
+
eligible_statuses:
|
| 38 |
+
- open
|
| 39 |
+
cooldown_days: 90
|
| 40 |
+
max_concurrent_journeys: 3
|
| 41 |
+
|
| 42 |
+
- wedge_type: hra
|
| 43 |
+
required_events:
|
| 44 |
+
- hra_eligible
|
| 45 |
+
eligible_statuses:
|
| 46 |
+
- eligible
|
| 47 |
+
cooldown_days: 365
|
| 48 |
+
max_concurrent_journeys: 1
|
| 49 |
+
|
| 50 |
+
global_enrollment_settings:
|
| 51 |
+
max_active_journeys_per_patient: 3
|
| 52 |
+
require_consent: true
|
| 53 |
+
allow_re_enrollment: true
|
decision/orchestrator/wedge_priority.yaml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
priorities:
|
| 2 |
+
- wedge_type: pde
|
| 3 |
+
safety_tier: 1
|
| 4 |
+
priority_rank: 100
|
| 5 |
+
- wedge_type: pre_op
|
| 6 |
+
safety_tier: 1
|
| 7 |
+
priority_rank: 90
|
| 8 |
+
- wedge_type: awv
|
| 9 |
+
safety_tier: 2
|
| 10 |
+
priority_rank: 50
|
| 11 |
+
- wedge_type: gap_closure
|
| 12 |
+
safety_tier: 2
|
| 13 |
+
priority_rank: 40
|
| 14 |
+
- wedge_type: hra
|
| 15 |
+
safety_tier: 2
|
| 16 |
+
priority_rank: 30
|
decision/orchestrator/wedge_suppression.yaml
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Temporal suppression rules for cross-wedge contact orchestration.
|
| 2 |
+
# After certain events, suppress outbound contacts for specified wedges.
|
| 3 |
+
# Escalation lock suppression blocks ALL wedges for ALL channels.
|
| 4 |
+
|
| 5 |
+
suppression_rules:
|
| 6 |
+
# After any PDE voice call, suppress routine wedges for 24 hours
|
| 7 |
+
- rule_id: pde_call_suppresses_routine
|
| 8 |
+
trigger_wedge: pde
|
| 9 |
+
trigger_event: call_completed
|
| 10 |
+
suppress_wedges:
|
| 11 |
+
- awv
|
| 12 |
+
- gap_closure
|
| 13 |
+
- hra
|
| 14 |
+
suppress_channels:
|
| 15 |
+
- voice
|
| 16 |
+
- sms
|
| 17 |
+
- email
|
| 18 |
+
duration_hours: 24
|
| 19 |
+
|
| 20 |
+
# After any Pre-Op voice call, suppress routine wedges for 24 hours
|
| 21 |
+
- rule_id: preop_call_suppresses_routine
|
| 22 |
+
trigger_wedge: pre_op
|
| 23 |
+
trigger_event: call_completed
|
| 24 |
+
suppress_wedges:
|
| 25 |
+
- awv
|
| 26 |
+
- gap_closure
|
| 27 |
+
- hra
|
| 28 |
+
suppress_channels:
|
| 29 |
+
- voice
|
| 30 |
+
- sms
|
| 31 |
+
- email
|
| 32 |
+
duration_hours: 24
|
| 33 |
+
|
| 34 |
+
# After R3 escalation lock, suppress ALL wedges (all channels)
|
| 35 |
+
- rule_id: r3_lock_suppresses_all
|
| 36 |
+
trigger_wedge: any
|
| 37 |
+
trigger_event: escalation_lock_r3
|
| 38 |
+
suppress_wedges:
|
| 39 |
+
- pde
|
| 40 |
+
- pre_op
|
| 41 |
+
- awv
|
| 42 |
+
- gap_closure
|
| 43 |
+
- hra
|
| 44 |
+
suppress_channels:
|
| 45 |
+
- voice
|
| 46 |
+
- sms
|
| 47 |
+
- email
|
| 48 |
+
duration_hours: 24
|
| 49 |
+
|
| 50 |
+
# After R2 escalation lock, suppress ALL wedges (all channels)
|
| 51 |
+
- rule_id: r2_lock_suppresses_all
|
| 52 |
+
trigger_wedge: any
|
| 53 |
+
trigger_event: escalation_lock_r2
|
| 54 |
+
suppress_wedges:
|
| 55 |
+
- pde
|
| 56 |
+
- pre_op
|
| 57 |
+
- awv
|
| 58 |
+
- gap_closure
|
| 59 |
+
- hra
|
| 60 |
+
suppress_channels:
|
| 61 |
+
- voice
|
| 62 |
+
- sms
|
| 63 |
+
- email
|
| 64 |
+
duration_hours: 72
|
| 65 |
+
|
| 66 |
+
# After any voice call, suppress voice for same patient for 4 hours
|
| 67 |
+
- rule_id: voice_cooldown
|
| 68 |
+
trigger_wedge: any
|
| 69 |
+
trigger_event: call_completed
|
| 70 |
+
suppress_wedges:
|
| 71 |
+
- pde
|
| 72 |
+
- pre_op
|
| 73 |
+
- awv
|
| 74 |
+
- gap_closure
|
| 75 |
+
- hra
|
| 76 |
+
suppress_channels:
|
| 77 |
+
- voice
|
| 78 |
+
duration_hours: 4
|
decision/primitives/clarify.yaml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flow_id: clarify
|
| 2 |
+
type: primitive
|
| 3 |
+
|
| 4 |
+
actions:
|
| 5 |
+
- step: 1
|
| 6 |
+
type: ask
|
| 7 |
+
response_spec:
|
| 8 |
+
id: global_clarify
|
| 9 |
+
goal: "Ask the patient to repeat or clarify what they said"
|
| 10 |
+
tone: gentle_professional
|
| 11 |
+
must_include:
|
| 12 |
+
- "understand"
|
| 13 |
+
must_ask:
|
| 14 |
+
- "could you say that again"
|
| 15 |
+
must_not:
|
| 16 |
+
- "diagnos"
|
| 17 |
+
max_questions: 1
|
| 18 |
+
collects:
|
| 19 |
+
- clarification_text
|
| 20 |
+
|
| 21 |
+
exit:
|
| 22 |
+
on_complete: re_evaluate
|
| 23 |
+
on_unclear: handoff
|