That guy James Bond :) commited on
Commit
af61b34
·
1 Parent(s): 8309405

Deploy Medical Intent Escalation API

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +2 -1
  2. .gitignore +3 -0
  3. Dockerfile +31 -0
  4. README.md +26 -5
  5. artifacts/config/decision_policy.json +71 -0
  6. artifacts/onnx_int8/config.json +44 -0
  7. artifacts/onnx_int8/decision_policy.json +71 -0
  8. artifacts/onnx_int8/export_metadata.json +9 -0
  9. artifacts/onnx_int8/label_mappings.json +18 -0
  10. artifacts/onnx_int8/model.onnx +3 -0
  11. artifacts/onnx_int8/model.onnx.data +3 -0
  12. artifacts/onnx_int8/model_preprocessed.onnx +3 -0
  13. artifacts/onnx_int8/quantization_metadata.json +12 -0
  14. artifacts/onnx_int8/special_tokens_map.json +37 -0
  15. artifacts/onnx_int8/tokenizer.json +0 -0
  16. artifacts/onnx_int8/tokenizer_config.json +14 -0
  17. artifacts/onnx_int8/training_metadata.json +21 -0
  18. artifacts/onnx_int8/vocab.txt +0 -0
  19. config/streaming_intent.yaml +117 -0
  20. config/train_config.yaml +87 -0
  21. config/train_config_augmented.yaml +80 -0
  22. config/train_config_v2.yaml +69 -0
  23. config/train_config_v3.yaml +74 -0
  24. config/train_config_v4.yaml +74 -0
  25. config/train_config_v5.yaml +71 -0
  26. config/train_config_v6.yaml +75 -0
  27. decision/context/fhir_mappings.yaml +61 -0
  28. decision/engine/__init__.py +55 -0
  29. decision/engine/config_loader.py +472 -0
  30. decision/engine/domain_nomination.py +326 -0
  31. decision/engine/fhir_context.py +506 -0
  32. decision/engine/flow_engine.py +763 -0
  33. decision/engine/journey_orchestrator.py +478 -0
  34. decision/engine/models.py +322 -0
  35. decision/engine/risk_classifier.py +456 -0
  36. decision/engine/tenant_manager.py +237 -0
  37. decision/engine/training_data_generator.py +373 -0
  38. decision/engine/trigger_engine.py +323 -0
  39. decision/global/global_rules.yaml +666 -0
  40. decision/journeys/annual_wellness_visit.yaml +58 -0
  41. decision/journeys/gap_closure.yaml +91 -0
  42. decision/journeys/health_risk_assessment.yaml +45 -0
  43. decision/journeys/post_discharge_engagement.yaml +120 -0
  44. decision/journeys/pre_op_optimization.yaml +83 -0
  45. decision/orchestrator/conflict_resolution.yaml +79 -0
  46. decision/orchestrator/contact_caps.yaml +16 -0
  47. decision/orchestrator/enrollment_rules.yaml +53 -0
  48. decision/orchestrator/wedge_priority.yaml +16 -0
  49. decision/orchestrator/wedge_suppression.yaml +78 -0
  50. 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
- *.pdf 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
 
 
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: DCE
3
- emoji: 🏆
4
- colorFrom: indigo
5
  colorTo: red
6
  sdk: docker
7
  pinned: false
8
- short_description: Medical Intent Escalation
 
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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