Skip to content

Commit 35e81ef

Browse files
committed
Add wellness check-in workflow routing
Add a first-class wellness workflow with burnout signal analysis, trend tracking, and execution routing so non-frontdesk use cases can plug into AgentCare cleanly.
1 parent 7b836a2 commit 35e81ef

22 files changed

Lines changed: 1029 additions & 19 deletions

File tree

services/dashboard/app.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from agentcare.doctor import DoctorProfile, load_doctor_schema
2828
from agentcare.extraction import extract_conversation_fields
2929
from agentcare.settings import settings
30-
from agentcare.usecases import process_frontdesk_execution
30+
from agentcare.usecases import process_agentcare_execution
3131

3232

3333
app = FastAPI(title="AgentCare Dashboard", version="0.1.0")
@@ -323,7 +323,7 @@ def _trigger_execution_processing_async(execution_payload: dict[str, Any]) -> bo
323323

324324
def _runner() -> None:
325325
try:
326-
process_frontdesk_execution(
326+
process_agentcare_execution(
327327
execution_payload,
328328
source="dashboard_status_terminal",
329329
automate_actions=True,

services/webhooks/app.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from fastapi import FastAPI
55
from pydantic import BaseModel, ConfigDict
66

7-
from agentcare.usecases import process_frontdesk_execution
7+
from agentcare.usecases import process_agentcare_execution, resolve_execution_workflow
88

99

1010
app = FastAPI(title="AgentCare Webhooks", version="0.1.0")
@@ -35,21 +35,33 @@ def on_bolna_execution(payload: BolnaExecutionPayload) -> dict[str, Any]:
3535
and sends confirmation email when customer email + appointment details exist.
3636
"""
3737
event = payload.model_dump()
38-
res = process_frontdesk_execution(
38+
workflow = resolve_execution_workflow(event)
39+
res = process_agentcare_execution(
3940
event,
4041
source="webhook",
42+
workflow=workflow,
4143
automate_actions=True,
4244
enforce_idempotency=True,
4345
)
4446

4547
return {
4648
"ok": res.ok,
47-
"deduplicated": res.deduplicated,
49+
"workflow": workflow,
50+
"deduplicated": getattr(res, "deduplicated", False),
4851
"execution_id": res.execution_id,
49-
"customer_id": res.customer_id,
50-
"email_sent": bool(res.email_confirmation),
51-
"email_result": res.email_confirmation,
52-
"analytics_store": res.analytics_store,
53-
"enriched_fields": res.extracted_data,
52+
"customer_id": getattr(res, "customer_id", None),
53+
"employee_id": getattr(res, "employee_id", None),
54+
"email_sent": bool(getattr(res, "email_confirmation", None)),
55+
"email_result": getattr(res, "email_confirmation", None),
56+
"analytics_store": getattr(res, "analytics_store", None),
57+
"enriched_fields": getattr(res, "extracted_data", getattr(res, "extraction", {})),
58+
"wellness": {
59+
"analysis": getattr(res, "analysis", None),
60+
"trend": getattr(res, "trend", None),
61+
"policy": getattr(res, "policy", None),
62+
"persisted": getattr(res, "persisted", None),
63+
}
64+
if workflow == "wellness_checkin"
65+
else None,
5466
}
5567

src/agentcare/analysis/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
from agentcare.analysis.burnout import BurnoutAnalysis, analyze_burnout_context
12
from agentcare.analysis.healthcare import HealthcareAnalysis, analyze_healthcare_context
3+
from agentcare.analysis.trend import TrendResult, detect_trend
24

3-
__all__ = ["HealthcareAnalysis", "analyze_healthcare_context"]
5+
__all__ = [
6+
"HealthcareAnalysis",
7+
"analyze_healthcare_context",
8+
"BurnoutAnalysis",
9+
"analyze_burnout_context",
10+
"TrendResult",
11+
"detect_trend",
12+
]

src/agentcare/analysis/burnout.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""
2+
Burnout signal analysis aligned to Maslach Burnout Inventory dimensions.
3+
4+
This is a signal layer over conversational text. It does not diagnose burnout;
5+
it produces conservative, evidence-anchored features for wellness routing.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import re
11+
from dataclasses import dataclass, field
12+
13+
14+
@dataclass
15+
class BurnoutAnalysis:
16+
ee_hits: int = 0
17+
dp_hits: int = 0
18+
pa_hits: int = 0
19+
ee_tags: list[str] = field(default_factory=list)
20+
dp_tags: list[str] = field(default_factory=list)
21+
pa_tags: list[str] = field(default_factory=list)
22+
ee_score: float = 0.0
23+
dp_score: float = 0.0
24+
pa_score: float = 0.0
25+
composite_score: float = 0.0
26+
risk_band: str = "low"
27+
recommended_action: str = "continue_monitoring"
28+
high_acuity_flag: bool = False
29+
30+
31+
_EE_PATTERNS: list[tuple[str, str]] = [
32+
(r"\bexhaust(ed|ing|ion)\b", "exhaustion_explicit"),
33+
(r"\bdrained\b", "drained"),
34+
(r"\bburn(ed|t)?[\s-]?out\b", "burnt_out_explicit"),
35+
(r"\b(can'?t|cannot) (cope|keep up|do this anymore)\b", "cant_cope"),
36+
(r"\boverwhelm(ed|ing)\b", "overwhelmed"),
37+
(r"\b(too much|too many) (work|things|deadlines|meetings)\b", "workload_excess"),
38+
(r"\bworking (late|weekends|all the time|nights)\b", "extended_hours"),
39+
(r"\bno (time|energy|break)\b", "no_recovery"),
40+
(r"\bnot sleeping\b", "sleep_disruption"),
41+
(r"\b(can'?t|cannot) (sleep|relax|switch off|disconnect)\b", "cant_disconnect"),
42+
(r"\b(constantly|always) (tired|fatigue)\b", "chronic_fatigue"),
43+
(r"\bno energy\b", "low_energy"),
44+
(r"\bemotionally (drained|spent|empty)\b", "emotional_depletion"),
45+
]
46+
47+
_DP_PATTERNS: list[tuple[str, str]] = [
48+
(r"\b(don'?t|do not) care anymore\b", "apathy_explicit"),
49+
(r"\bjust going through the motions\b", "going_through_motions"),
50+
(r"\bcheck(ing|ed) out\b", "checked_out"),
51+
(r"\bdisengag(ed|ing)\b", "disengaged"),
52+
(r"\bcynical\b", "cynicism_explicit"),
53+
(r"\bpointless\b", "pointless"),
54+
(r"\b(hate|dread) (work|my job|going in|mondays)\b", "work_aversion"),
55+
(r"\bwhat'?s the point\b", "questioning_purpose"),
56+
(r"\b(not|no longer) (engaged|invested|excited)\b", "loss_of_engagement"),
57+
(r"\b(distant|detached) from (team|colleagues|clients|patients)\b", "interpersonal_detachment"),
58+
(r"\b(robotic|mechanical|automatic) at work\b", "mechanical_work"),
59+
]
60+
61+
_PA_PATTERNS: list[tuple[str, str]] = [
62+
(r"\b(not|no longer) (effective|making (a )?difference|productive)\b", "ineffective"),
63+
(r"\bnothing (i do )?matters\b", "futility"),
64+
(r"\b(failing|failure) at (work|my job)\b", "perceived_failure"),
65+
(r"\b(can'?t|cannot) (focus|concentrate|finish|get anything done)\b", "performance_decline"),
66+
(r"\b(useless|worthless) at work\b", "self_devaluation"),
67+
(r"\bnot good enough\b", "self_doubt"),
68+
(r"\b(impostor|imposter)\b", "impostor"),
69+
(r"\bmissing deadlines\b", "missed_deadlines"),
70+
(r"\b(my )?work quality (has )?(dropped|slipped|declined)\b", "quality_decline"),
71+
(r"\b(losing|lost) (confidence|motivation)\b", "motivation_loss"),
72+
]
73+
74+
_HIGH_ACUITY_PATTERNS: list[tuple[str, str]] = [
75+
(r"\bsuicid(al|e)\b", "suicidal_ideation"),
76+
(r"\bself[- ]harm\b", "self_harm"),
77+
(r"\b(want|wish) to (die|disappear|not (exist|wake up))\b", "wish_to_die"),
78+
(r"\bend(ing)? it all\b", "end_it_all"),
79+
(r"\bno (way|reason) to (live|go on)\b", "hopelessness_acute"),
80+
(r"\b(can'?t|cannot) take (it|this) anymore\b", "breaking_point"),
81+
(r"\bhaving a breakdown\b", "breakdown"),
82+
]
83+
84+
85+
def _scan(text: str, patterns: list[tuple[str, str]]) -> tuple[int, list[str]]:
86+
hits = 0
87+
tags: list[str] = []
88+
seen: set[str] = set()
89+
for pattern, tag in patterns:
90+
if re.search(pattern, text, flags=re.I):
91+
hits += 1
92+
if tag not in seen:
93+
tags.append(tag)
94+
seen.add(tag)
95+
return hits, tags
96+
97+
98+
def _hits_to_score(hits: int) -> float:
99+
if hits <= 0:
100+
return 0.0
101+
return round(min(10.0, 10.0 * (1 - 0.55**hits)), 2)
102+
103+
104+
def _fuse(regex_score: float, llm_score: float | None) -> float:
105+
if llm_score is None:
106+
return regex_score
107+
return round(min(10.0, max(0.0, 0.75 * float(llm_score) + 0.25 * regex_score)), 2)
108+
109+
110+
def _band(score: float, high_acuity: bool) -> tuple[str, str]:
111+
if high_acuity:
112+
return "high", "confidential_human_followup_immediate"
113+
if score >= 7.0:
114+
return "high", "confidential_human_followup"
115+
if score >= 4.0:
116+
return "medium", "manager_check_in_suggested"
117+
return "low", "continue_monitoring"
118+
119+
120+
def analyze_burnout_context(
121+
*,
122+
transcript: str | None,
123+
reason: str | None = None,
124+
llm_ee: float | None = None,
125+
llm_dp: float | None = None,
126+
llm_pa: float | None = None,
127+
) -> BurnoutAnalysis:
128+
text = " ".join([reason or "", transcript or ""]).strip().lower()
129+
if not text:
130+
return BurnoutAnalysis()
131+
132+
ee_hits, ee_tags = _scan(text, _EE_PATTERNS)
133+
dp_hits, dp_tags = _scan(text, _DP_PATTERNS)
134+
pa_hits, pa_tags = _scan(text, _PA_PATTERNS)
135+
acuity_hits, acuity_tags = _scan(text, _HIGH_ACUITY_PATTERNS)
136+
137+
ee_score = _fuse(_hits_to_score(ee_hits), llm_ee)
138+
dp_score = _fuse(_hits_to_score(dp_hits), llm_dp)
139+
pa_score = _fuse(_hits_to_score(pa_hits), llm_pa)
140+
composite = round(0.45 * ee_score + 0.35 * dp_score + 0.20 * pa_score, 2)
141+
142+
high_acuity = acuity_hits > 0
143+
band, action = _band(composite, high_acuity)
144+
if high_acuity:
145+
ee_tags = list(dict.fromkeys(ee_tags + acuity_tags))
146+
147+
return BurnoutAnalysis(
148+
ee_hits=ee_hits,
149+
dp_hits=dp_hits,
150+
pa_hits=pa_hits,
151+
ee_tags=ee_tags[:6],
152+
dp_tags=dp_tags[:6],
153+
pa_tags=pa_tags[:6],
154+
ee_score=ee_score,
155+
dp_score=dp_score,
156+
pa_score=pa_score,
157+
composite_score=composite,
158+
risk_band=band,
159+
recommended_action=action,
160+
high_acuity_flag=high_acuity,
161+
)

src/agentcare/analysis/trend.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Longitudinal trend detection over per-person wellness score history."""
2+
3+
from __future__ import annotations
4+
5+
import math
6+
from dataclasses import dataclass
7+
8+
9+
@dataclass
10+
class TrendResult:
11+
n: int
12+
slope_per_session: float
13+
mk_statistic: float
14+
mk_z: float
15+
mk_p_value: float
16+
direction: str
17+
consecutive_deteriorating: int
18+
triage_trigger: bool
19+
20+
21+
def _ols_slope(values: list[float]) -> float:
22+
n = len(values)
23+
if n < 2:
24+
return 0.0
25+
x_mean = (n - 1) / 2.0
26+
y_mean = sum(values) / n
27+
numerator = 0.0
28+
denominator = 0.0
29+
for i, value in enumerate(values):
30+
dx = i - x_mean
31+
numerator += dx * (value - y_mean)
32+
denominator += dx * dx
33+
return numerator / denominator if denominator else 0.0
34+
35+
36+
def _norm_cdf(x: float) -> float:
37+
return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))
38+
39+
40+
def _mann_kendall(values: list[float]) -> tuple[float, float, float]:
41+
n = len(values)
42+
if n < 3:
43+
return 0.0, 0.0, 1.0
44+
45+
statistic = 0
46+
for i in range(n - 1):
47+
for j in range(i + 1, n):
48+
if values[j] > values[i]:
49+
statistic += 1
50+
elif values[j] < values[i]:
51+
statistic -= 1
52+
53+
counts: dict[float, int] = {}
54+
for value in values:
55+
counts[value] = counts.get(value, 0) + 1
56+
ties_term = sum(t * (t - 1) * (2 * t + 5) for t in counts.values() if t > 1)
57+
variance = (n * (n - 1) * (2 * n + 5) - ties_term) / 18.0
58+
if variance <= 0:
59+
return float(statistic), 0.0, 1.0
60+
61+
if statistic > 0:
62+
z_score = (statistic - 1) / math.sqrt(variance)
63+
elif statistic < 0:
64+
z_score = (statistic + 1) / math.sqrt(variance)
65+
else:
66+
z_score = 0.0
67+
68+
p_value = 2.0 * (1.0 - _norm_cdf(abs(z_score)))
69+
return float(statistic), float(z_score), float(p_value)
70+
71+
72+
def _consecutive_deteriorating(values: list[float]) -> int:
73+
if len(values) < 2:
74+
return 0
75+
run = 0
76+
for i in range(len(values) - 1, 0, -1):
77+
if values[i] > values[i - 1]:
78+
run += 1
79+
else:
80+
break
81+
return run
82+
83+
84+
def detect_trend(
85+
composite_scores: list[float],
86+
*,
87+
deterioration_run_threshold: int = 3,
88+
score_threshold: float = 7.0,
89+
p_value_threshold: float = 0.10,
90+
) -> TrendResult:
91+
n = len(composite_scores)
92+
if n == 0:
93+
return TrendResult(0, 0.0, 0.0, 0.0, 1.0, "stable", 0, False)
94+
95+
slope = _ols_slope(composite_scores)
96+
statistic, z_score, p_value = _mann_kendall(composite_scores)
97+
98+
if p_value < p_value_threshold:
99+
direction = "deteriorating" if statistic > 0 else "improving" if statistic < 0 else "stable"
100+
elif slope >= 0.5:
101+
direction = "deteriorating"
102+
elif slope <= -0.5:
103+
direction = "improving"
104+
else:
105+
direction = "stable"
106+
107+
consecutive = _consecutive_deteriorating(composite_scores)
108+
latest = composite_scores[-1]
109+
triage = (
110+
consecutive >= deterioration_run_threshold
111+
or latest >= score_threshold
112+
or (direction == "deteriorating" and latest >= 5.0)
113+
)
114+
115+
return TrendResult(
116+
n=n,
117+
slope_per_session=round(slope, 3),
118+
mk_statistic=round(statistic, 3),
119+
mk_z=round(z_score, 3),
120+
mk_p_value=round(p_value, 4),
121+
direction=direction,
122+
consecutive_deteriorating=consecutive,
123+
triage_trigger=triage,
124+
)

0 commit comments

Comments
 (0)